源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,751 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user