mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 13:05:09 +00:00
chore: fix bad merge
This commit is contained in:
parent
1dd45908a3
commit
94353f7696
205 changed files with 19244 additions and 14866 deletions
140
sdks/typescript/tests/helpers/mock-agent.ts
Normal file
140
sdks/typescript/tests/helpers/mock-agent.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function prepareMockAgentDataHome(dataHome: string): void {
|
||||
const installDir = join(dataHome, "sandbox-agent", "bin");
|
||||
const processDir = join(installDir, "agent_processes");
|
||||
mkdirSync(processDir, { recursive: true });
|
||||
|
||||
const runner = process.platform === "win32"
|
||||
? join(processDir, "mock-acp.cmd")
|
||||
: join(processDir, "mock-acp");
|
||||
|
||||
const scriptFile = process.platform === "win32"
|
||||
? join(processDir, "mock-acp.js")
|
||||
: runner;
|
||||
|
||||
const nodeScript = String.raw`#!/usr/bin/env node
|
||||
const { createInterface } = require("node:readline");
|
||||
|
||||
let nextSession = 0;
|
||||
|
||||
function emit(value) {
|
||||
process.stdout.write(JSON.stringify(value) + "\n");
|
||||
}
|
||||
|
||||
function firstText(prompt) {
|
||||
if (!Array.isArray(prompt)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (const block of prompt) {
|
||||
if (block && block.type === "text" && typeof block.text === "string") {
|
||||
return block.text;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on("line", (line) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMethod = typeof msg?.method === "string";
|
||||
const hasId = Object.prototype.hasOwnProperty.call(msg, "id");
|
||||
const method = hasMethod ? msg.method : undefined;
|
||||
|
||||
if (method === "session/prompt") {
|
||||
const sessionId = typeof msg?.params?.sessionId === "string" ? msg.params.sessionId : "";
|
||||
const text = firstText(msg?.params?.prompt);
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "mock: " + text,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasMethod || !hasId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "initialize") {
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
result: {
|
||||
protocolVersion: 1,
|
||||
capabilities: {},
|
||||
serverInfo: {
|
||||
name: "mock-acp-agent",
|
||||
version: "0.0.1",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "session/new") {
|
||||
nextSession += 1;
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
result: {
|
||||
sessionId: "mock-session-" + nextSession,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "session/prompt") {
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
result: {
|
||||
stopReason: "end_turn",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
result: {
|
||||
ok: true,
|
||||
echoedMethod: method,
|
||||
},
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
writeFileSync(scriptFile, nodeScript);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(runner, `@echo off\r\nnode "${scriptFile}" %*\r\n`);
|
||||
}
|
||||
|
||||
chmodSync(scriptFile, 0o755);
|
||||
if (process.platform === "win32") {
|
||||
chmodSync(runner, 0o755);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,19 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
AlreadyConnectedError,
|
||||
NotConnectedError,
|
||||
InMemorySessionPersistDriver,
|
||||
SandboxAgent,
|
||||
SandboxAgentClient,
|
||||
type AgentEvent,
|
||||
type SessionEvent,
|
||||
} from "../src/index.ts";
|
||||
import { spawnSandboxAgent, isNodeRuntime, type SandboxAgentSpawnHandle } from "../src/spawn.ts";
|
||||
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const AGENT_UNPARSED_METHOD = "_sandboxagent/agent/unparsed";
|
||||
|
||||
function findBinary(): string | null {
|
||||
if (process.env.SANDBOX_AGENT_BIN) {
|
||||
|
|
@ -49,8 +50,8 @@ function sleep(ms: number): Promise<void> {
|
|||
|
||||
async function waitFor<T>(
|
||||
fn: () => T | undefined | null,
|
||||
timeoutMs = 5000,
|
||||
stepMs = 25,
|
||||
timeoutMs = 6000,
|
||||
stepMs = 30,
|
||||
): Promise<T> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
|
|
@ -63,16 +64,23 @@ async function waitFor<T>(
|
|||
throw new Error("timed out waiting for condition");
|
||||
}
|
||||
|
||||
describe("Integration: TypeScript SDK against real server/runtime", () => {
|
||||
describe("Integration: TypeScript SDK flat session API", () => {
|
||||
let handle: SandboxAgentSpawnHandle;
|
||||
let baseUrl: string;
|
||||
let token: string;
|
||||
let dataHome: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataHome = mkdtempSync(join(tmpdir(), "sdk-integration-"));
|
||||
prepareMockAgentDataHome(dataHome);
|
||||
|
||||
handle = await spawnSandboxAgent({
|
||||
enabled: true,
|
||||
log: "silent",
|
||||
timeoutMs: 30000,
|
||||
env: {
|
||||
XDG_DATA_HOME: dataHome,
|
||||
},
|
||||
});
|
||||
baseUrl = handle.baseUrl;
|
||||
token = handle.token;
|
||||
|
|
@ -80,246 +88,197 @@ describe("Integration: TypeScript SDK against real server/runtime", () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await handle.dispose();
|
||||
rmSync(dataHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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({
|
||||
it("creates a session, sends prompt, and persists events", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
agent: "mock",
|
||||
autoConnect: false,
|
||||
});
|
||||
|
||||
const health = await client.getHealth();
|
||||
expect(health.status).toBe("ok");
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
|
||||
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 observed: SessionEvent[] = [];
|
||||
const off = session.onEvent((event) => {
|
||||
observed.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" }],
|
||||
});
|
||||
const prompt = await session.prompt([{ type: "text", text: "hello flat sdk" }]);
|
||||
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;
|
||||
const inbound = observed.find((event) => event.sender === "agent");
|
||||
return inbound;
|
||||
});
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
const listed = await sdk.listSessions({ limit: 20 });
|
||||
expect(listed.items.some((entry) => entry.id === session.id)).toBe(true);
|
||||
|
||||
it("enforces manual connect and disconnect lifecycle when autoConnect is disabled", async () => {
|
||||
const client = new SandboxAgentClient({
|
||||
baseUrl,
|
||||
token,
|
||||
agent: "mock",
|
||||
autoConnect: false,
|
||||
});
|
||||
const fetched = await sdk.getSession(session.id);
|
||||
expect(fetched?.agent).toBe("mock");
|
||||
|
||||
await expect(
|
||||
client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
metadata: {
|
||||
agent: "mock",
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(NotConnectedError);
|
||||
const events = await sdk.getEvents({ sessionId: session.id, limit: 100 });
|
||||
expect(events.items.length).toBeGreaterThan(0);
|
||||
expect(events.items.some((event) => event.sender === "client")).toBe(true);
|
||||
expect(events.items.some((event) => event.sender === "agent")).toBe(true);
|
||||
expect(events.items.every((event) => typeof event.id === "string")).toBe(true);
|
||||
expect(events.items.every((event) => Number.isInteger(event.eventIndex))).toBe(true);
|
||||
|
||||
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);
|
||||
for (let i = 1; i < events.items.length; i += 1) {
|
||||
expect(events.items[i]!.eventIndex).toBeGreaterThanOrEqual(events.items[i - 1]!.eventIndex);
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
off();
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("converts _sandboxagent/agent/unparsed notifications through the event adapter", async () => {
|
||||
const events: AgentEvent[] = [];
|
||||
const client = new SandboxAgentClient({
|
||||
it("restores a session on stale connection by recreating and replaying history on first prompt", async () => {
|
||||
const persist = new InMemorySessionPersistDriver({
|
||||
maxEventsPerSession: 200,
|
||||
});
|
||||
|
||||
const first = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
autoConnect: false,
|
||||
onEvent: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
persist,
|
||||
replayMaxEvents: 50,
|
||||
replayMaxChars: 20_000,
|
||||
});
|
||||
|
||||
(client as any).handleEnvelope(
|
||||
const created = await first.createSession({ agent: "mock" });
|
||||
await created.prompt([{ type: "text", text: "first run" }]);
|
||||
const oldConnectionId = created.lastConnectionId;
|
||||
|
||||
await first.dispose();
|
||||
|
||||
const second = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
persist,
|
||||
replayMaxEvents: 50,
|
||||
replayMaxChars: 20_000,
|
||||
});
|
||||
|
||||
const restored = await second.resumeSession(created.id);
|
||||
expect(restored.lastConnectionId).not.toBe(oldConnectionId);
|
||||
|
||||
await restored.prompt([{ type: "text", text: "second run" }]);
|
||||
|
||||
const events = await second.getEvents({ sessionId: restored.id, limit: 500 });
|
||||
|
||||
const replayInjected = events.items.find((event) => {
|
||||
if (event.sender !== "client") {
|
||||
return false;
|
||||
}
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
const method = payload.method;
|
||||
const params = payload.params as Record<string, unknown> | undefined;
|
||||
const prompt = Array.isArray(params?.prompt) ? params?.prompt : [];
|
||||
const firstBlock = prompt[0] as Record<string, unknown> | undefined;
|
||||
return (
|
||||
method === "session/prompt" &&
|
||||
typeof firstBlock?.text === "string" &&
|
||||
firstBlock.text.includes("Previous session history is replayed below")
|
||||
);
|
||||
});
|
||||
|
||||
expect(replayInjected).toBeTruthy();
|
||||
|
||||
await second.dispose();
|
||||
});
|
||||
|
||||
it("enforces in-memory event cap to avoid leaks", async () => {
|
||||
const persist = new InMemorySessionPersistDriver({
|
||||
maxEventsPerSession: 8,
|
||||
});
|
||||
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
persist,
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await session.prompt([{ type: "text", text: `event-cap-${i}` }]);
|
||||
}
|
||||
|
||||
const events = await sdk.getEvents({ sessionId: session.id, limit: 200 });
|
||||
expect(events.items.length).toBeLessThanOrEqual(8);
|
||||
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("supports MCP and skills config HTTP helpers", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), "sdk-config-"));
|
||||
|
||||
const mcpConfig = {
|
||||
type: "local" as const,
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
env: { LOG_LEVEL: "debug" },
|
||||
};
|
||||
|
||||
await sdk.setMcpConfig(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: AGENT_UNPARSED_METHOD,
|
||||
params: {
|
||||
raw: "unexpected payload",
|
||||
},
|
||||
directory,
|
||||
mcpName: "local-test",
|
||||
},
|
||||
"inbound",
|
||||
mcpConfig,
|
||||
);
|
||||
|
||||
const unparsed = events.find((event) => event.type === "agentUnparsed");
|
||||
expect(unparsed?.type).toBe("agentUnparsed");
|
||||
});
|
||||
const loadedMcp = await sdk.getMcpConfig({
|
||||
directory,
|
||||
mcpName: "local-test",
|
||||
});
|
||||
expect(loadedMcp.type).toBe("local");
|
||||
|
||||
it("rejects invalid token on protected /v2 endpoints", async () => {
|
||||
const client = new SandboxAgentClient({
|
||||
baseUrl,
|
||||
token: "invalid-token",
|
||||
autoConnect: false,
|
||||
await sdk.deleteMcpConfig({
|
||||
directory,
|
||||
mcpName: "local-test",
|
||||
});
|
||||
|
||||
await expect(client.getHealth()).rejects.toThrow();
|
||||
const skillsConfig = {
|
||||
sources: [
|
||||
{
|
||||
type: "github",
|
||||
source: "rivet-dev/skills",
|
||||
skills: ["sandbox-agent"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sdk.setSkillsConfig(
|
||||
{
|
||||
directory,
|
||||
skillName: "default",
|
||||
},
|
||||
skillsConfig,
|
||||
);
|
||||
|
||||
const loadedSkills = await sdk.getSkillsConfig({
|
||||
directory,
|
||||
skillName: "default",
|
||||
});
|
||||
expect(Array.isArray(loadedSkills.sources)).toBe(true);
|
||||
|
||||
await sdk.deleteSkillsConfig({
|
||||
directory,
|
||||
skillName: "default",
|
||||
});
|
||||
|
||||
await sdk.dispose();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue