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