Files
openwork/packages/headless-threads/src/client.test.ts
T

752 lines
28 KiB
TypeScript

import { createServer, type Server } from "node:http";
import { describe, expect, test } from "bun:test";
import { createHeadlessThreadClient } from "./client.js";
import { HeadlessThreadError } from "./errors.js";
import type { HeadlessFetch, HeadlessThreadStatus } from "./types.js";
type RecordedRequest = {
method: string;
path: string;
body: unknown;
headers: Headers;
redirect: RequestRedirect | undefined;
signal: AbortSignal | undefined;
};
type MessageWire = { info: { id: string; role: string; parentID?: string; time?: { created: number }; error?: unknown; tokens?: unknown; cost?: number }; parts: unknown[] };
/** One poll's worth of thread state, consumed in order by snapshot reads. */
type Beat = { status: HeadlessThreadStatus; messages: MessageWire[] };
const SESSION_ID = "ses_1";
const BASE_URL = "http://openwork.test";
function listen(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
}
function close(server: Server): Promise<void> {
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
function serverUrl(server: Server): string {
const address = server.address();
if (!address || typeof address === "string") throw new Error("test server did not bind");
return `http://127.0.0.1:${address.port}`;
}
function reply(id: string, role: string, text?: string, parentID?: string): MessageWire {
return {
info: { id, role, time: { created: 1 }, ...(parentID ? { parentID } : {}) },
parts: text === undefined ? [] : [{ id: `prt_${id}`, type: "text", text }],
};
}
/**
* A stand-in for the OpenWork server's native OpenCode proxy. `beats` scripts what
* successive snapshot reads observe, so a wait can be tested without a clock
* or an engine.
*/
function createOpenworkDouble(input?: { beats?: Beat[]; messages?: MessageWire[]; abortResult?: boolean }) {
const requests: RecordedRequest[] = [];
const beats = input?.beats ?? [];
const messages = input?.messages ?? [];
let beatIndex = 0;
let activeBeat: Beat | undefined;
const session = { id: SESSION_ID, title: "Refund policy", directory: "/workspace", time: { created: 1 } };
const fetchImpl: HeadlessFetch = async (url, init) => {
const parsed = new URL(url);
const method = init?.method ?? "GET";
const body: unknown = init?.body === undefined ? undefined : JSON.parse(init.body);
requests.push({
method,
path: `${parsed.pathname}${parsed.search}`,
body,
headers: new Headers(init?.headers),
redirect: init?.redirect,
signal: init?.signal,
});
if (method === "POST" && parsed.pathname === "/workspace/ws_1/opencode/session") {
return Response.json(session);
}
if (method === "GET" && parsed.pathname === `/workspace/ws_1/opencode/session/${SESSION_ID}`) {
activeBeat = beats[Math.min(beatIndex, beats.length - 1)];
beatIndex += 1;
return Response.json(session);
}
if (method === "GET" && parsed.pathname === `/workspace/ws_1/opencode/session/${SESSION_ID}/message`) {
return Response.json(activeBeat?.messages ?? messages);
}
if (method === "GET" && parsed.pathname === `/workspace/ws_1/opencode/session/${SESSION_ID}/todo`) {
return Response.json([]);
}
if (method === "GET" && parsed.pathname === "/workspace/ws_1/opencode/session/status") {
const status = activeBeat?.status;
return Response.json(status === undefined || status.type === "idle" ? {} : { [SESSION_ID]: status });
}
if (method === "POST" && parsed.pathname === `/workspace/ws_1/opencode/session/${SESSION_ID}/prompt_async`) {
return new Response(null, { status: 204 });
}
if (method === "POST" && parsed.pathname === `/workspace/ws_1/opencode/session/${SESSION_ID}/abort`) {
return Response.json(input?.abortResult ?? true);
}
return Response.json({ code: "not_found", message: "Not found" }, { status: 404 });
};
return { fetchImpl, requests, snapshotReads: () => beatIndex };
}
/** A clock that only moves when the client sleeps, so waits are instant. */
function createClock() {
let clock = 0;
return {
now: () => clock,
sleep: async (ms: number) => {
clock += ms;
},
elapsed: () => clock,
};
}
function createClient(double: ReturnType<typeof createOpenworkDouble>, clock = createClock()) {
return createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws_1",
token: "owt_test",
fetch: double.fetchImpl,
now: clock.now,
sleep: clock.sleep,
});
}
describe("createThread", () => {
test("creates first, then submits the initial prompt in OpenCode's casing", async () => {
const double = createOpenworkDouble();
const thread = await createClient(double).createThread({
title: "Refund policy",
prompt: "A customer wants a refund after 40 days.",
model: { providerId: "anthropic", modelId: "claude-sonnet-5", variant: "thinking" },
});
expect(double.requests.map(({ method, path }) => ({ method, path }))).toEqual([
{ method: "POST", path: "/workspace/ws_1/opencode/session" },
{ method: "POST", path: "/workspace/ws_1/opencode/session/ses_1/prompt_async" },
]);
expect(double.requests[0]?.body).toEqual({ title: "Refund policy" });
expect(double.requests[1]?.body).toEqual({
parts: [{ type: "text", text: "A customer wants a refund after 40 days." }],
model: { providerID: "anthropic", modelID: "claude-sonnet-5" },
variant: "thinking",
});
expect(thread).toEqual({
id: SESSION_ID,
workspaceId: "ws_1",
title: "Refund policy",
directory: "/workspace",
createdAt: 1,
started: true,
});
});
test("normalizes a base URL that ends in slashes", async () => {
const double = createOpenworkDouble();
const client = createHeadlessThreadClient({
baseUrl: `${BASE_URL}///`,
workspaceId: "ws_1",
token: "owt_test",
fetch: double.fetchImpl,
});
await client.createThread({ title: "Refund policy" });
expect(double.requests[0]?.path).toBe("/workspace/ws_1/opencode/session");
});
test("authenticates server-to-server Cloud requests with both worker tokens", async () => {
const double = createOpenworkDouble();
const client = createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws_1",
token: "client-token",
hostToken: "host-token",
fetch: double.fetchImpl,
});
await client.createThread({ title: "Cloud Automation" });
expect(double.requests[0]?.headers.get("authorization")).toBe("Bearer client-token");
expect(double.requests[0]?.headers.get("x-openwork-host-token")).toBe("host-token");
expect(double.requests[0]?.redirect).toBe("error");
});
test("omits the optional host token", async () => {
const double = createOpenworkDouble();
await createClient(double).createThread({ title: "Local" });
expect(double.requests[0]?.headers.get("authorization")).toBe("Bearer owt_test");
expect(double.requests[0]?.headers.has("x-openwork-host-token")).toBe(false);
});
test("omits the prompt and model when none were given", async () => {
const double = createOpenworkDouble();
const thread = await createClient(double).createThread({ title: "Empty" });
expect(double.requests[0]?.body).toEqual({ title: "Empty" });
expect(double.requests).toHaveLength(1);
expect(thread.started).toBe(false);
});
test("treats an explicitly empty initial prompt as not started", async () => {
const double = createOpenworkDouble();
const thread = await createClient(double).createThread({ title: "Empty prompt", prompt: "" });
expect(double.requests).toHaveLength(1);
expect(thread.started).toBe(false);
});
test("preserves client validation and normalization for initial title and prompt", async () => {
const double = createOpenworkDouble();
const client = createClient(double);
await client.createThread({ title: " Refund policy ", prompt: " Review it " });
expect(double.requests[0]?.body).toEqual({ title: "Refund policy" });
expect(double.requests[1]?.body).toEqual({ parts: [{ type: "text", text: "Review it" }] });
await expect(client.createThread({ title: " " })).rejects.toMatchObject({
code: "invalid_payload",
status: 400,
});
await expect(client.createThread({ title: "Valid", prompt: " " })).rejects.toMatchObject({
code: "invalid_payload",
status: 400,
});
await expect(client.createThread({ title: "x".repeat(121) })).rejects.toMatchObject({
code: "invalid_payload",
status: 400,
});
await expect(client.createThread({ title: "Valid", prompt: "x".repeat(100_001) })).rejects.toMatchObject({
code: "invalid_payload",
status: 400,
});
});
});
describe("sendTurn", () => {
test("records the pre-turn message count and prompts in OpenCode's casing", async () => {
const double = createOpenworkDouble({ messages: [reply("msg_1", "user"), reply("msg_2", "assistant", "hi")] });
const acceptance = await createClient(double).sendTurn(SESSION_ID, {
prompt: "They also lost the receipt.",
model: { providerId: "anthropic", modelId: "claude-sonnet-5" },
});
expect(acceptance).toEqual({
threadId: SESSION_ID,
acceptedAt: 0,
messageCountBefore: 2,
messageId: null,
alreadyPresent: false,
});
expect(double.requests.map((request) => request.path)).toEqual([
"/workspace/ws_1/opencode/session/ses_1/message",
"/workspace/ws_1/opencode/session/ses_1/prompt_async",
]);
expect(double.requests[1]?.body).toEqual({
parts: [{ type: "text", text: "They also lost the receipt." }],
model: { providerID: "anthropic", modelID: "claude-sonnet-5" },
});
});
test("falls back to the client's default model", async () => {
const double = createOpenworkDouble({ messages: [] });
const client = createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws_1",
token: "owt_test",
fetch: double.fetchImpl,
defaultModel: { providerId: "openai", modelId: "gpt-5" },
});
await client.sendTurn(SESSION_ID, { prompt: "hello" });
expect(double.requests[1]?.body).toEqual({
parts: [{ type: "text", text: "hello" }],
model: { providerID: "openai", modelID: "gpt-5" },
});
});
test("uses a stable message id and does not submit it twice", async () => {
const double = createOpenworkDouble({ messages: [reply("msg_run_1", "user")] });
const acceptance = await createClient(double).sendTurn(SESSION_ID, {
prompt: "Run the report.",
messageId: "msg_run_1",
});
expect(acceptance.alreadyPresent).toBe(true);
expect(acceptance.messageId).toBe("msg_run_1");
expect(double.requests).toHaveLength(1);
expect(double.requests[0]?.path).toBe("/workspace/ws_1/opencode/session/ses_1/message");
});
test("passes a new stable message id to OpenCode", async () => {
const double = createOpenworkDouble({ messages: [] });
await createClient(double).sendTurn(SESSION_ID, { prompt: "Run it.", messageId: "msg_run_2" });
expect(double.requests[1]?.body).toEqual({
parts: [{ type: "text", text: "Run it." }],
messageID: "msg_run_2",
});
});
test("URL-encodes workspace and session IDs on native proxy calls", async () => {
const paths: string[] = [];
const fetchImpl: HeadlessFetch = async (url) => {
const path = new URL(url).pathname;
paths.push(path);
if (path.endsWith("/message")) return Response.json([]);
return new Response(null, { status: 204 });
};
const client = createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws /?",
token: "owt_test",
fetch: fetchImpl,
});
await client.sendTurn("ses /?", { prompt: "hello" });
expect(paths).toEqual([
"/workspace/ws%20%2F%3F/opencode/session/ses%20%2F%3F/message",
"/workspace/ws%20%2F%3F/opencode/session/ses%20%2F%3F/prompt_async",
]);
});
});
describe("getThreadSnapshot", () => {
test("reads session, messages, todos, and status in parallel", async () => {
const requests: string[] = [];
let markStarted: () => void = () => {};
let releaseRequests: () => void = () => {};
const allStarted = new Promise<void>((resolve) => {
markStarted = resolve;
});
const released = new Promise<void>((resolve) => {
releaseRequests = resolve;
});
const fetchImpl: HeadlessFetch = async (url) => {
const path = new URL(url).pathname;
requests.push(path);
if (requests.length === 4) markStarted();
await released;
if (path.endsWith("/message")) return Response.json([]);
if (path.endsWith("/todo")) return Response.json([{ content: "Check", status: "pending", priority: "high" }]);
if (path.endsWith("/status")) return Response.json({ [SESSION_ID]: { type: "busy" } });
return Response.json({ id: SESSION_ID, title: "Refund policy", directory: "/workspace", time: { created: 1 } });
};
const client = createHeadlessThreadClient({ baseUrl: BASE_URL, workspaceId: "ws_1", token: "owt_test", fetch: fetchImpl });
const pending = client.getThreadSnapshot(SESSION_ID);
await allStarted;
expect(requests).toEqual([
"/workspace/ws_1/opencode/session/ses_1",
"/workspace/ws_1/opencode/session/ses_1/message",
"/workspace/ws_1/opencode/session/ses_1/todo",
"/workspace/ws_1/opencode/session/status",
]);
releaseRequests();
await expect(pending).resolves.toMatchObject({
threadId: SESSION_ID,
status: { type: "busy" },
todos: [{ content: "Check", status: "pending", priority: "high" }],
});
});
test("falls back to idle when the native status map omits the session", async () => {
const snapshot = await createClient(createOpenworkDouble()).getThreadSnapshot(SESSION_ID);
expect(snapshot.status).toEqual({ type: "idle" });
});
});
describe("waitForThread", () => {
test("does not call an idle thread finished before the engine has started", async () => {
// The first beat is the gap between accepting a prompt and starting work:
// the thread is idle and has no new reply. Settling there would report a
// turn finished before it began.
const double = createOpenworkDouble({
beats: [
{ status: { type: "idle" }, messages: [reply("msg_1", "user")] },
{ status: { type: "busy" }, messages: [reply("msg_1", "user")] },
{ status: { type: "retry", attempt: 1, message: "rate limited", next: 5 }, messages: [reply("msg_1", "user")] },
{ status: { type: "idle" }, messages: [reply("msg_1", "user"), reply("msg_2", "assistant", "Answer.")] },
],
});
const result = await createClient(double).waitForThread(SESSION_ID, { timeoutMs: 10_000, pollIntervalMs: 100 });
expect(result.outcome).toBe("settled");
expect(result.polls).toBe(4);
expect(result.observedRunning).toBe(true);
expect(result.waitedMs).toBe(300);
expect(result.snapshot.status).toEqual({ type: "idle" });
});
test("ignores an assistant reply that predates the turn being waited on", async () => {
const before = [reply("msg_1", "user"), reply("msg_2", "assistant", "First answer.")];
const double = createOpenworkDouble({
beats: [
{ status: { type: "idle" }, messages: before },
{ status: { type: "idle" }, messages: [...before, reply("msg_3", "user")] },
{ status: { type: "idle" }, messages: [...before, reply("msg_3", "user"), reply("msg_4", "assistant", "Second.")] },
],
});
const result = await createClient(double).waitForThread(SESSION_ID, {
timeoutMs: 10_000,
pollIntervalMs: 100,
since: { messageCountBefore: 2 },
});
expect(result.outcome).toBe("settled");
expect(result.polls).toBe(3);
expect(result.snapshot.messages.at(-1)?.id).toBe("msg_4");
});
test("reports a timeout instead of throwing when the thread never answers", async () => {
const clock = createClock();
const double = createOpenworkDouble({
beats: [{ status: { type: "busy" }, messages: [reply("msg_1", "user")] }],
});
const result = await createClient(double, clock).waitForThread(SESSION_ID, {
timeoutMs: 1_000,
pollIntervalMs: 400,
});
expect(result.outcome).toBe("timeout");
expect(result.observedRunning).toBe(true);
expect(clock.elapsed()).toBe(1_000);
expect(result.waitedMs).toBe(1_000);
});
test("stops on an aborted signal", async () => {
const controller = new AbortController();
controller.abort();
const double = createOpenworkDouble({
beats: [{ status: { type: "busy" }, messages: [] }],
});
const result = await createClient(double).waitForThread(SESSION_ID, {
timeoutMs: 10_000,
signal: controller.signal,
});
expect(result.outcome).toBe("aborted");
expect(result.polls).toBe(0);
});
test("aborting mid-wait stops polling after the in-flight beat and never calls abortThread implicitly", async () => {
const controller = new AbortController();
const clock = createClock();
// The turn never settles: every beat reports a busy engine.
const double = createOpenworkDouble({
beats: [{ status: { type: "busy" }, messages: [reply("msg_1", "user")] }],
});
// Abort while the wait sleeps between polls — mid-stream, not before the call.
const sleep = async (ms: number) => {
await clock.sleep(ms);
controller.abort();
};
const client = createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws_1",
token: "owt_test",
fetch: double.fetchImpl,
now: clock.now,
sleep,
});
const result = await client.waitForThread(SESSION_ID, {
timeoutMs: 10_000,
pollIntervalMs: 100,
signal: controller.signal,
});
expect(result.outcome).toBe("aborted");
expect(result.observedRunning).toBe(true);
// Exactly one live poll happened before the abort; the final snapshot read
// documents state at abort time instead of counting as a poll.
expect(result.polls).toBe(1);
expect(double.snapshotReads()).toBe(2);
// Cancelling a wait must never cancel the engine turn on the caller's behalf.
expect(double.requests.some((request) => request.path.endsWith("/abort"))).toBe(false);
});
test("matches the assistant response to the stable user message", async () => {
const double = createOpenworkDouble({
beats: [{
status: { type: "idle" },
messages: [
reply("msg_old_answer", "assistant", "old", "msg_old"),
reply("msg_new_answer", "assistant", "new", "msg_run_1"),
],
}],
});
const result = await createClient(double).waitForThread(SESSION_ID, {
timeoutMs: 1_000,
since: { messageCountBefore: 2, messageId: "msg_run_1" },
});
expect(result.outcome).toBe("settled");
});
test("reports a terminal assistant error", async () => {
const failed = reply("msg_failed", "assistant", undefined, "msg_run_1");
failed.info.error = { name: "ProviderAuthError", data: { message: "Reconnect the provider." } };
const double = createOpenworkDouble({ beats: [{ status: { type: "idle" }, messages: [failed] }] });
const result = await createClient(double).waitForThread(SESSION_ID, {
timeoutMs: 1_000,
since: { messageCountBefore: 0, messageId: "msg_run_1" },
});
expect(result.outcome).toBe("failed");
expect(result.terminalError).toMatchObject({ name: "ProviderAuthError", message: "Reconnect the provider." });
});
});
describe("failures", () => {
test("surfaces the server's error code and status", async () => {
const double = createOpenworkDouble();
const client = createClient(double);
const error = await client.getThreadSnapshot("ses_missing").catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(HeadlessThreadError);
if (!(error instanceof HeadlessThreadError)) throw new Error("expected a HeadlessThreadError");
expect(error.code).toBe("not_found");
expect(error.status).toBe(404);
expect(error.method).toBe("GET");
expect(error.path).toBe("/workspace/ws_1/opencode/session/ses_missing");
expect(error.message).toBe("Not found");
expect(error.body).toEqual({ code: "not_found", message: "Not found" });
});
test("rejects a payload that does not match the session read model", async () => {
const fetchImpl: HeadlessFetch = async () => Response.json({ item: { session: { id: 7 } } });
const client = createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws_1",
token: "owt_test",
fetch: fetchImpl,
});
const error = await client.getThreadSnapshot(SESSION_ID).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(HeadlessThreadError);
if (!(error instanceof HeadlessThreadError)) throw new Error("expected a HeadlessThreadError");
expect(error.code).toBe("invalid_response");
});
test("bounds an individual request even when the caller has no deadline", async () => {
const fetchImpl: HeadlessFetch = async (_url, init) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
});
const client = createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws_1",
token: "owt_test",
fetch: fetchImpl,
requestTimeoutMs: 5,
});
const error = await client.getThreadSnapshot(SESSION_ID).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(HeadlessThreadError);
if (!(error instanceof HeadlessThreadError)) throw new Error("expected a HeadlessThreadError");
expect(error.code).toBe("request_failed");
expect(error.method).toBe("GET");
expect(error.path).toBe("/workspace/ws_1/opencode/session/ses_1");
expect(error.status).toBeNull();
});
test("defaults individual request timeouts to 15 seconds", async () => {
const originalTimeout = AbortSignal.timeout;
const timeoutDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "timeout");
if (!timeoutDescriptor) throw new Error("AbortSignal.timeout is unavailable");
const requested: number[] = [];
Object.defineProperty(AbortSignal, "timeout", {
configurable: true,
value: (milliseconds: number) => {
requested.push(milliseconds);
return originalTimeout(milliseconds);
},
});
try {
await createClient(createOpenworkDouble()).createThread({ title: "Default timeout" });
expect(requested).toEqual([15_000]);
} finally {
Object.defineProperty(AbortSignal, "timeout", timeoutDescriptor);
}
});
test("disables request timeouts when configured with zero", async () => {
const originalTimeout = AbortSignal.timeout;
const timeoutDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "timeout");
if (!timeoutDescriptor) throw new Error("AbortSignal.timeout is unavailable");
const requested: number[] = [];
Object.defineProperty(AbortSignal, "timeout", {
configurable: true,
value: (milliseconds: number) => {
requested.push(milliseconds);
return originalTimeout(milliseconds);
},
});
const double = createOpenworkDouble();
const client = createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws_1",
token: "owt_test",
fetch: double.fetchImpl,
requestTimeoutMs: 0,
});
try {
await client.createThread({ title: "No timeout" });
expect(requested).toEqual([]);
expect(double.requests[0]?.signal?.aborted).toBe(false);
} finally {
Object.defineProperty(AbortSignal, "timeout", timeoutDescriptor);
}
});
test("merges the client-wide and per-call signals into SDK requests", async () => {
const globalController = new AbortController();
const callController = new AbortController();
const first = createOpenworkDouble();
const firstClient = createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws_1",
token: "owt_test",
signal: globalController.signal,
fetch: first.fetchImpl,
});
await firstClient.createThread({ title: "Global signal", signal: callController.signal });
const firstSignal = first.requests[0]?.signal;
expect(firstSignal?.aborted).toBe(false);
globalController.abort();
expect(firstSignal?.aborted).toBe(true);
const secondGlobal = new AbortController();
const secondCall = new AbortController();
const second = createOpenworkDouble();
const secondClient = createHeadlessThreadClient({
baseUrl: BASE_URL,
workspaceId: "ws_1",
token: "owt_test",
signal: secondGlobal.signal,
fetch: second.fetchImpl,
});
await secondClient.createThread({ title: "Call signal", signal: secondCall.signal });
const secondSignal = second.requests[0]?.signal;
expect(secondSignal?.aborted).toBe(false);
secondCall.abort();
expect(secondSignal?.aborted).toBe(true);
});
test("a custom Cloud fetch cannot follow a 307 with the thread body or tokens", async () => {
const targetRequests: Array<{ authorization: string | undefined; hostToken: string | undefined }> = [];
const target = createServer((request, response) => {
targetRequests.push({
authorization: request.headers.authorization,
hostToken: typeof request.headers["x-openwork-host-token"] === "string" ? request.headers["x-openwork-host-token"] : undefined,
});
response.end("unexpected");
});
const preview = createServer((_request, response) => {
response.writeHead(307, { Location: `${serverUrl(target)}/captured` });
response.end();
});
await Promise.all([listen(target), listen(preview)]);
try {
const client = createHeadlessThreadClient({
baseUrl: serverUrl(preview),
workspaceId: "ws_1",
token: "client-secret",
hostToken: "host-secret",
fetch: (url, init) => fetch(url, init),
});
await expect(client.createThread({ title: "Secret thread" })).rejects.toBeDefined();
expect(targetRequests).toEqual([]);
} finally {
await Promise.all([close(preview), close(target)]);
}
});
});
describe("abortThread", () => {
test("reports acceptance without claiming the run stopped", async () => {
const double = createOpenworkDouble();
await expect(createClient(double).abortThread(SESSION_ID)).resolves.toEqual({
threadId: SESSION_ID,
accepted: true,
});
});
test("preserves a native false abort result", async () => {
const double = createOpenworkDouble({ abortResult: false });
await expect(createClient(double).abortThread(SESSION_ID)).resolves.toEqual({
threadId: SESSION_ID,
accepted: false,
});
expect(double.requests[0]?.path).toBe("/workspace/ws_1/opencode/session/ses_1/abort");
});
test("waits until the aborted thread is observably idle", async () => {
const double = createOpenworkDouble({ beats: [
{ status: { type: "busy" }, messages: [] },
{ status: { type: "idle" }, messages: [] },
] });
const result = await createClient(double).waitUntilIdle(SESSION_ID, { timeoutMs: 1_000, pollIntervalMs: 10 });
expect(result.outcome).toBe("settled");
expect(result.observedRunning).toBe(true);
});
});
describe("exportTranscript", () => {
test("flattens the current snapshot", async () => {
const double = createOpenworkDouble({
beats: [
{
status: { type: "idle" },
messages: [reply("msg_1", "user", "Why?"), reply("msg_2", "assistant", "Because.")],
},
],
});
const transcript = await createClient(double).exportTranscript(SESSION_ID);
expect(transcript.threadId).toBe(SESSION_ID);
expect(transcript.finalAssistantText).toBe("Because.");
expect(transcript.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
});
});