sandbox-agent/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts
2026-02-11 07:57:02 -08:00

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");
});
});
});