This commit is contained in:
Nathan Flurry 2026-02-09 18:53:00 -08:00
parent a33b1323ff
commit 2ba630c180
264 changed files with 18559 additions and 51021 deletions

View file

@ -0,0 +1,37 @@
{
"name": "acp-http-client",
"version": "0.1.0",
"description": "Protocol-faithful ACP JSON-RPC over streamable HTTP client.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/rivet-dev/sandbox-agent"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View file

@ -0,0 +1,574 @@
import {
ClientSideConnection,
PROTOCOL_VERSION,
type AnyMessage,
type AuthenticateRequest,
type AuthenticateResponse,
type CancelNotification,
type Client,
type ForkSessionRequest,
type ForkSessionResponse,
type InitializeRequest,
type InitializeResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type NewSessionRequest,
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type RequestPermissionOutcome,
type RequestPermissionRequest,
type RequestPermissionResponse,
type ResumeSessionRequest,
type ResumeSessionResponse,
type SessionNotification,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type SetSessionModelRequest,
type SetSessionModelResponse,
type SetSessionModeRequest,
type SetSessionModeResponse,
type Stream,
} from "@agentclientprotocol/sdk";
const ACP_PATH = "/v2/rpc";
export interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
[key: string]: unknown;
}
export type AcpEnvelopeDirection = "inbound" | "outbound";
export type AcpEnvelopeObserver = (envelope: AnyMessage, direction: AcpEnvelopeDirection) => void;
export interface AcpHttpClientOptions {
baseUrl: string;
token?: string;
fetch?: typeof fetch;
headers?: HeadersInit;
client?: Partial<Client>;
onEnvelope?: AcpEnvelopeObserver;
}
export class AcpHttpError extends Error {
readonly status: number;
readonly problem?: ProblemDetails;
readonly response: Response;
constructor(status: number, problem: ProblemDetails | undefined, response: Response) {
super(problem?.title ?? `Request failed with status ${status}`);
this.name = "AcpHttpError";
this.status = status;
this.problem = problem;
this.response = response;
}
}
export class AcpHttpClient {
private readonly transport: StreamableHttpAcpTransport;
private readonly connection: ClientSideConnection;
constructor(options: AcpHttpClientOptions) {
const fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
if (!fetcher) {
throw new Error("Fetch API is not available; provide a fetch implementation.");
}
this.transport = new StreamableHttpAcpTransport({
baseUrl: options.baseUrl,
fetcher,
token: options.token,
defaultHeaders: options.headers,
onEnvelope: options.onEnvelope,
});
const clientHandlers = buildClientHandlers(options.client);
this.connection = new ClientSideConnection(() => clientHandlers, this.transport.stream);
}
get clientId(): string | undefined {
return this.transport.clientId ?? undefined;
}
async initialize(request: Partial<InitializeRequest> = {}): Promise<InitializeResponse> {
const params: InitializeRequest = {
protocolVersion: request.protocolVersion ?? PROTOCOL_VERSION,
clientCapabilities: request.clientCapabilities,
clientInfo: request.clientInfo ?? {
name: "acp-http-client",
version: "v2",
},
};
if (request._meta !== undefined) {
params._meta = request._meta;
}
return this.connection.initialize(params);
}
async authenticate(request: AuthenticateRequest): Promise<AuthenticateResponse> {
return this.connection.authenticate(request);
}
async newSession(request: NewSessionRequest): Promise<NewSessionResponse> {
return this.connection.newSession(request);
}
async loadSession(request: LoadSessionRequest): Promise<LoadSessionResponse> {
return this.connection.loadSession(request);
}
async prompt(request: PromptRequest): Promise<PromptResponse> {
return this.connection.prompt(request);
}
async cancel(notification: CancelNotification): Promise<void> {
return this.connection.cancel(notification);
}
async setSessionMode(request: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
return this.connection.setSessionMode(request);
}
async setSessionConfigOption(
request: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse> {
return this.connection.setSessionConfigOption(request);
}
async unstableListSessions(request: ListSessionsRequest): Promise<ListSessionsResponse> {
return this.connection.unstable_listSessions(request);
}
async unstableForkSession(request: ForkSessionRequest): Promise<ForkSessionResponse> {
return this.connection.unstable_forkSession(request);
}
async unstableResumeSession(request: ResumeSessionRequest): Promise<ResumeSessionResponse> {
return this.connection.unstable_resumeSession(request);
}
async unstableSetSessionModel(
request: SetSessionModelRequest,
): Promise<SetSessionModelResponse | void> {
return this.connection.unstable_setSessionModel(request);
}
async extMethod(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
return this.connection.extMethod(method, params);
}
async extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.connection.extNotification(method, params);
}
async disconnect(): Promise<void> {
await this.transport.close();
}
get closed(): Promise<void> {
return this.connection.closed;
}
get signal(): AbortSignal {
return this.connection.signal;
}
get clientSideConnection(): ClientSideConnection {
return this.connection;
}
}
type StreamableHttpAcpTransportOptions = {
baseUrl: string;
fetcher: typeof fetch;
token?: string;
defaultHeaders?: HeadersInit;
onEnvelope?: AcpEnvelopeObserver;
};
class StreamableHttpAcpTransport {
readonly stream: Stream;
private readonly baseUrl: string;
private readonly fetcher: typeof fetch;
private readonly token?: string;
private readonly defaultHeaders?: HeadersInit;
private readonly onEnvelope?: AcpEnvelopeObserver;
private readableController: ReadableStreamDefaultController<AnyMessage> | null = null;
private sseAbortController: AbortController | null = null;
private sseLoop: Promise<void> | null = null;
private lastEventId: string | null = null;
private closed = false;
private closingPromise: Promise<void> | null = null;
private _clientId: string | null = null;
constructor(options: StreamableHttpAcpTransportOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.fetcher = options.fetcher;
this.token = options.token;
this.defaultHeaders = options.defaultHeaders;
this.onEnvelope = options.onEnvelope;
this.stream = {
readable: new ReadableStream<AnyMessage>({
start: (controller) => {
this.readableController = controller;
},
cancel: async () => {
await this.close();
},
}),
writable: new WritableStream<AnyMessage>({
write: async (message) => {
await this.writeMessage(message);
},
close: async () => {
await this.close();
},
abort: async () => {
await this.close();
},
}),
};
}
get clientId(): string | null {
return this._clientId;
}
async close(): Promise<void> {
if (this.closingPromise) {
return this.closingPromise;
}
this.closingPromise = this.closeImpl();
return this.closingPromise;
}
private async closeImpl(): Promise<void> {
if (this.closed) {
return;
}
this.closed = true;
if (this.sseAbortController) {
this.sseAbortController.abort();
}
const clientId = this._clientId;
if (clientId) {
try {
const response = await this.fetcher(`${this.baseUrl}${ACP_PATH}`, {
method: "DELETE",
headers: this.buildHeaders({
"x-acp-connection-id": clientId,
Accept: "application/json",
}),
});
if (!response.ok && response.status !== 404) {
throw new AcpHttpError(response.status, await readProblem(response), response);
}
} catch {
// Ignore close errors; close must be best effort.
}
}
try {
this.readableController?.close();
} catch {
// no-op
}
this.readableController = null;
}
private async writeMessage(message: AnyMessage): Promise<void> {
if (this.closed) {
throw new Error("ACP client is closed");
}
this.observeEnvelope(message, "outbound");
const headers = this.buildHeaders({
"Content-Type": "application/json",
Accept: "application/json",
});
if (this._clientId) {
headers.set("x-acp-connection-id", this._clientId);
}
const response = await this.fetcher(`${this.baseUrl}${ACP_PATH}`, {
method: "POST",
headers,
body: JSON.stringify(message),
});
if (!response.ok) {
throw new AcpHttpError(response.status, await readProblem(response), response);
}
const responseClientId = response.headers.get("x-acp-connection-id");
if (responseClientId && responseClientId !== this._clientId) {
this._clientId = responseClientId;
this.ensureSseLoop();
}
if (response.status === 200) {
const text = await response.text();
if (text.trim()) {
const envelope = JSON.parse(text) as AnyMessage;
this.pushInbound(envelope);
}
}
}
private ensureSseLoop(): void {
if (this.sseLoop || this.closed || !this._clientId) {
return;
}
this.sseLoop = this.runSseLoop().finally(() => {
this.sseLoop = null;
});
}
private async runSseLoop(): Promise<void> {
while (!this.closed && this._clientId) {
this.sseAbortController = new AbortController();
const headers = this.buildHeaders({
"x-acp-connection-id": this._clientId,
Accept: "text/event-stream",
});
if (this.lastEventId) {
headers.set("Last-Event-ID", this.lastEventId);
}
try {
const response = await this.fetcher(`${this.baseUrl}${ACP_PATH}`, {
method: "GET",
headers,
signal: this.sseAbortController.signal,
});
if (!response.ok) {
throw new AcpHttpError(response.status, await readProblem(response), response);
}
if (!response.body) {
throw new Error("SSE stream is not readable in this environment.");
}
await this.consumeSse(response.body);
if (!this.closed) {
await delay(150);
}
} catch (error) {
if (this.closed || isAbortError(error)) {
return;
}
this.failReadable(error);
return;
}
}
}
private async consumeSse(body: ReadableStream<Uint8Array>): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (!this.closed) {
const { done, value } = await reader.read();
if (done) {
return;
}
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
let separatorIndex = buffer.indexOf("\n\n");
while (separatorIndex !== -1) {
const eventChunk = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
this.processSseEvent(eventChunk);
separatorIndex = buffer.indexOf("\n\n");
}
}
} finally {
reader.releaseLock();
}
}
private processSseEvent(chunk: string): void {
if (!chunk.trim()) {
return;
}
let eventName = "message";
let eventId: string | null = null;
const dataLines: string[] = [];
for (const line of chunk.split("\n")) {
if (!line || line.startsWith(":")) {
continue;
}
if (line.startsWith("event:")) {
eventName = line.slice(6).trim();
continue;
}
if (line.startsWith("id:")) {
eventId = line.slice(3).trim();
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
}
if (eventId) {
this.lastEventId = eventId;
}
if (eventName !== "message" || dataLines.length === 0) {
return;
}
const payloadText = dataLines.join("\n");
if (!payloadText.trim()) {
return;
}
const envelope = JSON.parse(payloadText) as AnyMessage;
this.pushInbound(envelope);
}
private pushInbound(envelope: AnyMessage): void {
if (this.closed) {
return;
}
this.observeEnvelope(envelope, "inbound");
try {
this.readableController?.enqueue(envelope);
} catch (error) {
this.failReadable(error);
}
}
private failReadable(error: unknown): void {
if (this.closed) {
return;
}
this.closed = true;
try {
this.readableController?.error(error);
} catch {
// no-op
}
this.readableController = null;
if (this.sseAbortController) {
this.sseAbortController.abort();
}
}
private observeEnvelope(message: AnyMessage, direction: AcpEnvelopeDirection): void {
if (!this.onEnvelope) {
return;
}
this.onEnvelope(message, direction);
}
private buildHeaders(extra?: HeadersInit): Headers {
const headers = new Headers(this.defaultHeaders ?? undefined);
if (this.token) {
headers.set("Authorization", `Bearer ${this.token}`);
}
if (extra) {
const merged = new Headers(extra);
merged.forEach((value, key) => headers.set(key, value));
}
return headers;
}
}
function buildClientHandlers(client?: Partial<Client>): Client {
const fallbackPermission: RequestPermissionResponse = {
outcome: {
outcome: "cancelled",
} as RequestPermissionOutcome,
};
return {
requestPermission: async (request: RequestPermissionRequest) => {
if (client?.requestPermission) {
return client.requestPermission(request);
}
return fallbackPermission;
},
sessionUpdate: async (notification: SessionNotification) => {
if (client?.sessionUpdate) {
await client.sessionUpdate(notification);
}
},
readTextFile: client?.readTextFile,
writeTextFile: client?.writeTextFile,
createTerminal: client?.createTerminal,
terminalOutput: client?.terminalOutput,
releaseTerminal: client?.releaseTerminal,
waitForTerminalExit: client?.waitForTerminalExit,
killTerminal: client?.killTerminal,
extMethod: client?.extMethod,
extNotification: client?.extNotification,
};
}
async function readProblem(response: Response): Promise<ProblemDetails | undefined> {
try {
const text = await response.clone().text();
if (!text) {
return undefined;
}
return JSON.parse(text) as ProblemDetails;
} catch {
return undefined;
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export type * from "@agentclientprotocol/sdk";

View file

@ -0,0 +1,135 @@
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { AcpHttpClient, type SessionNotification } from "../src/index.ts";
import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
function findBinary(): string | null {
if (process.env.SANDBOX_AGENT_BIN) {
return process.env.SANDBOX_AGENT_BIN;
}
const cargoPaths = [
resolve(__dirname, "../../../target/debug/sandbox-agent"),
resolve(__dirname, "../../../target/release/sandbox-agent"),
];
for (const p of cargoPaths) {
if (existsSync(p)) {
return p;
}
}
return null;
}
const BINARY_PATH = findBinary();
if (!BINARY_PATH) {
throw new Error(
"sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.",
);
}
if (!process.env.SANDBOX_AGENT_BIN) {
process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor<T>(
fn: () => T | undefined | null,
timeoutMs = 5000,
stepMs = 25,
): Promise<T> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = fn();
if (value !== undefined && value !== null) {
return value;
}
await sleep(stepMs);
}
throw new Error("timed out waiting for condition");
}
describe("AcpHttpClient integration", () => {
let handle: SandboxAgentSpawnHandle;
let baseUrl: string;
let token: string;
beforeAll(async () => {
handle = await spawnSandboxAgent({
enabled: true,
log: "silent",
timeoutMs: 30000,
});
baseUrl = handle.baseUrl;
token = handle.token;
});
afterAll(async () => {
await handle.dispose();
});
it("runs initialize/newSession/prompt against real /v2/rpc", async () => {
const updates: SessionNotification[] = [];
const client = new AcpHttpClient({
baseUrl,
token,
client: {
sessionUpdate: async (notification) => {
updates.push(notification);
},
},
});
const initialize = await client.initialize({
_meta: {
"sandboxagent.dev": {
agent: "mock",
},
},
});
expect(initialize.protocolVersion).toBeTruthy();
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: {
"sandboxagent.dev": {
agent: "mock",
},
},
});
expect(session.sessionId).toBeTruthy();
const prompt = await client.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "acp package integration" }],
});
expect(prompt.stopReason).toBe("end_turn");
await waitFor(() => {
const text = updates
.flatMap((entry) => {
if (entry.update.sessionUpdate !== "agent_message_chunk") {
return [];
}
const content = entry.update.content;
if (content.type !== "text") {
return [];
}
return [content.text];
})
.join("");
return text.includes("mock: acp package integration") ? text : undefined;
});
await client.disconnect();
});
});

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
});

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
},
});

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-shared",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "Shared helpers for sandbox-agent CLI and SDK",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "CLI for sandbox-agent - run AI coding agents in sandboxes",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-darwin-arm64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "sandbox-agent CLI binary for macOS ARM64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-darwin-x64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "sandbox-agent CLI binary for macOS x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-linux-arm64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "sandbox-agent CLI binary for Linux arm64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-linux-x64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "sandbox-agent CLI binary for Linux x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-win32-x64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "sandbox-agent CLI binary for Windows x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "Gigacode CLI (sandbox-agent with OpenCode attach by default)",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-darwin-arm64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "gigacode CLI binary for macOS arm64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-darwin-x64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "gigacode CLI binary for macOS x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-linux-arm64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "gigacode CLI binary for Linux arm64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-linux-x64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "gigacode CLI binary for Linux x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-win32-x64",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "gigacode CLI binary for Windows x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "sandbox-agent",
"version": "0.1.12-rc.1",
"version": "0.2.0",
"description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.",
"license": "Apache-2.0",
"repository": {
@ -17,6 +17,7 @@
}
},
"dependencies": {
"acp-http-client": "workspace:*",
"@sandbox-agent/cli-shared": "workspace:*"
},
"files": [
@ -26,9 +27,9 @@
"generate:openapi": "SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p sandbox-agent-openapi-gen -- --out ../../docs/openapi.json",
"generate:types": "openapi-typescript ../../docs/openapi.json -o src/generated/openapi.ts",
"generate": "pnpm run generate:openapi && pnpm run generate:types",
"build": "if [ -z \"$SKIP_OPENAPI_GEN\" ]; then pnpm run generate:openapi; fi && pnpm run generate:types && tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "pnpm --filter acp-http-client build && if [ -z \"$SKIP_OPENAPI_GEN\" ]; then pnpm run generate:openapi; fi && pnpm run generate:types && tsup",
"typecheck": "pnpm --filter acp-http-client build && tsc --noEmit",
"test": "pnpm --filter acp-http-client build && vitest run",
"test:watch": "vitest"
},
"devDependencies": {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,76 +1,48 @@
export { SandboxAgent, SandboxAgentError } from "./client.ts";
export { buildInspectorUrl } from "./inspector.ts";
export type { InspectorUrlOptions } from "./inspector.ts";
export type {
SandboxAgentConnectOptions,
SandboxAgentStartOptions,
export {
AlreadyConnectedError,
NotConnectedError,
SandboxAgent,
SandboxAgentClient,
SandboxAgentError,
} from "./client.ts";
export { buildInspectorUrl } from "./inspector.ts";
export type {
AgentEvent,
AgentUnparsedNotification,
ListModelsResponse,
PermissionRequest,
PermissionResponse,
SandboxAgentClientConnectOptions,
SandboxAgentClientOptions,
SandboxAgentConnectOptions,
SandboxAgentEventObserver,
SandboxAgentStartOptions,
SandboxMetadata,
SessionCreateRequest,
SessionModelInfo,
SessionUpdateNotification,
} from "./client.ts";
export type {
InspectorUrlOptions,
} from "./inspector.ts";
export type {
AgentCapabilities,
AgentInfo,
AgentInstallArtifact,
AgentInstallRequest,
AgentInstallResponse,
AgentListResponse,
AgentModelInfo,
AgentModelsResponse,
AgentModeInfo,
AgentModesResponse,
AgentUnparsedData,
ContentPart,
CreateSessionRequest,
CreateSessionResponse,
ErrorData,
EventSource,
EventsQuery,
EventsResponse,
FileAction,
FsActionResponse,
FsDeleteQuery,
FsEntriesQuery,
FsEntry,
FsEntryType,
FsMoveRequest,
FsMoveResponse,
FsPathQuery,
FsSessionQuery,
FsStat,
FsUploadBatchQuery,
FsUploadBatchResponse,
FsWriteResponse,
HealthResponse,
ItemDeltaData,
ItemEventData,
ItemKind,
ItemRole,
ItemStatus,
MessageAttachment,
MessageRequest,
PermissionEventData,
PermissionReply,
PermissionReplyRequest,
PermissionStatus,
ProblemDetails,
QuestionEventData,
QuestionReplyRequest,
QuestionStatus,
ReasoningVisibility,
SessionEndReason,
SessionEndedData,
SessionInfo,
SessionListResponse,
SessionStartedData,
TerminatedBy,
TurnStreamQuery,
UniversalEvent,
UniversalEventData,
UniversalEventType,
UniversalItem,
McpServerConfig,
McpCommand,
McpRemoteTransport,
McpOAuthConfig,
McpOAuthConfigOrDisabled,
SkillSource,
SkillsConfig,
SessionTerminateResponse,
} from "./types.ts";
export type { components, paths } from "./generated/openapi.ts";
export type { SandboxAgentSpawnOptions, SandboxAgentSpawnLogMode } from "./spawn.ts";
export type {
SandboxAgentSpawnLogMode,
SandboxAgentSpawnOptions,
} from "./spawn.ts";

View file

@ -207,7 +207,7 @@ async function waitForHealth(
throw new Error("sandbox-agent exited before becoming healthy.");
}
try {
const response = await fetcher(`${baseUrl}/v1/health`, {
const response = await fetcher(`${baseUrl}/v2/health`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {

View file

@ -1,70 +1,282 @@
import type { components } from "./generated/openapi.ts";
export interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
[key: string]: unknown;
}
type S = components["schemas"];
export type HealthStatus = "healthy" | "degraded" | "unhealthy" | "ok";
export type AgentCapabilities = S["AgentCapabilities"];
export type AgentInfo = S["AgentInfo"];
export type AgentInstallRequest = S["AgentInstallRequest"];
export type AgentListResponse = S["AgentListResponse"];
export type AgentModelInfo = S["AgentModelInfo"];
export type AgentModelsResponse = S["AgentModelsResponse"];
export type AgentModeInfo = S["AgentModeInfo"];
export type AgentModesResponse = S["AgentModesResponse"];
export type AgentUnparsedData = S["AgentUnparsedData"];
export type ContentPart = S["ContentPart"];
export type CreateSessionRequest = S["CreateSessionRequest"];
export type CreateSessionResponse = S["CreateSessionResponse"];
export type ErrorData = S["ErrorData"];
export type EventSource = S["EventSource"];
export type EventsQuery = S["EventsQuery"];
export type EventsResponse = S["EventsResponse"];
export type FileAction = S["FileAction"];
export type FsActionResponse = S["FsActionResponse"];
export type FsDeleteQuery = S["FsDeleteQuery"];
export type FsEntriesQuery = S["FsEntriesQuery"];
export type FsEntry = S["FsEntry"];
export type FsEntryType = S["FsEntryType"];
export type FsMoveRequest = S["FsMoveRequest"];
export type FsMoveResponse = S["FsMoveResponse"];
export type FsPathQuery = S["FsPathQuery"];
export type FsSessionQuery = S["FsSessionQuery"];
export type FsStat = S["FsStat"];
export type FsUploadBatchQuery = S["FsUploadBatchQuery"];
export type FsUploadBatchResponse = S["FsUploadBatchResponse"];
export type FsWriteResponse = S["FsWriteResponse"];
export type HealthResponse = S["HealthResponse"];
export type ItemDeltaData = S["ItemDeltaData"];
export type ItemEventData = S["ItemEventData"];
export type ItemKind = S["ItemKind"];
export type ItemRole = S["ItemRole"];
export type ItemStatus = S["ItemStatus"];
export type MessageRequest = S["MessageRequest"];
export type MessageAttachment = S["MessageAttachment"];
export type PermissionEventData = S["PermissionEventData"];
export type PermissionReply = S["PermissionReply"];
export type PermissionReplyRequest = S["PermissionReplyRequest"];
export type PermissionStatus = S["PermissionStatus"];
export type ProblemDetails = S["ProblemDetails"];
export type QuestionEventData = S["QuestionEventData"];
export type QuestionReplyRequest = S["QuestionReplyRequest"];
export type QuestionStatus = S["QuestionStatus"];
export type ReasoningVisibility = S["ReasoningVisibility"];
export type SessionEndReason = S["SessionEndReason"];
export type SessionEndedData = S["SessionEndedData"];
export type SessionInfo = S["SessionInfo"];
export type SessionListResponse = S["SessionListResponse"];
export type SessionStartedData = S["SessionStartedData"];
export type TerminatedBy = S["TerminatedBy"];
export type TurnStreamQuery = S["TurnStreamQuery"];
export type UniversalEvent = S["UniversalEvent"];
export type UniversalEventData = S["UniversalEventData"];
export type UniversalEventType = S["UniversalEventType"];
export type UniversalItem = S["UniversalItem"];
export interface AgentHealthInfo {
agent: string;
installed: boolean;
running: boolean;
[key: string]: unknown;
}
export type McpServerConfig = S["McpServerConfig"];
export type McpCommand = S["McpCommand"];
export type McpRemoteTransport = S["McpRemoteTransport"];
export type McpOAuthConfig = S["McpOAuthConfig"];
export type McpOAuthConfigOrDisabled = S["McpOAuthConfigOrDisabled"];
export type SkillSource = S["SkillSource"];
export type SkillsConfig = S["SkillsConfig"];
export interface HealthResponse {
status: HealthStatus | string;
version: string;
uptime_ms: number;
agents: AgentHealthInfo[];
// Backward-compatible field from earlier v2 payloads.
api_version?: string;
[key: string]: unknown;
}
export type ServerStatus = "running" | "stopped" | "error";
export interface ServerStatusInfo {
status: ServerStatus | string;
base_url?: string | null;
baseUrl?: string | null;
uptime_ms?: number | null;
uptimeMs?: number | null;
restart_count?: number;
restartCount?: number;
last_error?: string | null;
lastError?: string | null;
[key: string]: unknown;
}
export interface AgentModelInfo {
id?: string;
model_id?: string;
modelId?: string;
name?: string | null;
description?: string | null;
default_variant?: string | null;
defaultVariant?: string | null;
variants?: string[] | null;
[key: string]: unknown;
}
export interface AgentModeInfo {
id: string;
name: string;
description: string;
[key: string]: unknown;
}
export interface AgentCapabilities {
plan_mode?: boolean;
permissions?: boolean;
questions?: boolean;
tool_calls?: boolean;
tool_results?: boolean;
text_messages?: boolean;
images?: boolean;
file_attachments?: boolean;
session_lifecycle?: boolean;
error_events?: boolean;
reasoning?: boolean;
status?: boolean;
command_execution?: boolean;
file_changes?: boolean;
mcp_tools?: boolean;
streaming_deltas?: boolean;
item_started?: boolean;
shared_process?: boolean;
unstable_methods?: boolean;
[key: string]: unknown;
}
export interface AgentInfo {
id: string;
installed?: boolean;
credentials_available?: boolean;
native_required?: boolean;
native_installed?: boolean;
native_version?: string | null;
agent_process_installed?: boolean;
agent_process_source?: string | null;
agent_process_version?: string | null;
version?: string | null;
path?: string | null;
server_status?: ServerStatusInfo | null;
models?: AgentModelInfo[] | null;
default_model?: string | null;
modes?: AgentModeInfo[] | null;
capabilities: AgentCapabilities;
[key: string]: unknown;
}
export interface AgentListResponse {
agents: AgentInfo[];
}
export interface AgentInstallRequest {
reinstall?: boolean;
agentVersion?: string;
agentProcessVersion?: string;
}
export interface AgentInstallArtifact {
kind: string;
path: string;
source: string;
version?: string | null;
}
export interface AgentInstallResponse {
already_installed: boolean;
artifacts: AgentInstallArtifact[];
}
export type SessionEndReason = "completed" | "error" | "terminated";
export type TerminatedBy = "agent" | "daemon";
export interface StderrOutput {
head?: string | null;
tail?: string | null;
truncated: boolean;
total_lines?: number | null;
}
export interface SessionTerminationInfo {
reason: SessionEndReason | string;
terminated_by: TerminatedBy | string;
message?: string | null;
exit_code?: number | null;
stderr?: StderrOutput | null;
[key: string]: unknown;
}
export interface SessionInfo {
session_id: string;
sessionId?: string;
agent?: string;
cwd?: string;
title?: string | null;
ended?: boolean;
created_at?: string | number | null;
createdAt?: string | number | null;
updated_at?: string | number | null;
updatedAt?: string | number | null;
model?: string | null;
metadata?: Record<string, unknown> | null;
agent_mode?: string;
agentMode?: string;
permission_mode?: string;
permissionMode?: string;
native_session_id?: string | null;
nativeSessionId?: string | null;
event_count?: number;
eventCount?: number;
directory?: string | null;
variant?: string | null;
mcp?: Record<string, unknown> | null;
skills?: Record<string, unknown> | null;
termination_info?: SessionTerminationInfo | null;
terminationInfo?: SessionTerminationInfo | null;
[key: string]: unknown;
}
export interface SessionListResponse {
sessions: SessionInfo[];
}
export interface SessionTerminateResponse {
terminated?: boolean;
reason?: SessionEndReason | string;
terminated_by?: TerminatedBy | string;
terminatedBy?: TerminatedBy | string;
[key: string]: unknown;
}
export interface SessionEndedParams {
session_id?: string;
sessionId?: string;
data?: SessionTerminationInfo;
reason?: SessionEndReason | string;
terminated_by?: TerminatedBy | string;
terminatedBy?: TerminatedBy | string;
message?: string | null;
exit_code?: number | null;
stderr?: StderrOutput | null;
[key: string]: unknown;
}
export interface SessionEndedNotification {
jsonrpc: "2.0";
method: "_sandboxagent/session/ended";
params: SessionEndedParams;
[key: string]: unknown;
}
export interface FsPathQuery {
path: string;
session_id?: string | null;
sessionId?: string | null;
}
export interface FsEntriesQuery {
path?: string | null;
session_id?: string | null;
sessionId?: string | null;
}
export interface FsSessionQuery {
session_id?: string | null;
sessionId?: string | null;
}
export interface FsDeleteQuery {
path: string;
recursive?: boolean | null;
session_id?: string | null;
sessionId?: string | null;
}
export interface FsUploadBatchQuery {
path?: string | null;
session_id?: string | null;
sessionId?: string | null;
}
export type FsEntryType = "file" | "directory";
export interface FsEntry {
name: string;
path: string;
size: number;
entry_type?: FsEntryType;
entryType?: FsEntryType;
modified?: string | null;
}
export interface FsStat {
path: string;
size: number;
entry_type?: FsEntryType;
entryType?: FsEntryType;
modified?: string | null;
}
export interface FsWriteResponse {
path: string;
bytes_written?: number;
bytesWritten?: number;
}
export interface FsMoveRequest {
from: string;
to: string;
overwrite?: boolean | null;
}
export interface FsMoveResponse {
from: string;
to: string;
}
export interface FsActionResponse {
path: string;
}
export interface FsUploadBatchResponse {
paths: string[];
truncated: boolean;
}

View file

@ -1,322 +0,0 @@
import { describe, it, expect, vi, type Mock } from "vitest";
import { SandboxAgent, SandboxAgentError } from "../src/client.ts";
function createMockFetch(
response: unknown,
status = 200,
headers: Record<string, string> = {}
): Mock<typeof fetch> {
return vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify(response), {
status,
headers: { "Content-Type": "application/json", ...headers },
})
);
}
function createMockFetchError(status: number, problem: unknown): Mock<typeof fetch> {
return vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify(problem), {
status,
headers: { "Content-Type": "application/problem+json" },
})
);
}
describe("SandboxAgent", () => {
describe("connect", () => {
it("creates client with baseUrl", async () => {
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
});
expect(client).toBeInstanceOf(SandboxAgent);
});
it("strips trailing slash from baseUrl", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080/",
fetch: mockFetch,
});
await client.getHealth();
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/health",
expect.any(Object)
);
});
it("throws if fetch is not available", async () => {
const originalFetch = globalThis.fetch;
// @ts-expect-error - testing missing fetch
globalThis.fetch = undefined;
await expect(
SandboxAgent.connect({
baseUrl: "http://localhost:8080",
})
).rejects.toThrow("Fetch API is not available");
globalThis.fetch = originalFetch;
});
});
describe("start", () => {
it("rejects when spawn disabled", async () => {
await expect(SandboxAgent.start({ spawn: false })).rejects.toThrow(
"SandboxAgent.start requires spawn to be enabled."
);
});
});
describe("getHealth", () => {
it("returns health response", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const result = await client.getHealth();
expect(result).toEqual({ status: "ok" });
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/health",
expect.objectContaining({ method: "GET" })
);
});
});
describe("listAgents", () => {
it("returns agent list", async () => {
const agents = { agents: [{ id: "claude", installed: true }] };
const mockFetch = createMockFetch(agents);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const result = await client.listAgents();
expect(result).toEqual(agents);
});
});
describe("createSession", () => {
it("creates session with agent", async () => {
const response = { healthy: true, agentSessionId: "abc123" };
const mockFetch = createMockFetch(response);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const result = await client.createSession("test-session", {
agent: "claude",
});
expect(result).toEqual(response);
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ agent: "claude" }),
})
);
});
it("encodes session ID in URL", async () => {
const mockFetch = createMockFetch({ healthy: true });
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.createSession("test/session", { agent: "claude" });
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test%2Fsession",
expect.any(Object)
);
});
});
describe("postMessage", () => {
it("sends message to session", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, { status: 204 })
);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.postMessage("test-session", { message: "Hello" });
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session/messages",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ message: "Hello" }),
})
);
});
});
describe("postMessageStream", () => {
it("posts message and requests SSE", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response("", {
status: 200,
headers: { "Content-Type": "text/event-stream" },
})
);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.postMessageStream("test-session", { message: "Hello" }, { includeRaw: true });
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session/messages/stream?includeRaw=true",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ message: "Hello" }),
})
);
});
});
describe("getEvents", () => {
it("returns events", async () => {
const events = { events: [], hasMore: false };
const mockFetch = createMockFetch(events);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const result = await client.getEvents("test-session");
expect(result).toEqual(events);
});
it("passes query parameters", async () => {
const mockFetch = createMockFetch({ events: [], hasMore: false });
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.getEvents("test-session", { offset: 10, limit: 50 });
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session/events?offset=10&limit=50",
expect.any(Object)
);
});
});
describe("authentication", () => {
it("includes authorization header when token provided", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
token: "test-token",
fetch: mockFetch,
});
await client.getHealth();
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.any(Headers),
})
);
const call = mockFetch.mock.calls[0];
const headers = call?.[1]?.headers as Headers | undefined;
expect(headers?.get("Authorization")).toBe("Bearer test-token");
});
});
describe("error handling", () => {
it("throws SandboxAgentError on non-ok response", async () => {
const problem = {
type: "error",
title: "Not Found",
status: 404,
detail: "Session not found",
};
const mockFetch = createMockFetchError(404, problem);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await expect(client.getEvents("nonexistent")).rejects.toThrow(
SandboxAgentError
);
try {
await client.getEvents("nonexistent");
} catch (e) {
expect(e).toBeInstanceOf(SandboxAgentError);
const error = e as SandboxAgentError;
expect(error.status).toBe(404);
expect(error.problem?.title).toBe("Not Found");
}
});
});
describe("replyQuestion", () => {
it("sends question reply", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, { status: 204 })
);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.replyQuestion("test-session", "q1", {
answers: [["Yes"]],
});
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session/questions/q1/reply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ answers: [["Yes"]] }),
})
);
});
});
describe("replyPermission", () => {
it("sends permission reply", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(null, { status: 204 })
);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await client.replyPermission("test-session", "p1", {
reply: "once",
});
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8080/v1/sessions/test-session/permissions/p1/reply",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ reply: "once" }),
})
);
});
});
});

View file

@ -2,19 +2,23 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { type ChildProcess } from "node:child_process";
import { SandboxAgent } from "../src/client.ts";
import { spawnSandboxAgent, isNodeRuntime } from "../src/spawn.ts";
import {
AlreadyConnectedError,
NotConnectedError,
SandboxAgent,
SandboxAgentClient,
type AgentEvent,
} from "../src/index.ts";
import { spawnSandboxAgent, isNodeRuntime, type SandboxAgentSpawnHandle } from "../src/spawn.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const AGENT_UNPARSED_METHOD = "_sandboxagent/agent/unparsed";
// Check for binary in common locations
function findBinary(): string | null {
if (process.env.SANDBOX_AGENT_BIN) {
return process.env.SANDBOX_AGENT_BIN;
}
// Check cargo build output (run from sdks/typescript/tests)
const cargoPaths = [
resolve(__dirname, "../../../target/debug/sandbox-agent"),
resolve(__dirname, "../../../target/release/sandbox-agent"),
@ -30,136 +34,292 @@ function findBinary(): string | null {
}
const BINARY_PATH = findBinary();
const SKIP_INTEGRATION = !BINARY_PATH && !process.env.RUN_INTEGRATION_TESTS;
// Set env var if we found a binary
if (BINARY_PATH && !process.env.SANDBOX_AGENT_BIN) {
if (!BINARY_PATH) {
throw new Error(
"sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.",
);
}
if (!process.env.SANDBOX_AGENT_BIN) {
process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
}
describe.skipIf(SKIP_INTEGRATION)("Integration: spawn (local mode)", () => {
it("spawns server and connects", async () => {
const handle = await spawnSandboxAgent({
enabled: true,
log: "silent",
timeoutMs: 30000,
});
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
try {
expect(handle.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(handle.token).toBeTruthy();
const client = await SandboxAgent.connect({
baseUrl: handle.baseUrl,
token: handle.token,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
} finally {
await handle.dispose();
async function waitFor<T>(
fn: () => T | undefined | null,
timeoutMs = 5000,
stepMs = 25,
): Promise<T> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = fn();
if (value !== undefined && value !== null) {
return value;
}
});
await sleep(stepMs);
}
throw new Error("timed out waiting for condition");
}
it("SandboxAgent.start spawns automatically", async () => {
const client = await SandboxAgent.start({
spawn: { log: "silent", timeoutMs: 30000 },
});
try {
const health = await client.getHealth();
expect(health.status).toBe("ok");
const agents = await client.listAgents();
expect(agents.agents).toBeDefined();
expect(Array.isArray(agents.agents)).toBe(true);
} finally {
await client.dispose();
}
});
it("lists available agents", async () => {
const client = await SandboxAgent.start({
spawn: { log: "silent", timeoutMs: 30000 },
});
try {
const agents = await client.listAgents();
expect(agents.agents).toBeDefined();
// Should have at least some agents defined
expect(agents.agents.length).toBeGreaterThan(0);
} finally {
await client.dispose();
}
});
});
describe.skipIf(SKIP_INTEGRATION)("Integration: connect (remote mode)", () => {
let serverProcess: ChildProcess;
describe("Integration: TypeScript SDK against real server/runtime", () => {
let handle: SandboxAgentSpawnHandle;
let baseUrl: string;
let token: string;
beforeAll(async () => {
// Start server manually to simulate remote server
const handle = await spawnSandboxAgent({
handle = await spawnSandboxAgent({
enabled: true,
log: "silent",
timeoutMs: 30000,
});
serverProcess = handle.child;
baseUrl = handle.baseUrl;
token = handle.token;
});
afterAll(async () => {
if (serverProcess && serverProcess.exitCode === null) {
serverProcess.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
serverProcess.kill("SIGKILL");
resolve();
}, 5000);
serverProcess.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
await handle.dispose();
});
it("connects to remote server", async () => {
const client = await SandboxAgent.connect({
baseUrl,
token,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
});
it("handles authentication", async () => {
const client = await SandboxAgent.connect({
baseUrl,
token,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
});
it("rejects invalid token on protected endpoints", async () => {
const client = await SandboxAgent.connect({
baseUrl,
token: "invalid-token",
});
// Health endpoint may be open, but listing agents should require auth
await expect(client.listAgents()).rejects.toThrow();
});
});
describe("Runtime detection", () => {
it("detects Node.js runtime", () => {
expect(isNodeRuntime()).toBe(true);
});
it("keeps health on HTTP and requires ACP connection for ACP-backed helpers", async () => {
const client = await SandboxAgent.connect({
baseUrl,
token,
agent: "mock",
autoConnect: false,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
await expect(client.listAgents()).rejects.toBeInstanceOf(NotConnectedError);
await client.connect();
const agents = await client.listAgents();
expect(Array.isArray(agents.agents)).toBe(true);
expect(agents.agents.length).toBeGreaterThan(0);
await client.disconnect();
});
it("auto-connects on constructor and runs initialize/new/prompt flow", async () => {
const events: AgentEvent[] = [];
const client = new SandboxAgentClient({
baseUrl,
token,
agent: "mock",
onEvent: (event) => {
events.push(event);
},
});
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
metadata: {
agent: "mock",
},
});
expect(session.sessionId).toBeTruthy();
const prompt = await client.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello integration" }],
});
expect(prompt.stopReason).toBe("end_turn");
await waitFor(() => {
const text = events
.filter((event): event is Extract<AgentEvent, { type: "sessionUpdate" }> => {
return event.type === "sessionUpdate";
})
.map((event) => event.notification)
.filter((entry) => entry.update.sessionUpdate === "agent_message_chunk")
.map((entry) => entry.update.content)
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("");
return text.includes("mock: hello integration") ? text : undefined;
});
await client.disconnect();
});
it("enforces manual connect and disconnect lifecycle when autoConnect is disabled", async () => {
const client = new SandboxAgentClient({
baseUrl,
token,
agent: "mock",
autoConnect: false,
});
await expect(
client.newSession({
cwd: process.cwd(),
mcpServers: [],
metadata: {
agent: "mock",
},
}),
).rejects.toBeInstanceOf(NotConnectedError);
await client.connect();
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
metadata: {
agent: "mock",
},
});
expect(session.sessionId).toBeTruthy();
await client.disconnect();
await expect(
client.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "after disconnect" }],
}),
).rejects.toBeInstanceOf(NotConnectedError);
});
it("rejects duplicate connect calls for a single client instance", async () => {
const client = new SandboxAgentClient({
baseUrl,
token,
agent: "mock",
autoConnect: false,
});
await client.connect();
await expect(client.connect()).rejects.toBeInstanceOf(AlreadyConnectedError);
await client.disconnect();
});
it("injects metadata on newSession and extracts metadata from session/list", async () => {
const client = new SandboxAgentClient({
baseUrl,
token,
agent: "mock",
autoConnect: false,
});
await client.connect();
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
metadata: {
agent: "mock",
variant: "high",
},
});
await client.setMetadata(session.sessionId, {
title: "sdk title",
permissionMode: "ask",
model: "mock",
});
const listed = await client.unstableListSessions({});
const current = listed.sessions.find((entry) => entry.sessionId === session.sessionId) as
| (Record<string, unknown> & { metadata?: Record<string, unknown> })
| undefined;
expect(current).toBeTruthy();
expect(current?.title).toBe("sdk title");
const metadata =
(current?.metadata as Record<string, unknown> | undefined) ??
((current?._meta as Record<string, unknown> | undefined)?.["sandboxagent.dev"] as
| Record<string, unknown>
| undefined);
expect(metadata?.variant).toBe("high");
expect(metadata?.permissionMode).toBe("ask");
expect(metadata?.model).toBe("mock");
await client.disconnect();
});
it("converts _sandboxagent/session/ended into typed agent events", async () => {
const events: AgentEvent[] = [];
const client = new SandboxAgentClient({
baseUrl,
token,
agent: "mock",
autoConnect: false,
onEvent: (event) => {
events.push(event);
},
});
await client.connect();
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
metadata: {
agent: "mock",
},
});
await client.terminateSession(session.sessionId);
const ended = await waitFor(() => {
return events.find((event) => event.type === "sessionEnded");
});
expect(ended.type).toBe("sessionEnded");
if (ended.type === "sessionEnded") {
const endedSessionId =
ended.notification.params.sessionId ?? ended.notification.params.session_id;
expect(endedSessionId).toBe(session.sessionId);
}
await client.disconnect();
});
it("converts _sandboxagent/agent/unparsed notifications through the event adapter", async () => {
const events: AgentEvent[] = [];
const client = new SandboxAgentClient({
baseUrl,
token,
autoConnect: false,
onEvent: (event) => {
events.push(event);
},
});
(client as any).handleEnvelope(
{
jsonrpc: "2.0",
method: AGENT_UNPARSED_METHOD,
params: {
raw: "unexpected payload",
},
},
"inbound",
);
const unparsed = events.find((event) => event.type === "agentUnparsed");
expect(unparsed?.type).toBe("agentUnparsed");
});
it("rejects invalid token on protected /v2 endpoints", async () => {
const client = new SandboxAgentClient({
baseUrl,
token: "invalid-token",
autoConnect: false,
});
await expect(client.getHealth()).rejects.toThrow();
});
});

View file

@ -1,208 +0,0 @@
import { describe, it, expect, vi, type Mock } from "vitest";
import { SandboxAgent } from "../src/client.ts";
import type { UniversalEvent } from "../src/types.ts";
function createMockResponse(chunks: string[]): Response {
let chunkIndex = 0;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (chunkIndex < chunks.length) {
controller.enqueue(encoder.encode(chunks[chunkIndex]));
chunkIndex++;
} else {
controller.close();
}
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
function createMockFetch(chunks: string[]): Mock<typeof fetch> {
return vi.fn<typeof fetch>().mockResolvedValue(createMockResponse(chunks));
}
function createEvent(sequence: number): UniversalEvent {
return {
event_id: `evt-${sequence}`,
sequence,
session_id: "test-session",
source: "agent",
synthetic: false,
time: new Date().toISOString(),
type: "item.started",
data: {
item_id: `item-${sequence}`,
kind: "message",
role: "assistant",
status: "in_progress",
content: [],
},
} as UniversalEvent;
}
describe("SSE Parser", () => {
it("parses single SSE event", async () => {
const event = createEvent(1);
const mockFetch = createMockFetch([`data: ${JSON.stringify(event)}\n\n`]);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(1);
expect(events[0].sequence).toBe(1);
});
it("parses multiple SSE events", async () => {
const event1 = createEvent(1);
const event2 = createEvent(2);
const mockFetch = createMockFetch([
`data: ${JSON.stringify(event1)}\n\n`,
`data: ${JSON.stringify(event2)}\n\n`,
]);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(2);
expect(events[0].sequence).toBe(1);
expect(events[1].sequence).toBe(2);
});
it("handles chunked SSE data", async () => {
const event = createEvent(1);
const fullMessage = `data: ${JSON.stringify(event)}\n\n`;
// Split in the middle of the message
const mockFetch = createMockFetch([
fullMessage.slice(0, 10),
fullMessage.slice(10),
]);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(1);
expect(events[0].sequence).toBe(1);
});
it("handles multiple events in single chunk", async () => {
const event1 = createEvent(1);
const event2 = createEvent(2);
const mockFetch = createMockFetch([
`data: ${JSON.stringify(event1)}\n\ndata: ${JSON.stringify(event2)}\n\n`,
]);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(2);
});
it("ignores non-data lines", async () => {
const event = createEvent(1);
const mockFetch = createMockFetch([
`: this is a comment\n`,
`id: 1\n`,
`data: ${JSON.stringify(event)}\n\n`,
]);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(1);
});
it("handles CRLF line endings", async () => {
const event = createEvent(1);
const mockFetch = createMockFetch([
`data: ${JSON.stringify(event)}\r\n\r\n`,
]);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(1);
});
it("handles empty stream", async () => {
const mockFetch = createMockFetch([]);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
const events: UniversalEvent[] = [];
for await (const e of client.streamEvents("test-session")) {
events.push(e);
}
expect(events).toHaveLength(0);
});
it("passes query parameters", async () => {
const mockFetch = createMockFetch([]);
const client = await SandboxAgent.connect({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
// Consume the stream
for await (const _ of client.streamEvents("test-session", { offset: 5 })) {
// empty
}
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("offset=5"),
expect.any(Object)
);
});
});