mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
541 lines
19 KiB
TypeScript
541 lines
19 KiB
TypeScript
/**
|
|
* Tests for OpenCode-compatible session management endpoints.
|
|
*
|
|
* These tests verify that sandbox-agent exposes OpenCode-compatible API endpoints
|
|
* that can be used with the official OpenCode SDK.
|
|
*
|
|
* Expected endpoints:
|
|
* - POST /session - Create a new session
|
|
* - GET /session - List all sessions
|
|
* - GET /session/{id} - Get session details
|
|
* - PATCH /session/{id} - Update session properties
|
|
* - DELETE /session/{id} - Delete a session
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
|
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
|
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
describe("OpenCode-compatible Session API", () => {
|
|
let handle: SandboxAgentHandle;
|
|
let client: OpencodeClient;
|
|
|
|
async function createSessionViaHttp(body: Record<string, unknown>) {
|
|
const response = await fetch(`${handle.baseUrl}/opencode/session`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${handle.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
return response.json();
|
|
}
|
|
|
|
async function getBackingSessionPermissionMode(sessionId: string) {
|
|
const response = await fetch(`${handle.baseUrl}/opencode/session`, {
|
|
headers: { Authorization: `Bearer ${handle.token}` },
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
const sessions = await response.json();
|
|
const session = (sessions ?? []).find((item: any) => item.id === sessionId);
|
|
return session?.permissionMode;
|
|
}
|
|
|
|
async function getBackingSession(sessionId: string) {
|
|
const response = await fetch(`${handle.baseUrl}/opencode/session`, {
|
|
headers: { Authorization: `Bearer ${handle.token}` },
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
const sessions = await response.json();
|
|
return (sessions ?? []).find((item: any) => item.id === sessionId);
|
|
}
|
|
|
|
async function initSessionViaHttp(
|
|
sessionId: string,
|
|
body: Record<string, unknown> = {}
|
|
): Promise<{ response: Response; data: any }> {
|
|
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/init`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${handle.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await response.json();
|
|
return { response, data };
|
|
}
|
|
|
|
async function listMessagesViaHttp(sessionId: string): Promise<any[]> {
|
|
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/message`, {
|
|
headers: { Authorization: `Bearer ${handle.token}` },
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
return response.json();
|
|
}
|
|
|
|
async function getProvidersViaHttp(): Promise<{
|
|
connected: string[];
|
|
default: Record<string, string>;
|
|
}> {
|
|
const response = await fetch(`${handle.baseUrl}/opencode/provider`, {
|
|
headers: { Authorization: `Bearer ${handle.token}` },
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
const data = await response.json();
|
|
return {
|
|
connected: data.connected ?? [],
|
|
default: data.default ?? {},
|
|
};
|
|
}
|
|
|
|
async function waitForAssistantMessage(sessionId: string, timeoutMs = 10_000): Promise<any> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const messages = await listMessagesViaHttp(sessionId);
|
|
const assistant = messages.find((message) => message?.info?.role === "assistant");
|
|
if (assistant) {
|
|
return assistant;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error("Timed out waiting for assistant message");
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// Build the binary if needed
|
|
await buildSandboxAgent();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Spawn a fresh sandbox-agent instance for each test
|
|
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
|
client = createOpencodeClient({
|
|
baseUrl: `${handle.baseUrl}/opencode`,
|
|
headers: { Authorization: `Bearer ${handle.token}` },
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await handle?.dispose();
|
|
});
|
|
|
|
describe("session.create", () => {
|
|
it("should create a new session", async () => {
|
|
const response = await client.session.create();
|
|
|
|
expect(response.data).toBeDefined();
|
|
expect(response.data?.id).toBeDefined();
|
|
expect(typeof response.data?.id).toBe("string");
|
|
expect(response.data?.id.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should create session with custom title", async () => {
|
|
const response = await client.session.create({
|
|
body: { title: "Test Session" },
|
|
});
|
|
|
|
expect(response.data).toBeDefined();
|
|
expect(response.data?.title).toBe("Test Session");
|
|
});
|
|
|
|
it("should assign unique IDs to each session", async () => {
|
|
const session1 = await client.session.create();
|
|
const session2 = await client.session.create();
|
|
|
|
expect(session1.data?.id).not.toBe(session2.data?.id);
|
|
});
|
|
|
|
it("should pass permissionMode bypass to backing session", async () => {
|
|
const session = await createSessionViaHttp({ permissionMode: "bypass" });
|
|
const sessionId = session.id as string;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const prompt = await client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
model: { providerID: "mock", modelID: "mock" },
|
|
parts: [{ type: "text", text: "hello" }],
|
|
},
|
|
});
|
|
expect(prompt.error).toBeUndefined();
|
|
|
|
const permissionMode = await getBackingSessionPermissionMode(sessionId);
|
|
expect(permissionMode).toBe("bypass");
|
|
});
|
|
|
|
it("should accept permission_mode alias and pass bypass to backing session", async () => {
|
|
const session = await createSessionViaHttp({ permission_mode: "bypass" });
|
|
const sessionId = session.id as string;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const prompt = await client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
model: { providerID: "mock", modelID: "mock" },
|
|
parts: [{ type: "text", text: "hello" }],
|
|
},
|
|
});
|
|
expect(prompt.error).toBeUndefined();
|
|
|
|
const permissionMode = await getBackingSessionPermissionMode(sessionId);
|
|
expect(permissionMode).toBe("bypass");
|
|
});
|
|
});
|
|
|
|
describe("session.list", () => {
|
|
it("should return empty list when no sessions exist", async () => {
|
|
const response = await client.session.list();
|
|
|
|
expect(response.data).toBeDefined();
|
|
expect(Array.isArray(response.data)).toBe(true);
|
|
expect(response.data?.length).toBe(0);
|
|
});
|
|
|
|
it("should list created sessions", async () => {
|
|
// Create some sessions
|
|
await client.session.create({ body: { title: "Session 1" } });
|
|
await client.session.create({ body: { title: "Session 2" } });
|
|
|
|
const response = await client.session.list();
|
|
|
|
expect(response.data).toBeDefined();
|
|
expect(response.data?.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("session.init", () => {
|
|
it("should accept empty init body and keep message flow working", async () => {
|
|
const session = await client.session.create();
|
|
const sessionId = session.data?.id!;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const initialized = await initSessionViaHttp(sessionId, {});
|
|
expect(initialized.response.ok).toBe(true);
|
|
expect(initialized.data).toBe(true);
|
|
|
|
const prompt = await client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
parts: [{ type: "text", text: "hello after init" }],
|
|
} as any,
|
|
});
|
|
expect(prompt.error).toBeUndefined();
|
|
|
|
const assistant = await waitForAssistantMessage(sessionId);
|
|
expect(assistant?.info?.role).toBe("assistant");
|
|
});
|
|
|
|
it("should apply explicit init model selection to the backing session", async () => {
|
|
const session = await client.session.create();
|
|
const sessionId = session.data?.id!;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const initialized = await initSessionViaHttp(sessionId, {
|
|
providerID: "codex",
|
|
modelID: "gpt-5",
|
|
messageID: "msg_init",
|
|
});
|
|
expect(initialized.response.ok).toBe(true);
|
|
expect(initialized.data).toBe(true);
|
|
|
|
const backingSession = await getBackingSession(sessionId);
|
|
expect(backingSession?.agent).toBe("codex");
|
|
expect(backingSession?.model).toBe("gpt-5");
|
|
});
|
|
|
|
it("should accept first prompt after codex init without session-not-found", async () => {
|
|
const providers = await getProvidersViaHttp();
|
|
if (!providers.connected.includes("codex")) {
|
|
return;
|
|
}
|
|
const codexDefaultModel = providers.default?.codex;
|
|
if (!codexDefaultModel) {
|
|
return;
|
|
}
|
|
|
|
const session = await client.session.create();
|
|
const sessionId = session.data?.id!;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const initialized = await initSessionViaHttp(sessionId, {
|
|
providerID: "codex",
|
|
modelID: codexDefaultModel,
|
|
});
|
|
expect(initialized.response.ok).toBe(true);
|
|
expect(initialized.data).toBe(true);
|
|
|
|
const prompt = await client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
model: { providerID: "codex", modelID: codexDefaultModel },
|
|
parts: [{ type: "text", text: "hello after codex init" }],
|
|
},
|
|
});
|
|
expect(prompt.error).toBeUndefined();
|
|
});
|
|
|
|
it("should reject init model changes after the first prompt", async () => {
|
|
const session = await client.session.create();
|
|
const sessionId = session.data?.id!;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const firstPrompt = await client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
model: { providerID: "mock", modelID: "mock" },
|
|
parts: [{ type: "text", text: "first" }],
|
|
},
|
|
});
|
|
expect(firstPrompt.error).toBeUndefined();
|
|
|
|
const changed = await initSessionViaHttp(sessionId, {
|
|
providerID: "codex",
|
|
modelID: "gpt-5",
|
|
});
|
|
expect(changed.response.status).toBe(400);
|
|
expect(changed.data?.errors?.[0]?.message).toBe(
|
|
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."
|
|
);
|
|
});
|
|
|
|
it("should map agent-only first prompt selection to provider/model defaults", async () => {
|
|
const session = await client.session.create();
|
|
const sessionId = session.data?.id!;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const prompt = await client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
agent: "codex",
|
|
parts: [{ type: "text", text: "hello with agent only" }],
|
|
} as any,
|
|
});
|
|
expect(prompt.error).toBeUndefined();
|
|
expect(prompt.data?.info?.providerID).toBe("codex");
|
|
expect(prompt.data?.info?.modelID).toBe("gpt-5");
|
|
});
|
|
});
|
|
|
|
describe("session.get", () => {
|
|
it("should retrieve session by ID", async () => {
|
|
const created = await client.session.create({ body: { title: "Test" } });
|
|
const sessionId = created.data?.id;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const response = await client.session.get({ path: { id: sessionId! } });
|
|
|
|
expect(response.data).toBeDefined();
|
|
expect(response.data?.id).toBe(sessionId);
|
|
expect(response.data?.title).toBe("Test");
|
|
});
|
|
|
|
it("should keep session.get available during first prompt after /new-style creation", async () => {
|
|
const providers = await getProvidersViaHttp();
|
|
const providerId = providers.connected.find(
|
|
(provider) => provider !== "mock" && typeof providers.default?.[provider] === "string"
|
|
);
|
|
if (!providerId) {
|
|
return;
|
|
}
|
|
const modelId = providers.default?.[providerId];
|
|
if (!modelId) {
|
|
return;
|
|
}
|
|
|
|
const created = await client.session.create({ body: { title: "Race Repro" } });
|
|
const sessionId = created.data?.id!;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const promptPromise = client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
model: { providerID: providerId, modelID: modelId },
|
|
parts: [{ type: "text", text: "hello after /new" }],
|
|
},
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
|
|
const getDuringPrompt = await client.session.get({ path: { id: sessionId } });
|
|
expect(getDuringPrompt.error).toBeUndefined();
|
|
expect(getDuringPrompt.data?.id).toBe(sessionId);
|
|
|
|
// Best-effort settle; this assertion focuses on availability during the in-flight turn.
|
|
await promptPromise;
|
|
});
|
|
|
|
it("should return error for non-existent session", async () => {
|
|
const response = await client.session.get({
|
|
path: { id: "non-existent-session-id" },
|
|
});
|
|
|
|
expect(response.error).toBeDefined();
|
|
});
|
|
|
|
it("should restore persisted sessions after server restart and continue prompting", async () => {
|
|
await handle.dispose();
|
|
|
|
const tempStateDir = mkdtempSync(join(tmpdir(), "sandbox-agent-opencode-restore-"));
|
|
const sqlitePath = join(tempStateDir, "opencode-sessions.db");
|
|
|
|
try {
|
|
handle = await spawnSandboxAgent({
|
|
opencodeCompat: true,
|
|
env: { OPENCODE_COMPAT_DB_PATH: sqlitePath },
|
|
});
|
|
client = createOpencodeClient({
|
|
baseUrl: `${handle.baseUrl}/opencode`,
|
|
headers: { Authorization: `Bearer ${handle.token}` },
|
|
});
|
|
|
|
const created = await client.session.create({ body: { title: "Persisted Session" } });
|
|
const sessionId = created.data?.id!;
|
|
expect(sessionId).toBeDefined();
|
|
|
|
const firstPrompt = await client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
model: { providerID: "mock", modelID: "mock" },
|
|
parts: [{ type: "text", text: "before restart" }],
|
|
},
|
|
});
|
|
expect(firstPrompt.error).toBeUndefined();
|
|
|
|
await waitForAssistantMessage(sessionId);
|
|
await handle.dispose();
|
|
|
|
handle = await spawnSandboxAgent({
|
|
opencodeCompat: true,
|
|
env: { OPENCODE_COMPAT_DB_PATH: sqlitePath },
|
|
});
|
|
client = createOpencodeClient({
|
|
baseUrl: `${handle.baseUrl}/opencode`,
|
|
headers: { Authorization: `Bearer ${handle.token}` },
|
|
});
|
|
|
|
const restored = await client.session.get({ path: { id: sessionId } });
|
|
expect(restored.error).toBeUndefined();
|
|
expect(restored.data?.id).toBe(sessionId);
|
|
expect(restored.data?.title).toBe("Persisted Session");
|
|
|
|
const secondPrompt = await client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
model: { providerID: "mock", modelID: "mock" },
|
|
parts: [{ type: "text", text: "after restart" }],
|
|
},
|
|
});
|
|
expect(secondPrompt.error).toBeUndefined();
|
|
|
|
const messages = await listMessagesViaHttp(sessionId);
|
|
expect(messages.length).toBeGreaterThan(2);
|
|
} finally {
|
|
rmSync(tempStateDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("session.update", () => {
|
|
it("should update session title", async () => {
|
|
const created = await client.session.create({ body: { title: "Original" } });
|
|
const sessionId = created.data?.id!;
|
|
|
|
await client.session.update({
|
|
path: { id: sessionId },
|
|
body: { title: "Updated" },
|
|
});
|
|
|
|
const response = await client.session.get({ path: { id: sessionId } });
|
|
expect(response.data?.title).toBe("Updated");
|
|
});
|
|
|
|
it("should reject model changes after session creation", async () => {
|
|
const created = await client.session.create({ body: { title: "Original" } });
|
|
const sessionId = created.data?.id!;
|
|
|
|
const payloads = [
|
|
{ providerID: "codex", modelID: "gpt-5" },
|
|
{ provider_id: "codex", model_id: "gpt-5" },
|
|
{ providerId: "codex", modelId: "gpt-5" },
|
|
];
|
|
|
|
for (const payload of payloads) {
|
|
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
Authorization: `Bearer ${handle.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(data?.errors?.[0]?.message).toBe(
|
|
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."
|
|
);
|
|
}
|
|
});
|
|
|
|
it("should reject prompt model changes after the first prompt", async () => {
|
|
const created = await client.session.create({ body: { title: "Model Lock" } });
|
|
const sessionId = created.data?.id!;
|
|
|
|
const firstPrompt = await client.session.prompt({
|
|
path: { id: sessionId },
|
|
body: {
|
|
model: { providerID: "mock", modelID: "mock" },
|
|
parts: [{ type: "text", text: "first" }],
|
|
},
|
|
});
|
|
expect(firstPrompt.error).toBeUndefined();
|
|
|
|
const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/message`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${handle.token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
model: { providerID: "codex", modelID: "gpt-5" },
|
|
parts: [{ type: "text", text: "second" }],
|
|
}),
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(data?.errors?.[0]?.message).toBe(
|
|
"OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("session.delete", () => {
|
|
it("should delete a session", async () => {
|
|
const created = await client.session.create();
|
|
const sessionId = created.data?.id!;
|
|
|
|
await client.session.delete({ path: { id: sessionId } });
|
|
|
|
const response = await client.session.get({ path: { id: sessionId } });
|
|
expect(response.error).toBeDefined();
|
|
});
|
|
|
|
it("should not affect other sessions when one is deleted", async () => {
|
|
const session1 = await client.session.create({ body: { title: "Keep" } });
|
|
const session2 = await client.session.create({ body: { title: "Delete" } });
|
|
|
|
await client.session.delete({ path: { id: session2.data?.id! } });
|
|
|
|
const response = await client.session.get({ path: { id: session1.data?.id! } });
|
|
expect(response.data).toBeDefined();
|
|
expect(response.data?.title).toBe("Keep");
|
|
});
|
|
});
|
|
});
|