源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)

This commit is contained in:
agent-radar
2026-09-02 01:05:41 +08:00
commit 04bac1f6e2
2200 changed files with 489415 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
# @openwork/headless-threads
Drive a native OpenWork thread from code, without rendering the app.
A "thread" here is an ordinary OpenWork session. Same workspace, same managed
OpenCode engine, same session id, same persisted messages, tool activity, and
final state the desktop UI shows. A thread this package creates can be opened
in the app afterwards, because there is no separate headless thread type.
This is a client, not a runtime. It adds no chat engine, no session store, no
model gateway, and no new server route — it is a typed workflow over the
official OpenCode SDK through OpenWork's workspace-scoped proxy.
## Why
Anything that drives OpenWork without a UI — a load check, an automation
harness, an agent-quality benchmark — currently re-implements the same
sequence by hand against raw HTTP: create a session, submit `prompt_async`,
poll `/session/status`, poll messages, work out when the turn actually
finished, then dig text back out of message parts.
Legacy harnesses contained one such copy. This package is that sequence, typed
and tested once.
Note what the hand-rolled version has to get right, and what this package now
owns: a thread is still `idle` in the gap between accepting a prompt and
starting work, so waiting for "not busy" alone reports a turn finished before
it began. Settling requires a non-running status **and** an assistant message
that was not there when the turn was submitted.
## Use
```ts
import { createHeadlessThreadClient } from "@openwork/headless-threads";
const threads = createHeadlessThreadClient({
baseUrl: "http://127.0.0.1:8787",
workspaceId: "ws_1",
token: process.env.OPENWORK_TOKEN,
defaultModel: { providerId: "anthropic", modelId: "claude-sonnet-5" },
});
const thread = await threads.createThread({
title: "Refund policy",
prompt: "A customer wants a refund after 40 days. What are their options?",
});
await threads.waitForThread(thread.id, { timeoutMs: 120_000 });
const followUp = await threads.sendTurn(thread.id, {
prompt: "They also lost the receipt.",
});
const waited = await threads.waitForThread(thread.id, {
timeoutMs: 120_000,
since: followUp,
});
if (waited.outcome === "settled") {
const transcript = await threads.exportTranscript(thread.id);
console.log(transcript.finalAssistantText);
}
```
`thread.id` is the native session id. Opening OpenWork on the same workspace
shows this conversation in the sidebar.
## Contract
`AgentSessionClient` is the intentionally small replaceable runtime port:
create a thread, send a turn, read a snapshot, and abort. `HeadlessThreadClient`
extends it with polling and transcript helpers. OpenCode is currently the only
implementation; UI inventory and cross-workspace plugin routing stay on the
native protocol instead of expanding this port speculatively.
| Function | Does |
| --- | --- |
| `createThread` | Creates a thread, optionally with its first turn and a model. |
| `sendTurn` | Submits a turn and returns an acceptance that records the pre-turn message count. |
| `waitForThread` | Polls to a bounded deadline. Returns `settled`, `timeout`, or `aborted` — never throws on a slow thread. |
| `getThreadSnapshot` | Status, messages, and todos as OpenWork stores them. |
| `abortThread` | Requests a stop. Acceptance is not proof the run ended; wait afterwards to observe idle. |
| `exportTranscript` | Flattens a snapshot into per-message text, reasoning, and tool calls. |
`waitForThread` reports an outcome rather than throwing, so a caller running
many threads can record a timeout as a result instead of losing the run.
Transport and payload failures throw `HeadlessThreadError`, which carries
`code`, `status`, `method`, `path`, and the server's body.
Deterministic by construction: inject `fetch`, `now`, and `sleep` to run the
whole contract against fixtures with no wall-clock dependency.
## Test
```bash
pnpm --filter @openwork/headless-threads test
```
The end-to-end proof lives with the server it drives, in
`apps/server/src/headless-threads.e2e.test.ts`, and runs against a real
OpenWork server:
```bash
pnpm --filter openwork-server test src/headless-threads.e2e.test.ts
```
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@openwork/headless-threads",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"bun": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"src",
"README.md"
],
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean --external zod --target es2022",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "bun test src"
},
"dependencies": {
"@opencode-ai/sdk": "^1.18.15",
"zod": "^4.3.6"
},
"devDependencies": {
"bun-types": "^1.3.6",
"tsup": "^8.5.0",
"typescript": "^5.6.3"
}
}
@@ -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"]);
});
});
+329
View File
@@ -0,0 +1,329 @@
/**
* A function-driven client for native OpenWork threads.
*
* Every call uses the OpenCode SDK through OpenWork's workspace-scoped mount:
*
* - `POST /workspace/:id/opencode/session` creates a thread
* - native session reads provide messages, todos, and status
* - native prompt and abort calls control a turn
*/
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { z } from "zod";
import { HeadlessThreadError } from "./errors.js";
import { assistantReplyForTurn, toTranscript } from "./transcript.js";
import type {
CreateThreadInput,
HeadlessAbortResult,
HeadlessThread,
HeadlessThreadClient,
HeadlessThreadClientOptions,
HeadlessThreadSnapshot,
HeadlessThreadTranscript,
HeadlessThreadTurnInput,
HeadlessThreadWaitInput,
HeadlessThreadWaitResult,
HeadlessTurnAcceptance,
} from "./types.js";
import {
abortResultSchema,
isRunning,
sessionSchema,
threadMessagesSchema,
threadSnapshotSchema,
threadStatusesSchema,
threadTodosSchema,
toSnapshot,
toThread,
} from "./wire.js";
const DEFAULT_POLL_INTERVAL_MS = 500;
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
const errorBodySchema = z
.object({ code: z.string().optional(), message: z.string().optional() })
.passthrough();
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Scanned rather than matched with `/\/+$/`: the anchored form backtracks
* quadratically on a long run of slashes, which CodeQL flags.
*/
function stripTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0 && value[end - 1] === "/") end -= 1;
return value.slice(0, end);
}
export function createHeadlessThreadClient(options: HeadlessThreadClientOptions): HeadlessThreadClient {
const baseUrl = stripTrailingSlashes(options.baseUrl);
const workspaceId = options.workspaceId;
const fetchImpl = options.fetch ?? globalThis.fetch;
const now = options.now ?? Date.now;
const sleep = options.sleep ?? defaultSleep;
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const workspacePath = `/workspace/${encodeURIComponent(workspaceId)}`;
const opencodePath = `${workspacePath}/opencode`;
function invalidCreatePayload(message: string): never {
const path = `${opencodePath}/session`;
throw new HeadlessThreadError({
code: "invalid_payload",
message,
method: "POST",
path,
status: 400,
body: { code: "invalid_payload", message },
});
}
function createTitle(value: unknown): string {
if (typeof value !== "string" || !value.trim()) {
return invalidCreatePayload("title must be a non-empty string");
}
const title = value.trim();
if (title.length > 120) return invalidCreatePayload("title must be 120 characters or fewer");
return title;
}
function initialPrompt(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string" || !value.trim()) {
return invalidCreatePayload("prompt must be a non-empty string");
}
const prompt = value.trim();
if (prompt.length > 100_000) return invalidCreatePayload("prompt must be 100000 characters or fewer");
return prompt;
}
function requestSignal(signal?: AbortSignal): AbortSignal | undefined {
const signals = [options.signal, signal].filter((item): item is AbortSignal => item !== undefined);
if (requestTimeoutMs !== 0) signals.push(AbortSignal.timeout(requestTimeoutMs));
return signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
}
const sdkFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init);
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.text();
return fetchImpl(request.url, {
method: request.method,
headers,
...(body === "" ? {} : { body }),
redirect: "error",
signal: request.signal,
});
};
const opencode = createOpencodeClient({
baseUrl: `${baseUrl}${opencodePath}`,
headers: {
Authorization: `Bearer ${options.token}`,
...(options.hostToken === undefined ? {} : { "X-OpenWork-Host-Token": options.hostToken }),
},
redirect: "error",
fetch: Object.assign(sdkFetch, { preconnect: () => {} }),
});
type SdkResult<T> = {
data?: T;
error?: unknown;
response?: Response;
};
function sdkSuccess<T>(result: SdkResult<T>, method: string, path: string): T | undefined {
if (result.error === undefined) return result.data;
const detail = errorBodySchema.safeParse(result.error);
throw new HeadlessThreadError({
code: detail.success && detail.data.code !== undefined ? detail.data.code : "request_failed",
message: detail.success && detail.data.message !== undefined
? detail.data.message
: result.response
? `OpenWork returned ${result.response.status} for ${method} ${path}`
: `OpenWork request failed for ${method} ${path}`,
method,
path,
...(result.response === undefined ? {} : { status: result.response.status }),
body: result.error,
});
}
function sdkJson<T>(schema: z.ZodType<T>, result: SdkResult<unknown>, method: string, path: string): T {
const parsed = schema.safeParse(sdkSuccess(result, method, path));
if (parsed.success) return parsed.data;
throw new HeadlessThreadError({
code: "invalid_response",
message: `OpenWork returned an unexpected payload for ${method} ${path}`,
method,
path,
...(result.response === undefined ? {} : { status: result.response.status }),
body: parsed.error.issues,
});
}
async function readMessages(threadId: string, signal?: AbortSignal) {
const path = `${opencodePath}/session/${encodeURIComponent(threadId)}/message`;
return sdkJson(
threadMessagesSchema,
await opencode.session.messages({ sessionID: threadId }, { signal: requestSignal(signal) }),
"GET",
path,
);
}
async function getThreadSnapshot(threadId: string, input?: { signal?: AbortSignal; limit?: number }): Promise<HeadlessThreadSnapshot> {
const encodedThreadId = encodeURIComponent(threadId);
const sessionPath = `${opencodePath}/session/${encodedThreadId}`;
const messagesPath = `${sessionPath}/message`;
const todosPath = `${sessionPath}/todo`;
const statusPath = `${opencodePath}/session/status`;
const [sessionResult, messagesResult, todosResult, statusResult] = await Promise.all([
opencode.session.get({ sessionID: threadId }, { signal: requestSignal(input?.signal) }),
opencode.session.messages({ sessionID: threadId, limit: input?.limit }, { signal: requestSignal(input?.signal) }),
opencode.session.todo({ sessionID: threadId }, { signal: requestSignal(input?.signal) }),
opencode.session.status(undefined, { signal: requestSignal(input?.signal) }),
]);
const session = sdkJson(sessionSchema, sessionResult, "GET", sessionPath);
const messages = sdkJson(threadMessagesSchema, messagesResult, "GET", messagesPath);
const todos = sdkJson(threadTodosSchema, todosResult, "GET", todosPath);
const statuses = sdkJson(threadStatusesSchema, statusResult, "GET", statusPath);
return toSnapshot(threadSnapshotSchema.parse({
session,
messages,
todos,
status: statuses[threadId] ?? { type: "idle" },
}));
}
async function createThread(input: CreateThreadInput): Promise<HeadlessThread> {
const model = input.model ?? options.defaultModel;
const createPath = `${opencodePath}/session`;
const prompt = initialPrompt(input.prompt);
const session = sdkJson(
sessionSchema,
await opencode.session.create({ title: createTitle(input.title) }, { signal: requestSignal(input.signal) }),
"POST",
createPath,
);
if (prompt !== undefined) {
const promptPath = `${opencodePath}/session/${encodeURIComponent(session.id)}/prompt_async`;
const result = await opencode.session.promptAsync({
sessionID: session.id,
parts: [{ type: "text", text: prompt }],
...(model === undefined ? {} : { model: { providerID: model.providerId, modelID: model.modelId } }),
...(model?.variant === undefined ? {} : { variant: model.variant }),
}, { signal: requestSignal(input.signal) });
sdkSuccess(result, "POST", promptPath);
}
return toThread(session, workspaceId, prompt !== undefined);
}
async function sendTurn(threadId: string, input: HeadlessThreadTurnInput): Promise<HeadlessTurnAcceptance> {
const model = input.model ?? options.defaultModel;
const messages = await readMessages(threadId, input.signal);
const messageCountBefore = messages.length;
if (input.messageId && messages.some((message) => message.info.id === input.messageId && message.info.role === "user")) {
return { threadId, acceptedAt: now(), messageCountBefore, messageId: input.messageId, alreadyPresent: true };
}
const path = `${opencodePath}/session/${encodeURIComponent(threadId)}/prompt_async`;
const result = await opencode.session.promptAsync({
sessionID: threadId,
parts: [{ type: "text", text: input.prompt }],
...(input.messageId === undefined ? {} : { messageID: input.messageId }),
...(model === undefined ? {} : { model: { providerID: model.providerId, modelID: model.modelId } }),
...(model?.variant === undefined ? {} : { variant: model.variant }),
}, { signal: requestSignal(input.signal) });
sdkSuccess(result, "POST", path);
return { threadId, acceptedAt: now(), messageCountBefore, messageId: input.messageId ?? null, alreadyPresent: false };
}
async function waitForThread(threadId: string, input: HeadlessThreadWaitInput): Promise<HeadlessThreadWaitResult> {
const startedAt = now();
const deadline = startedAt + input.timeoutMs;
const interval = input.pollIntervalMs ?? pollIntervalMs;
const messageCountBefore = input.since?.messageCountBefore ?? 0;
const messageId = input.since?.messageId ?? null;
let polls = 0;
let observedRunning = false;
for (;;) {
if (input.signal?.aborted) {
const snapshot = await getThreadSnapshot(threadId);
return { outcome: "aborted", snapshot, waitedMs: now() - startedAt, polls, observedRunning, terminalError: null };
}
const snapshot = await getThreadSnapshot(threadId, { signal: input.signal });
polls += 1;
const finish = (outcome: HeadlessThreadWaitResult["outcome"]): HeadlessThreadWaitResult => ({
outcome,
snapshot,
waitedMs: now() - startedAt,
polls,
observedRunning,
terminalError: outcome === "failed"
? assistantReplyForTurn(snapshot.messages, { messageId, messageCountBefore })?.error ?? null
: null,
});
if (isRunning(snapshot.status)) {
observedRunning = true;
} else {
const reply = assistantReplyForTurn(snapshot.messages, { messageId, messageCountBefore });
if (reply?.error) return finish("failed");
if (reply) return finish("settled");
}
if (input.signal?.aborted) return finish("aborted");
const remaining = deadline - now();
if (remaining <= 0) return finish("timeout");
await sleep(Math.min(interval, remaining));
}
}
async function waitUntilIdle(threadId: string, input: HeadlessThreadWaitInput): Promise<HeadlessThreadWaitResult> {
const startedAt = now();
const deadline = startedAt + input.timeoutMs;
const interval = input.pollIntervalMs ?? pollIntervalMs;
let polls = 0;
let observedRunning = false;
for (;;) {
if (input.signal?.aborted) {
const snapshot = await getThreadSnapshot(threadId);
return { outcome: "aborted", snapshot, waitedMs: now() - startedAt, polls, observedRunning, terminalError: null };
}
const snapshot = await getThreadSnapshot(threadId, { signal: input.signal });
polls += 1;
if (!isRunning(snapshot.status)) {
return { outcome: "settled", snapshot, waitedMs: now() - startedAt, polls, observedRunning, terminalError: null };
}
observedRunning = true;
const remaining = deadline - now();
if (remaining <= 0) {
return { outcome: "timeout", snapshot, waitedMs: now() - startedAt, polls, observedRunning, terminalError: null };
}
await sleep(Math.min(interval, remaining));
}
}
async function abortThread(threadId: string, input?: { signal?: AbortSignal }): Promise<HeadlessAbortResult> {
const path = `${opencodePath}/session/${encodeURIComponent(threadId)}/abort`;
const accepted = sdkJson(
abortResultSchema,
await opencode.session.abort({ sessionID: threadId }, { signal: requestSignal(input?.signal) }),
"POST",
path,
);
return { threadId, accepted };
}
async function exportTranscript(threadId: string, input?: { signal?: AbortSignal }): Promise<HeadlessThreadTranscript> {
return toTranscript(await getThreadSnapshot(threadId, input));
}
return { createThread, sendTurn, waitForThread, waitUntilIdle, getThreadSnapshot, abortThread, exportTranscript };
}
+28
View File
@@ -0,0 +1,28 @@
/**
* A single error type for every headless thread failure, so a caller running
* many threads can classify failures without string matching.
*/
export class HeadlessThreadError extends Error {
readonly code: string;
readonly method: string;
readonly path: string;
readonly status: number | null;
readonly body: unknown;
constructor(input: {
code: string;
message: string;
method: string;
path: string;
status?: number;
body?: unknown;
}) {
super(input.message);
this.name = "HeadlessThreadError";
this.code = input.code;
this.method = input.method;
this.path = input.path;
this.status = input.status ?? null;
this.body = input.body;
}
}
+29
View File
@@ -0,0 +1,29 @@
export { createHeadlessThreadClient } from "./client.js";
export { HeadlessThreadError } from "./errors.js";
export { hasAssistantReplySince, toTranscript, toTranscriptMessage } from "./transcript.js";
export { isRunning } from "./wire.js";
export type {
AgentSessionClient,
CreateThreadInput,
HeadlessAbortResult,
HeadlessFetch,
HeadlessThread,
HeadlessThreadClient,
HeadlessThreadClientOptions,
HeadlessThreadMessage,
HeadlessThreadMessageError,
HeadlessThreadMessagePart,
HeadlessThreadModel,
HeadlessThreadSnapshot,
HeadlessThreadStatus,
HeadlessThreadTodo,
HeadlessThreadUsage,
HeadlessThreadTranscript,
HeadlessThreadTurnInput,
HeadlessThreadWaitInput,
HeadlessThreadWaitOutcome,
HeadlessThreadWaitResult,
HeadlessTranscriptMessage,
HeadlessTranscriptToolCall,
HeadlessTurnAcceptance,
} from "./types.js";
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test";
import { hasAssistantReplySince, toTranscript } from "./transcript.js";
import type { HeadlessThreadMessage, HeadlessThreadSnapshot } from "./types.js";
function message(input: Partial<HeadlessThreadMessage> & { id: string; role: string }): HeadlessThreadMessage {
return { parentId: null, createdAt: null, error: null, usage: null, parts: [], ...input };
}
function snapshot(messages: HeadlessThreadMessage[]): HeadlessThreadSnapshot {
return {
threadId: "ses_1",
title: "Refund policy",
directory: "/workspace",
status: { type: "idle" },
messages,
todos: [],
};
}
describe("toTranscript", () => {
test("joins text parts, keeps reasoning separate, and lists tool calls", () => {
const transcript = toTranscript(
snapshot([
message({ id: "msg_1", role: "user", parts: [{ id: "prt_1", type: "text", text: " Why? " }] }),
message({
id: "msg_2",
role: "assistant",
createdAt: 200,
parts: [
{ id: "prt_2", type: "reasoning", text: "check the policy" },
{ id: "prt_3", type: "tool", tool: "read", callId: "call_1", toolStatus: "completed" },
{ id: "prt_4", type: "text", text: "Because " },
{ id: "prt_5", type: "text", text: "the window closed." },
],
}),
]),
);
expect(transcript.messages).toEqual([
{ id: "msg_1", role: "user", createdAt: null, text: "Why?", reasoning: "", toolCalls: [] },
{
id: "msg_2",
role: "assistant",
createdAt: 200,
text: "Because the window closed.",
reasoning: "check the policy",
toolCalls: [{ partId: "prt_3", name: "read", callId: "call_1", status: "completed" }],
},
]);
expect(transcript.finalAssistantText).toBe("Because the window closed.");
});
test("drops synthetic and ignored parts the app also hides", () => {
const transcript = toTranscript(
snapshot([
message({
id: "msg_1",
role: "assistant",
parts: [
{ id: "prt_1", type: "text", text: "<system reminder>", synthetic: true },
{ id: "prt_2", type: "text", text: "stale", ignored: true },
{ id: "prt_3", type: "text", text: "kept" },
],
}),
]),
);
expect(transcript.messages[0]?.text).toBe("kept");
});
test("reports an empty final answer rather than falling back to an earlier turn", () => {
const transcript = toTranscript(
snapshot([
message({ id: "msg_1", role: "assistant", parts: [{ id: "prt_1", type: "text", text: "first" }] }),
message({ id: "msg_2", role: "user", parts: [{ id: "prt_2", type: "text", text: "and now?" }] }),
]),
);
expect(transcript.finalAssistantText).toBe("first");
});
test("has no final answer when the thread never answered", () => {
const transcript = toTranscript(
snapshot([message({ id: "msg_1", role: "user", parts: [{ id: "prt_1", type: "text", text: "hi" }] })]),
);
expect(transcript.finalAssistantText).toBe("");
});
});
describe("hasAssistantReplySince", () => {
const thread = [
message({ id: "msg_1", role: "user" }),
message({ id: "msg_2", role: "assistant" }),
message({ id: "msg_3", role: "user" }),
];
test("ignores assistant replies that predate the turn", () => {
expect(hasAssistantReplySince(thread, 3)).toBe(false);
});
test("sees an assistant reply appended after the turn", () => {
expect(hasAssistantReplySince([...thread, message({ id: "msg_4", role: "assistant" })], 3)).toBe(true);
});
test("treats a fresh thread as having no reply until one arrives", () => {
expect(hasAssistantReplySince([], 0)).toBe(false);
expect(hasAssistantReplySince([message({ id: "msg_1", role: "assistant" })], 0)).toBe(true);
});
});
@@ -0,0 +1,97 @@
/**
* Transcript projection.
*
* A snapshot carries the thread exactly as OpenWork stores it. A transcript is
* the flattened, comparable view of the same data: what each side said, what
* it reasoned about, and which tools it called. Deriving it here keeps every
* consumer from re-implementing part handling the way the desktop UI does.
*/
import type {
HeadlessThreadMessage,
HeadlessThreadSnapshot,
HeadlessThreadTranscript,
HeadlessTranscriptMessage,
HeadlessTranscriptToolCall,
} from "./types.js";
function joinPartText(message: HeadlessThreadMessage, type: string): string {
return message.parts
.filter((part) => part.type === type && typeof part.text === "string")
// Synthetic and ignored parts are engine bookkeeping; the UI hides them
// and a transcript comparison must not see them either.
.filter((part) => part.synthetic !== true && part.ignored !== true)
.map((part) => part.text)
.join("")
.trim();
}
function toToolCalls(message: HeadlessThreadMessage): HeadlessTranscriptToolCall[] {
return message.parts
.filter((part) => part.type === "tool")
.map((part) => ({
partId: part.id,
name: part.tool ?? "",
callId: part.callId ?? null,
status: part.toolStatus ?? null,
}));
}
export function toTranscriptMessage(message: HeadlessThreadMessage): HeadlessTranscriptMessage {
return {
id: message.id,
role: message.role,
createdAt: message.createdAt,
text: joinPartText(message, "text"),
reasoning: joinPartText(message, "reasoning"),
toolCalls: toToolCalls(message),
};
}
function emptyUsage() {
return { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0 };
}
export function toTranscript(snapshot: HeadlessThreadSnapshot): HeadlessThreadTranscript {
const messages = snapshot.messages.map(toTranscriptMessage);
const assistantMessages = snapshot.messages.filter((message) => message.role === "assistant");
const lastAssistant = messages.filter((message) => message.role === "assistant" && message.text).at(-1);
const usage = assistantMessages.reduce((total, message) => {
if (!message.usage) return total;
total.inputTokens += message.usage.inputTokens;
total.outputTokens += message.usage.outputTokens;
total.reasoningTokens += message.usage.reasoningTokens;
total.cacheReadTokens += message.usage.cacheReadTokens;
total.cacheWriteTokens += message.usage.cacheWriteTokens;
total.cost += message.usage.cost;
return total;
}, emptyUsage());
return {
threadId: snapshot.threadId,
title: snapshot.title,
status: snapshot.status,
messages,
finalAssistantText: lastAssistant ? lastAssistant.text : "",
usage,
terminalError: assistantMessages.at(-1)?.error ?? null,
};
}
export function assistantReplyForTurn(messages: HeadlessThreadMessage[], input: {
messageId: string | null;
messageCountBefore: number;
}): HeadlessThreadMessage | null {
if (input.messageId) {
return messages.find((message) => message.role === "assistant" && message.parentId === input.messageId) ?? null;
}
return messages.slice(input.messageCountBefore).find((message) => message.role === "assistant") ?? null;
}
/**
* True once the thread holds an assistant message that did not exist when a
* turn was submitted. Paired with a non-running status this is what makes a
* turn "finished" — a status read alone is ambiguous, because a thread is
* still idle in the moment between accepting a prompt and starting work.
*/
export function hasAssistantReplySince(messages: HeadlessThreadMessage[], messageCountBefore: number): boolean {
return messages.slice(messageCountBefore).some((message) => message.role === "assistant");
}
+218
View File
@@ -0,0 +1,218 @@
/**
* The Headless Threads contract.
*
* A "thread" is a native OpenWork session: the same workspace, the same
* managed OpenCode engine, the same session id, the same persisted messages
* and tool activity the desktop UI shows. Nothing here introduces a second
* chat engine, a second session store, or a second model gateway — the types
* below only describe the session surface OpenWork already serves.
*/
/** Model selection, in OpenWork's server casing (`providerId`/`modelId`). */
export interface HeadlessThreadModel {
providerId: string;
modelId: string;
/** Optional engine variant, e.g. a reasoning configuration. */
variant?: string;
}
export type HeadlessThreadStatus =
| { type: "idle" }
| { type: "busy" }
| { type: "retry"; attempt: number; message: string; next: number };
export interface HeadlessThreadTodo {
content: string;
status: string;
priority: string;
}
export interface HeadlessThreadMessagePart {
id: string;
type?: string;
text?: string;
tool?: string;
callId?: string;
toolStatus?: string;
synthetic?: boolean;
ignored?: boolean;
}
export interface HeadlessThreadMessage {
id: string;
role: string;
/** The user message this assistant response belongs to. */
parentId: string | null;
createdAt: number | null;
error: HeadlessThreadMessageError | null;
usage: HeadlessThreadUsage | null;
parts: HeadlessThreadMessagePart[];
}
export interface HeadlessThreadMessageError {
name: string;
message: string;
retryable: boolean | null;
}
export interface HeadlessThreadUsage {
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
cost: number;
}
export interface CreateThreadInput {
title: string;
/** Optional first turn. When present the thread starts running immediately. */
prompt?: string;
model?: HeadlessThreadModel;
signal?: AbortSignal;
}
export interface HeadlessThread {
/**
* The native OpenCode session id. It is the same id the OpenWork UI uses,
* so a headless thread can be opened in the app afterwards.
*/
id: string;
workspaceId: string;
title: string | null;
directory: string | null;
createdAt: number | null;
/** True when `createThread` was given a prompt and the engine accepted it. */
started: boolean;
}
export interface HeadlessThreadTurnInput {
prompt: string;
model?: HeadlessThreadModel;
/** Stable engine message id used to make prompt admission idempotent. */
messageId?: string;
signal?: AbortSignal;
}
/**
* Proof that the engine accepted a turn, plus the message count observed just
* before submitting it. `waitForThread` uses that count to tell a fresh
* assistant reply apart from the replies already in the thread.
*/
export interface HeadlessTurnAcceptance {
threadId: string;
acceptedAt: number;
messageCountBefore: number;
messageId: string | null;
/** True when the engine already held this exact user message. */
alreadyPresent: boolean;
}
export interface HeadlessThreadWaitInput {
timeoutMs: number;
pollIntervalMs?: number;
/** The turn being waited on. Omit to wait on a thread's first reply. */
since?: { messageCountBefore: number; messageId?: string | null };
signal?: AbortSignal;
}
export type HeadlessThreadWaitOutcome = "settled" | "failed" | "timeout" | "aborted";
export interface HeadlessThreadWaitResult {
outcome: HeadlessThreadWaitOutcome;
snapshot: HeadlessThreadSnapshot;
waitedMs: number;
polls: number;
/** True when a busy or retry status was seen while waiting. */
observedRunning: boolean;
terminalError: HeadlessThreadMessageError | null;
}
export interface HeadlessThreadSnapshot {
threadId: string;
title: string | null;
directory: string | null;
status: HeadlessThreadStatus;
messages: HeadlessThreadMessage[];
todos: HeadlessThreadTodo[];
}
export interface HeadlessAbortResult {
threadId: string;
/**
* The server accepted the abort request. Acceptance is not proof the run
* stopped — call `waitForThread` afterwards to observe the thread go idle.
*/
accepted: boolean;
}
export interface HeadlessTranscriptToolCall {
partId: string;
name: string;
callId: string | null;
status: string | null;
}
export interface HeadlessTranscriptMessage {
id: string;
role: string;
createdAt: number | null;
text: string;
reasoning: string;
toolCalls: HeadlessTranscriptToolCall[];
}
export interface HeadlessThreadTranscript {
threadId: string;
title: string | null;
status: HeadlessThreadStatus;
messages: HeadlessTranscriptMessage[];
/** Text of the last assistant message, or an empty string when there is none. */
finalAssistantText: string;
usage: HeadlessThreadUsage;
terminalError: HeadlessThreadMessageError | null;
}
export interface AgentSessionClient {
createThread(input: CreateThreadInput): Promise<HeadlessThread>;
sendTurn(threadId: string, input: HeadlessThreadTurnInput): Promise<HeadlessTurnAcceptance>;
getThreadSnapshot(threadId: string, input?: { signal?: AbortSignal; limit?: number }): Promise<HeadlessThreadSnapshot>;
abortThread(threadId: string, input?: { signal?: AbortSignal }): Promise<HeadlessAbortResult>;
}
export interface HeadlessThreadClient extends AgentSessionClient {
waitForThread(threadId: string, input: HeadlessThreadWaitInput): Promise<HeadlessThreadWaitResult>;
waitUntilIdle(threadId: string, input: HeadlessThreadWaitInput): Promise<HeadlessThreadWaitResult>;
exportTranscript(threadId: string, input?: { signal?: AbortSignal }): Promise<HeadlessThreadTranscript>;
}
/**
* The narrow slice of `fetch` this client uses. `globalThis.fetch` satisfies
* it, and so does a test double, without dragging in a runtime's own `fetch`
* type (Bun's carries `preconnect`, Node's does not).
*/
export type HeadlessFetch = (
input: string,
init?: { method?: string; headers?: Record<string, string>; body?: string; redirect?: RequestRedirect; signal?: AbortSignal },
) => Promise<Response>;
export interface HeadlessThreadClientOptions {
/** OpenWork server base URL, e.g. `http://127.0.0.1:8787`. */
baseUrl: string;
workspaceId: string;
/** A collaborator-scoped OpenWork client token. */
token: string;
/** Host credential for server-to-server execution against the resolved Cloud runtime. */
hostToken?: string;
/** Model used when a call does not name one. */
defaultModel?: HeadlessThreadModel;
/** Default `waitForThread` poll interval. Defaults to 500ms. */
pollIntervalMs?: number;
/** Bounds every individual HTTP request. Defaults to 15 seconds; use 0 to disable. */
requestTimeoutMs?: number;
/** Cancels every operation issued by this client. */
signal?: AbortSignal;
fetch?: HeadlessFetch;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
}
+197
View File
@@ -0,0 +1,197 @@
/**
* The wire boundary: schemas for native OpenCode session payloads, plus the
* mapping into the headless thread types.
*
* Every schema is permissive about fields it does not name, so an engine or
* server that grows a field does not break a benchmark run mid-campaign.
*/
import { z } from "zod";
import type {
HeadlessThread,
HeadlessThreadMessage,
HeadlessThreadMessagePart,
HeadlessThreadSnapshot,
HeadlessThreadStatus,
HeadlessThreadTodo,
} from "./types.js";
const timeSchema = z
.object({
created: z.number().optional(),
updated: z.number().optional(),
})
.passthrough();
export const threadStatusSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("idle") }),
z.object({ type: z.literal("busy") }),
z.object({ type: z.literal("retry"), attempt: z.number(), message: z.string(), next: z.number() }),
]);
export const sessionSchema = z
.object({
id: z.string(),
title: z.string().nullish(),
directory: z.string().nullish(),
time: timeSchema.optional(),
})
.passthrough();
const partSchema = z
.object({
id: z.string(),
type: z.string().optional(),
text: z.string().optional(),
tool: z.string().optional(),
callID: z.string().optional(),
state: z.object({ status: z.string().optional() }).passthrough().optional(),
synthetic: z.boolean().optional(),
ignored: z.boolean().optional(),
})
.passthrough();
const messageSchema = z
.object({
info: z
.object({
id: z.string(),
role: z.string(),
parentID: z.string().optional(),
time: timeSchema.optional(),
error: z.unknown().optional(),
cost: z.number().nonnegative().optional(),
tokens: z.object({
input: z.number().nonnegative().optional(),
output: z.number().nonnegative().optional(),
reasoning: z.number().nonnegative().optional(),
cache: z.object({
read: z.number().nonnegative().optional(),
write: z.number().nonnegative().optional(),
}).optional(),
}).optional(),
})
.passthrough(),
parts: z.array(partSchema),
})
.passthrough();
const todoSchema = z
.object({
content: z.string(),
status: z.string(),
priority: z.string(),
})
.passthrough();
export const threadMessagesSchema = z.array(messageSchema);
export const threadTodosSchema = z.array(todoSchema);
export const threadStatusesSchema = z.record(z.string(), threadStatusSchema);
export const abortResultSchema = z.boolean();
export const threadSnapshotSchema = z.object({
session: sessionSchema,
messages: threadMessagesSchema,
todos: threadTodosSchema,
status: threadStatusSchema,
});
type SessionWire = z.infer<typeof sessionSchema>;
type MessageWire = z.infer<typeof messageSchema>;
type PartWire = z.infer<typeof partSchema>;
type TodoWire = z.infer<typeof todoSchema>;
function optionalString(value: string | null | undefined): string | null {
return typeof value === "string" ? value : null;
}
function toPart(part: PartWire): HeadlessThreadMessagePart {
const mapped: HeadlessThreadMessagePart = { id: part.id };
if (part.type !== undefined) mapped.type = part.type;
if (part.text !== undefined) mapped.text = part.text;
if (part.tool !== undefined) mapped.tool = part.tool;
if (part.callID !== undefined) mapped.callId = part.callID;
if (part.state?.status !== undefined) mapped.toolStatus = part.state.status;
if (part.synthetic !== undefined) mapped.synthetic = part.synthetic;
if (part.ignored !== undefined) mapped.ignored = part.ignored;
return mapped;
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null ? value as Record<string, unknown> : null;
}
function stringField(records: Array<Record<string, unknown> | null>, keys: string[]): string | null {
for (const item of records) {
if (!item) continue;
for (const key of keys) {
const value = item[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
}
return null;
}
function toMessageError(value: unknown): HeadlessThreadMessage["error"] {
const outer = record(value);
if (!outer) return null;
const data = record(outer.data);
const cause = record(data?.cause);
return {
name: stringField([outer, data, cause], ["name", "code", "type"]) ?? "ExecutionError",
message: stringField([outer, data, cause], ["message", "error", "detail"]) ?? "The agent turn failed.",
retryable: typeof outer.retryable === "boolean"
? outer.retryable
: typeof data?.retryable === "boolean" ? data.retryable : null,
};
}
export function toThreadMessage(message: MessageWire): HeadlessThreadMessage {
return {
id: message.info.id,
role: message.info.role,
parentId: message.info.parentID ?? null,
createdAt: message.info.time?.created ?? null,
error: toMessageError(message.info.error),
usage: message.info.role === "assistant" && message.info.tokens ? {
inputTokens: message.info.tokens?.input ?? 0,
outputTokens: message.info.tokens?.output ?? 0,
reasoningTokens: message.info.tokens?.reasoning ?? 0,
cacheReadTokens: message.info.tokens?.cache?.read ?? 0,
cacheWriteTokens: message.info.tokens?.cache?.write ?? 0,
cost: message.info.cost ?? 0,
} : null,
parts: message.parts.map(toPart),
};
}
function toTodo(todo: TodoWire): HeadlessThreadTodo {
return { content: todo.content, status: todo.status, priority: todo.priority };
}
export function toThread(session: SessionWire, workspaceId: string, started: boolean): HeadlessThread {
return {
id: session.id,
workspaceId,
title: optionalString(session.title),
directory: optionalString(session.directory),
createdAt: session.time?.created ?? null,
started,
};
}
export function toSnapshot(item: z.infer<typeof threadSnapshotSchema>): HeadlessThreadSnapshot {
return {
threadId: item.session.id,
title: optionalString(item.session.title),
directory: optionalString(item.session.directory),
status: item.status,
messages: item.messages.map(toThreadMessage),
todos: item.todos.map(toTodo),
};
}
/** Busy and retry both mean the engine still owns the turn. */
export function isRunning(status: HeadlessThreadStatus): boolean {
return status.type === "busy" || status.type === "retry";
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["bun-types"]
},
"include": ["src/**/*"]
}