feat: sync universal schema and sdk updates

This commit is contained in:
Nathan Flurry 2026-01-27 02:52:25 -08:00
parent 79bb441287
commit f5d1a6383d
56 changed files with 6800 additions and 3974 deletions

View file

@ -11,6 +11,12 @@
"sandbox-agent": "bin/sandbox-agent",
"sandbox-daemon": "bin/sandbox-agent"
},
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"vitest": "^3.0.0"
},
"optionalDependencies": {
"@sandbox-agent/cli-darwin-arm64": "0.1.0",
"@sandbox-agent/cli-darwin-x64": "0.1.0",

View file

@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const LAUNCHER_PATH = resolve(__dirname, "../bin/sandbox-agent");
// 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
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();
const SKIP_INTEGRATION = !BINARY_PATH;
describe("CLI Launcher", () => {
describe("platform detection", () => {
it("defines all supported platforms", () => {
const PLATFORMS: Record<string, string> = {
"darwin-arm64": "@sandbox-agent/cli-darwin-arm64",
"darwin-x64": "@sandbox-agent/cli-darwin-x64",
"linux-x64": "@sandbox-agent/cli-linux-x64",
"win32-x64": "@sandbox-agent/cli-win32-x64",
};
// Verify platform map covers expected platforms
expect(PLATFORMS["darwin-arm64"]).toBe("@sandbox-agent/cli-darwin-arm64");
expect(PLATFORMS["darwin-x64"]).toBe("@sandbox-agent/cli-darwin-x64");
expect(PLATFORMS["linux-x64"]).toBe("@sandbox-agent/cli-linux-x64");
expect(PLATFORMS["win32-x64"]).toBe("@sandbox-agent/cli-win32-x64");
});
it("generates correct platform key format", () => {
const key = `${process.platform}-${process.arch}`;
expect(key).toMatch(/^[a-z0-9]+-[a-z0-9]+$/);
});
});
});
describe.skipIf(SKIP_INTEGRATION)("CLI Integration", () => {
it("runs --help successfully", () => {
const result = spawnSync(BINARY_PATH!, ["--help"], {
encoding: "utf8",
timeout: 10000,
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("sandbox-agent");
});
it("runs --version successfully", () => {
const result = spawnSync(BINARY_PATH!, ["--version"], {
encoding: "utf8",
timeout: 10000,
});
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/\d+\.\d+\.\d+/);
});
it("lists agents", () => {
const result = spawnSync(BINARY_PATH!, ["agents", "list"], {
encoding: "utf8",
timeout: 10000,
});
expect(result.status).toBe(0);
});
it("shows server help", () => {
const result = spawnSync(BINARY_PATH!, ["server", "--help"], {
encoding: "utf8",
timeout: 10000,
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("server");
});
});

View file

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

View file

@ -23,13 +23,17 @@
"generate:openapi": "cargo check -p sandbox-agent-openapi-gen && 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": "pnpm run generate && tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
"build": "pnpm run generate && tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^22.0.0",
"openapi-typescript": "^6.7.0",
"typescript": "^5.7.0"
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"optionalDependencies": {
"@sandbox-agent/cli": "0.1.0"

View file

@ -1,31 +1,23 @@
import type { components } from "./generated/openapi.js";
import type {
SandboxDaemonSpawnHandle,
SandboxDaemonSpawnOptions,
} from "./spawn.js";
export type AgentInstallRequest = components["schemas"]["AgentInstallRequest"];
export type AgentModeInfo = components["schemas"]["AgentModeInfo"];
export type AgentModesResponse = components["schemas"]["AgentModesResponse"];
export type AgentInfo = components["schemas"]["AgentInfo"];
export type AgentListResponse = components["schemas"]["AgentListResponse"];
export type CreateSessionRequest = components["schemas"]["CreateSessionRequest"];
export type CreateSessionResponse = components["schemas"]["CreateSessionResponse"];
export type HealthResponse = components["schemas"]["HealthResponse"];
export type MessageRequest = components["schemas"]["MessageRequest"];
export type EventsQuery = components["schemas"]["EventsQuery"];
export type EventsResponse = components["schemas"]["EventsResponse"];
export type PermissionRequest = components["schemas"]["PermissionRequest"];
export type QuestionReplyRequest = components["schemas"]["QuestionReplyRequest"];
export type QuestionRequest = components["schemas"]["QuestionRequest"];
export type PermissionReplyRequest = components["schemas"]["PermissionReplyRequest"];
export type PermissionReply = components["schemas"]["PermissionReply"];
export type ProblemDetails = components["schemas"]["ProblemDetails"];
export type SessionInfo = components["schemas"]["SessionInfo"];
export type SessionListResponse = components["schemas"]["SessionListResponse"];
export type UniversalEvent = components["schemas"]["UniversalEvent"];
export type UniversalMessage = components["schemas"]["UniversalMessage"];
export type UniversalMessagePart = components["schemas"]["UniversalMessagePart"];
} from "./spawn.ts";
import type {
AgentInstallRequest,
AgentListResponse,
AgentModesResponse,
CreateSessionRequest,
CreateSessionResponse,
EventsQuery,
EventsResponse,
HealthResponse,
MessageRequest,
PermissionReplyRequest,
ProblemDetails,
QuestionReplyRequest,
SessionListResponse,
UniversalEvent,
} from "./types.ts";
const API_PREFIX = "/v1";
@ -179,13 +171,14 @@ export class SandboxDaemonClient {
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// Normalize CRLF to LF for consistent parsing
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
let index = buffer.indexOf("\n\n");
while (index !== -1) {
const chunk = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const dataLines = chunk
.split(/\r?\n/)
.split("\n")
.filter((line) => line.startsWith("data:"));
if (dataLines.length > 0) {
const payload = dataLines

View file

@ -4,11 +4,6 @@
*/
/** OneOf type helpers */
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;
export interface paths {
"/v1/agents": {
get: operations["list_agents"];
@ -46,12 +41,21 @@ export interface paths {
"/v1/sessions/{session_id}/questions/{question_id}/reply": {
post: operations["reply_question"];
};
"/v1/sessions/{session_id}/terminate": {
post: operations["terminate_session"];
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AgentCapabilities: {
permissions: boolean;
planMode: boolean;
questions: boolean;
toolCalls: boolean;
};
AgentError: {
agent?: string | null;
details?: unknown;
@ -60,6 +64,7 @@ export interface components {
type: components["schemas"]["ErrorType"];
};
AgentInfo: {
capabilities: components["schemas"]["AgentCapabilities"];
id: string;
installed: boolean;
path?: string | null;
@ -79,25 +84,52 @@ export interface components {
AgentModesResponse: {
modes: components["schemas"]["AgentModeInfo"][];
};
AttachmentSource: {
AgentUnparsedData: {
error: string;
location: string;
raw_hash?: string | null;
};
ContentPart: {
text: string;
/** @enum {string} */
type: "text";
} | {
json: unknown;
/** @enum {string} */
type: "json";
} | {
arguments: string;
call_id: string;
name: string;
/** @enum {string} */
type: "tool_call";
} | {
call_id: string;
output: string;
/** @enum {string} */
type: "tool_result";
} | ({
action: components["schemas"]["FileAction"];
diff?: string | null;
path: string;
/** @enum {string} */
type: "path";
} | {
type: "file_ref";
}) | {
text: string;
/** @enum {string} */
type: "url";
url: string;
type: "reasoning";
visibility: components["schemas"]["ReasoningVisibility"];
} | ({
data: string;
encoding?: string | null;
mime?: string | null;
path: string;
/** @enum {string} */
type: "data";
type: "image";
}) | ({
detail?: string | null;
label: string;
/** @enum {string} */
type: "status";
});
CrashInfo: {
details?: unknown;
kind?: string | null;
message: string;
};
CreateSessionRequest: {
agent: string;
agentMode?: string | null;
@ -107,13 +139,21 @@ export interface components {
variant?: string | null;
};
CreateSessionResponse: {
agentSessionId?: string | null;
error?: components["schemas"]["AgentError"] | null;
healthy: boolean;
nativeSessionId?: string | null;
};
ErrorData: {
code?: string | null;
details?: unknown;
message: string;
};
/** @enum {string} */
ErrorType: "invalid_request" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
/** @enum {string} */
EventSource: "agent" | "daemon";
EventsQuery: {
includeRaw?: boolean | null;
/** Format: int64 */
limit?: number | null;
/** Format: int64 */
@ -123,32 +163,41 @@ export interface components {
events: components["schemas"]["UniversalEvent"][];
hasMore: boolean;
};
/** @enum {string} */
FileAction: "read" | "write" | "patch";
HealthResponse: {
status: string;
};
ItemDeltaData: {
delta: string;
item_id: string;
native_item_id?: string | null;
};
ItemEventData: {
item: components["schemas"]["UniversalItem"];
};
/** @enum {string} */
ItemKind: "message" | "tool_call" | "tool_result" | "system" | "status" | "unknown";
/** @enum {string} */
ItemRole: "user" | "assistant" | "system" | "tool";
/** @enum {string} */
ItemStatus: "in_progress" | "completed" | "failed";
MessageRequest: {
message: string;
};
PermissionEventData: {
action: string;
metadata?: unknown;
permission_id: string;
status: components["schemas"]["PermissionStatus"];
};
/** @enum {string} */
PermissionReply: "once" | "always" | "reject";
PermissionReplyRequest: {
reply: components["schemas"]["PermissionReply"];
};
PermissionRequest: {
always: string[];
id: string;
metadata?: {
[key: string]: unknown;
};
patterns: string[];
permission: string;
sessionId: string;
tool?: components["schemas"]["PermissionToolRef"] | null;
};
PermissionToolRef: {
callId: string;
messageId: string;
};
/** @enum {string} */
PermissionStatus: "requested" | "approved" | "denied";
ProblemDetails: {
detail?: string | null;
instance?: string | null;
@ -158,38 +207,34 @@ export interface components {
type: string;
[key: string]: unknown;
};
QuestionInfo: {
custom?: boolean | null;
header?: string | null;
multiSelect?: boolean | null;
options: components["schemas"]["QuestionOption"][];
question: string;
};
QuestionOption: {
description?: string | null;
label: string;
QuestionEventData: {
options: string[];
prompt: string;
question_id: string;
response?: string | null;
status: components["schemas"]["QuestionStatus"];
};
QuestionReplyRequest: {
answers: string[][];
};
QuestionRequest: {
id: string;
questions: components["schemas"]["QuestionInfo"][];
sessionId: string;
tool?: components["schemas"]["QuestionToolRef"] | null;
};
QuestionToolRef: {
callId: string;
messageId: string;
/** @enum {string} */
QuestionStatus: "requested" | "answered" | "rejected";
/** @enum {string} */
ReasoningVisibility: "public" | "private";
/** @enum {string} */
SessionEndReason: "completed" | "error" | "terminated";
SessionEndedData: {
reason: components["schemas"]["SessionEndReason"];
terminated_by: components["schemas"]["TerminatedBy"];
};
SessionInfo: {
agent: string;
agentMode: string;
agentSessionId?: string | null;
ended: boolean;
/** Format: int64 */
eventCount: number;
model?: string | null;
nativeSessionId?: string | null;
permissionMode: string;
sessionId: string;
variant?: string | null;
@ -197,98 +242,35 @@ export interface components {
SessionListResponse: {
sessions: components["schemas"]["SessionInfo"][];
};
Started: {
details?: unknown;
message?: string | null;
SessionStartedData: {
metadata?: unknown;
};
/** @enum {string} */
TerminatedBy: "agent" | "daemon";
UniversalEvent: {
agent: string;
agentSessionId?: string | null;
data: components["schemas"]["UniversalEventData"];
event_id: string;
native_session_id?: string | null;
raw?: unknown;
/** Format: int64 */
id: number;
sessionId: string;
timestamp: string;
sequence: number;
session_id: string;
source: components["schemas"]["EventSource"];
synthetic: boolean;
time: string;
type: components["schemas"]["UniversalEventType"];
};
UniversalEventData: {
message: components["schemas"]["UniversalMessage"];
} | {
started: components["schemas"]["Started"];
} | {
error: components["schemas"]["CrashInfo"];
} | {
questionAsked: components["schemas"]["QuestionRequest"];
} | {
permissionAsked: components["schemas"]["PermissionRequest"];
} | {
raw: unknown;
};
UniversalMessage: OneOf<[components["schemas"]["UniversalMessageParsed"], {
error?: string | null;
raw: unknown;
}]>;
UniversalMessageParsed: {
id?: string | null;
metadata?: {
[key: string]: unknown;
};
parts: components["schemas"]["UniversalMessagePart"][];
role: string;
};
UniversalMessagePart: {
text: string;
/** @enum {string} */
type: "text";
} | ({
id?: string | null;
input: unknown;
name: string;
/** @enum {string} */
type: "tool_call";
}) | ({
id?: string | null;
is_error?: boolean | null;
name?: string | null;
output: unknown;
/** @enum {string} */
type: "tool_result";
}) | ({
arguments: unknown;
id?: string | null;
name?: string | null;
raw?: unknown;
/** @enum {string} */
type: "function_call";
}) | ({
id?: string | null;
is_error?: boolean | null;
name?: string | null;
raw?: unknown;
result: unknown;
/** @enum {string} */
type: "function_result";
}) | ({
filename?: string | null;
mime_type?: string | null;
raw?: unknown;
source: components["schemas"]["AttachmentSource"];
/** @enum {string} */
type: "file";
}) | ({
alt?: string | null;
mime_type?: string | null;
raw?: unknown;
source: components["schemas"]["AttachmentSource"];
/** @enum {string} */
type: "image";
}) | {
message: string;
/** @enum {string} */
type: "error";
} | {
raw: unknown;
/** @enum {string} */
type: "unknown";
UniversalEventData: components["schemas"]["SessionStartedData"] | components["schemas"]["SessionEndedData"] | components["schemas"]["ItemEventData"] | components["schemas"]["ItemDeltaData"] | components["schemas"]["ErrorData"] | components["schemas"]["PermissionEventData"] | components["schemas"]["QuestionEventData"] | components["schemas"]["AgentUnparsedData"];
/** @enum {string} */
UniversalEventType: "session.started" | "session.ended" | "item.started" | "item.delta" | "item.completed" | "error" | "permission.requested" | "permission.resolved" | "question.requested" | "question.resolved" | "agent.unparsed";
UniversalItem: {
content: components["schemas"]["ContentPart"][];
item_id: string;
kind: components["schemas"]["ItemKind"];
native_item_id?: string | null;
parent_id?: string | null;
role?: components["schemas"]["ItemRole"] | null;
status: components["schemas"]["ItemStatus"];
};
};
responses: never;
@ -418,10 +400,12 @@ export interface operations {
get_events: {
parameters: {
query?: {
/** @description Last seen event id (exclusive) */
/** @description Last seen event sequence (exclusive) */
offset?: number | null;
/** @description Max events to return */
limit?: number | null;
/** @description Include raw provider payloads */
include_raw?: boolean | null;
};
path: {
/** @description Session id */
@ -444,8 +428,10 @@ export interface operations {
get_events_sse: {
parameters: {
query?: {
/** @description Last seen event id (exclusive) */
/** @description Last seen event sequence (exclusive) */
offset?: number | null;
/** @description Include raw provider payloads */
include_raw?: boolean | null;
};
path: {
/** @description Session id */
@ -556,4 +542,23 @@ export interface operations {
};
};
};
terminate_session: {
parameters: {
path: {
/** @description Session id */
session_id: string;
};
};
responses: {
/** @description Session terminated */
204: {
content: never;
};
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
}

View file

@ -3,32 +3,53 @@ export {
SandboxDaemonError,
connectSandboxDaemonClient,
createSandboxDaemonClient,
} from "./client.js";
} from "./client.ts";
export type {
SandboxDaemonClientOptions,
SandboxDaemonConnectOptions,
} from "./client.ts";
export type {
AgentCapabilities,
AgentInfo,
AgentInstallRequest,
AgentListResponse,
AgentModeInfo,
AgentModesResponse,
AgentUnparsedData,
ContentPart,
CreateSessionRequest,
CreateSessionResponse,
ErrorData,
EventSource,
EventsQuery,
EventsResponse,
FileAction,
HealthResponse,
ItemDeltaData,
ItemEventData,
ItemKind,
ItemRole,
ItemStatus,
MessageRequest,
PermissionRequest,
PermissionEventData,
PermissionReply,
PermissionReplyRequest,
PermissionStatus,
ProblemDetails,
QuestionRequest,
QuestionEventData,
QuestionReplyRequest,
QuestionStatus,
ReasoningVisibility,
SessionEndReason,
SessionEndedData,
SessionInfo,
SessionListResponse,
SessionStartedData,
TerminatedBy,
UniversalEvent,
UniversalMessage,
UniversalMessagePart,
SandboxDaemonClientOptions,
SandboxDaemonConnectOptions,
} from "./client.js";
export type { components, paths } from "./generated/openapi.js";
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.js";
UniversalEventData,
UniversalEventType,
UniversalItem,
} from "./types.ts";
export type { components, paths } from "./generated/openapi.ts";
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.ts";

View file

@ -0,0 +1,45 @@
import type { components } from "./generated/openapi.ts";
type S = components["schemas"];
export type AgentCapabilities = S["AgentCapabilities"];
export type AgentInfo = S["AgentInfo"];
export type AgentInstallRequest = S["AgentInstallRequest"];
export type AgentListResponse = S["AgentListResponse"];
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 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 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 UniversalEvent = S["UniversalEvent"];
export type UniversalEventData = S["UniversalEventData"];
export type UniversalEventType = S["UniversalEventType"];
export type UniversalItem = S["UniversalItem"];

View file

@ -0,0 +1,305 @@
import { describe, it, expect, vi, type Mock } from "vitest";
import { SandboxDaemonClient, SandboxDaemonError } 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("SandboxDaemonClient", () => {
describe("constructor", () => {
it("creates client with baseUrl", () => {
const client = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
});
expect(client).toBeInstanceOf(SandboxDaemonClient);
});
it("strips trailing slash from baseUrl", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = new SandboxDaemonClient({
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", () => {
const originalFetch = globalThis.fetch;
// @ts-expect-error - testing missing fetch
globalThis.fetch = undefined;
expect(() => {
new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
});
}).toThrow("Fetch API is not available");
globalThis.fetch = originalFetch;
});
});
describe("connect", () => {
it("creates client without spawn when baseUrl provided", async () => {
const client = await SandboxDaemonClient.connect({
baseUrl: "http://localhost:8080",
spawn: false,
});
expect(client).toBeInstanceOf(SandboxDaemonClient);
});
it("throws when no baseUrl and spawn disabled", async () => {
await expect(
SandboxDaemonClient.connect({ spawn: false })
).rejects.toThrow("baseUrl is required when autospawn is disabled");
});
});
describe("getHealth", () => {
it("returns health response", async () => {
const mockFetch = createMockFetch({ status: "ok" });
const client = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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("getEvents", () => {
it("returns events", async () => {
const events = { events: [], hasMore: false };
const mockFetch = createMockFetch(events);
const client = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 SandboxDaemonError 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 = new SandboxDaemonClient({
baseUrl: "http://localhost:8080",
fetch: mockFetch,
});
await expect(client.getEvents("nonexistent")).rejects.toThrow(
SandboxDaemonError
);
try {
await client.getEvents("nonexistent");
} catch (e) {
expect(e).toBeInstanceOf(SandboxDaemonError);
const error = e as SandboxDaemonError;
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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

@ -0,0 +1,174 @@
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 { SandboxDaemonClient } from "../src/client.ts";
import { spawnSandboxDaemon, isNodeRuntime } from "../src/spawn.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
// 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"),
];
for (const p of cargoPaths) {
if (existsSync(p)) {
return p;
}
}
return 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) {
process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
}
describe.skipIf(SKIP_INTEGRATION)("Integration: spawn (local mode)", () => {
it("spawns daemon and connects", async () => {
const handle = await spawnSandboxDaemon({
enabled: true,
log: "silent",
timeoutMs: 30000,
});
try {
expect(handle.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(handle.token).toBeTruthy();
const client = new SandboxDaemonClient({
baseUrl: handle.baseUrl,
token: handle.token,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
} finally {
await handle.dispose();
}
});
it("SandboxDaemonClient.connect spawns automatically", async () => {
const client = await SandboxDaemonClient.connect({
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 SandboxDaemonClient.connect({
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 daemonProcess: ChildProcess;
let baseUrl: string;
let token: string;
beforeAll(async () => {
// Start daemon manually to simulate remote server
const handle = await spawnSandboxDaemon({
enabled: true,
log: "silent",
timeoutMs: 30000,
});
daemonProcess = handle.child;
baseUrl = handle.baseUrl;
token = handle.token;
});
afterAll(async () => {
if (daemonProcess && daemonProcess.exitCode === null) {
daemonProcess.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
daemonProcess.kill("SIGKILL");
resolve();
}, 5000);
daemonProcess.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
});
it("connects to remote server", async () => {
const client = await SandboxDaemonClient.connect({
baseUrl,
token,
spawn: false,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
});
it("creates client directly without spawn", () => {
const client = new SandboxDaemonClient({
baseUrl,
token,
});
expect(client).toBeInstanceOf(SandboxDaemonClient);
});
it("handles authentication", async () => {
const client = new SandboxDaemonClient({
baseUrl,
token,
});
const health = await client.getHealth();
expect(health.status).toBe("ok");
});
it("rejects invalid token on protected endpoints", async () => {
const client = new SandboxDaemonClient({
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);
});
});

View file

@ -0,0 +1,208 @@
import { describe, it, expect, vi, type Mock } from "vitest";
import { SandboxDaemonClient } 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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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 = new SandboxDaemonClient({
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)
);
});
});

View file

@ -2,15 +2,14 @@
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true,
"declaration": true
"resolveJsonModule": true
},
"include": ["src/**/*"],
"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,
},
});