源码快照 @ 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
|
||||
```
|
||||
Reference in New Issue
Block a user