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