Files
openwork/apps/server/src/headless-threads.e2e.test.ts
T

259 lines
9.2 KiB
TypeScript

import { afterEach, expect, test } from "bun:test";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHeadlessThreadClient } from "@openwork/headless-threads";
import { startServer } from "./server.js";
import type { ServerConfig } from "./types.js";
type Served = {
port: number;
stop: (closeActiveConnections?: boolean) => void | Promise<void>;
};
const SESSION_ID = "ses_headless";
const FIRST_ANSWER = "The refund window closed after 30 days.";
const SECOND_ANSWER = "Without a receipt, use the order lookup.";
const stops: Array<() => void | Promise<void>> = [];
const roots: string[] = [];
afterEach(async () => {
while (stops.length) await stops.pop()?.();
while (roots.length) await rm(roots.pop()!, { recursive: true, force: true });
});
function message(id: string, role: string, text: string) {
return {
info: { id, sessionID: SESSION_ID, role, time: { created: 100 } },
parts: [{ id: `prt_${id}`, messageID: id, sessionID: SESSION_ID, type: "text", text }],
};
}
const USER_1 = message("msg_1", "user", "A customer wants a refund after 40 days.");
const ASSISTANT_1 = message("msg_2", "assistant", FIRST_ANSWER);
const USER_2 = message("msg_3", "user", "They also lost the receipt.");
const ASSISTANT_2 = message("msg_4", "assistant", SECOND_ANSWER);
/**
* What the engine looks like at each step of two turns. Beat 0 and beat 3 are
* the moments that matter: the thread has accepted a prompt but has not
* started, so it reads idle with no new reply. A client that settles there
* reports a turn finished before it began.
*/
const BEATS = [
{ busy: false, messages: [USER_1] },
{ busy: true, messages: [USER_1] },
{ busy: false, messages: [USER_1, ASSISTANT_1] },
{ busy: false, messages: [USER_1, ASSISTANT_1, USER_2] },
{ busy: true, messages: [USER_1, ASSISTANT_1, USER_2] },
{ busy: false, messages: [USER_1, ASSISTANT_1, USER_2, ASSISTANT_2] },
];
async function createWorkspaceRoot() {
const root = await mkdtemp(join(tmpdir(), "openwork-headless-threads-"));
await mkdir(join(root, ".opencode"), { recursive: true });
roots.push(root);
return root;
}
/**
* A managed-OpenCode stand-in. It only moves to the next beat when `advance()`
* is called, which the test wires to the client's sleep — so the whole journey
* is deterministic and needs no wall-clock waiting.
*/
function startMockOpencode() {
const prompts: Array<Record<string, unknown>> = [];
let aborted = 0;
let beat = 0;
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const url = new URL(request.url);
const current = BEATS[Math.min(beat, BEATS.length - 1)];
const session = {
id: SESSION_ID,
title: "Refund policy",
slug: "refund-policy",
directory: request.headers.get("x-opencode-directory"),
time: { created: 100, updated: 100 },
};
if (url.pathname === "/session" && request.method === "POST") return Response.json(session);
if (url.pathname === "/session" && request.method === "GET") return Response.json([session]);
if (url.pathname === `/session/${SESSION_ID}`) return Response.json(session);
if (url.pathname === `/session/${SESSION_ID}/message`) return Response.json(current.messages);
if (url.pathname === `/session/${SESSION_ID}/todo`) return Response.json([]);
if (url.pathname === "/session/status") {
return Response.json(current.busy ? { [SESSION_ID]: { type: "busy" } } : {});
}
if (url.pathname === `/session/${SESSION_ID}/prompt_async` && request.method === "POST") {
prompts.push(await request.json());
return new Response(null, { status: 204 });
}
if (url.pathname === `/session/${SESSION_ID}/abort` && request.method === "POST") {
aborted += 1;
return Response.json(true);
}
return Response.json({ code: "not_found", message: `Not found: ${request.method} ${url.pathname}` }, { status: 404 });
},
}) as Served;
stops.push(() => server.stop(true));
return {
server,
prompts,
advance: () => {
beat += 1;
},
abortCount: () => aborted,
};
}
async function startOpenworkServer(input: { workspaceRoot: string; opencodeBaseUrl: string }) {
const config: ServerConfig = {
host: "127.0.0.1",
port: 0,
token: "owt_test_token",
hostToken: "owt_host_token",
approval: { mode: "auto", timeoutMs: 1000 },
corsOrigins: ["*"],
workspaces: [
{
id: "ws_1",
name: "Workspace",
path: input.workspaceRoot,
preset: "starter",
workspaceType: "local",
baseUrl: input.opencodeBaseUrl,
},
],
authorizedRoots: [input.workspaceRoot],
readOnly: false,
startedAt: Date.now(),
tokenSource: "cli",
hostTokenSource: "cli",
logFormat: "pretty",
logRequests: false,
};
const server = await startServer(config) as Served;
stops.push(() => server.stop(true));
return { server, token: config.token };
}
test("drives two headless turns on a native OpenWork thread", async () => {
const workspaceRoot = await createWorkspaceRoot();
const engine = startMockOpencode();
const openwork = await startOpenworkServer({
workspaceRoot,
opencodeBaseUrl: `http://127.0.0.1:${engine.server.port}`,
});
let clock = 0;
const threads = createHeadlessThreadClient({
baseUrl: `http://127.0.0.1:${openwork.server.port}`,
workspaceId: "ws_1",
token: openwork.token,
defaultModel: { providerId: "anthropic", modelId: "claude-sonnet-5" },
now: () => clock,
// Each poll gap moves the engine exactly one beat, so the journey below is
// reproducible and never waits on real time.
sleep: async (ms) => {
clock += ms;
engine.advance();
},
});
const thread = await threads.createThread({
title: "Refund policy",
prompt: "A customer wants a refund after 40 days.",
});
expect(thread.id).toBe(SESSION_ID);
expect(thread.started).toBe(true);
expect(thread.directory).toBe(workspaceRoot);
expect(engine.prompts).toHaveLength(1);
expect(engine.prompts[0]).toEqual({
parts: [{ type: "text", text: "A customer wants a refund after 40 days." }],
model: { providerID: "anthropic", modelID: "claude-sonnet-5" },
});
const firstWait = await threads.waitForThread(thread.id, { timeoutMs: 60_000, pollIntervalMs: 10 });
expect(firstWait.outcome).toBe("settled");
expect(firstWait.observedRunning).toBe(true);
// Beat 0 was idle with no reply yet: settling there would have been wrong.
expect(firstWait.polls).toBe(3);
const firstTranscript = await threads.exportTranscript(thread.id);
expect(firstTranscript.finalAssistantText).toBe(FIRST_ANSWER);
expect(firstTranscript.messages.map((entry) => entry.role)).toEqual(["user", "assistant"]);
const followUp = await threads.sendTurn(thread.id, { prompt: "They also lost the receipt." });
expect(followUp.messageCountBefore).toBe(2);
expect(engine.prompts).toHaveLength(2);
expect(engine.prompts[1]).toEqual({
parts: [{ type: "text", text: "They also lost the receipt." }],
model: { providerID: "anthropic", modelID: "claude-sonnet-5" },
});
const secondWait = await threads.waitForThread(thread.id, {
timeoutMs: 60_000,
pollIntervalMs: 10,
since: followUp,
});
expect(secondWait.outcome).toBe("settled");
expect(secondWait.observedRunning).toBe(true);
// The first answer was already in the thread; only the new one settles it.
expect(secondWait.polls).toBe(4);
const transcript = await threads.exportTranscript(thread.id);
expect(transcript.finalAssistantText).toBe(SECOND_ANSWER);
expect(transcript.messages.map((entry) => entry.text)).toEqual([
"A customer wants a refund after 40 days.",
FIRST_ANSWER,
"They also lost the receipt.",
SECOND_ANSWER,
]);
await expect(threads.abortThread(thread.id)).resolves.toEqual({ threadId: thread.id, accepted: true });
expect(engine.abortCount()).toBe(1);
});
test("leaves the thread readable through the session surface the app uses", async () => {
const workspaceRoot = await createWorkspaceRoot();
const engine = startMockOpencode();
const openwork = await startOpenworkServer({
workspaceRoot,
opencodeBaseUrl: `http://127.0.0.1:${engine.server.port}`,
});
const base = `http://127.0.0.1:${openwork.server.port}`;
const thread = await createHeadlessThreadClient({
baseUrl: base,
workspaceId: "ws_1",
token: openwork.token,
}).createThread({ title: "Refund policy" });
// The app lists and opens sessions through the native workspace mount. A headless thread
// is an ordinary session, so it has to be visible here with the same id.
const listed = await fetch(`${base}/workspace/ws_1/opencode/session`, {
headers: { Authorization: `Bearer ${openwork.token}` },
});
expect(listed.status).toBe(200);
const listedBody = await listed.json();
expect(listedBody.map((item: { id: string }) => item.id)).toContain(thread.id);
const opened = await fetch(`${base}/workspace/ws_1/opencode/session/${thread.id}`, {
headers: { Authorization: `Bearer ${openwork.token}` },
});
expect(opened.status).toBe(200);
await expect(opened.json()).resolves.toMatchObject({ id: thread.id, title: "Refund policy" });
});