mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 18:03:56 +00:00
acp spec (#155)
This commit is contained in:
parent
70287ec471
commit
e72eb9f611
264 changed files with 18559 additions and 51021 deletions
|
|
@ -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" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue