104 lines
4.1 KiB
Markdown
104 lines
4.1 KiB
Markdown
# @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
|
|
```
|