mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 09:01:17 +00:00
acp spec (#155)
This commit is contained in:
parent
70287ec471
commit
e72eb9f611
264 changed files with 18559 additions and 51021 deletions
|
|
@ -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
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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