This commit is contained in:
NathanFlurry 2026-02-11 14:47:41 +00:00
parent 70287ec471
commit e72eb9f611
No known key found for this signature in database
GPG key ID: 6A5F43A4F3241BCA
264 changed files with 18559 additions and 51021 deletions

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)
);
});
});