Skip to content

Harness opencode - #2483

Closed
aron-cf wants to merge 3 commits into
mainfrom
harness-opencode
Closed

aron-cf wants to merge 3 commits into
mainfrom
harness-opencode

Conversation

@aron-cf

@aron-cf aron-cf commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

This uses OpenCode's embedded SDK (@opencode/sdk/workerd) in a Durable Object as one Lifecycle capability. It has the same interface as PiHarness.

The createAI({ binding }) from agents/models/opencode keeps OpenCode's Workers AI provider and catalog, but uses the AI binding. Pass ai.provider to OpenCodeHarness({ providers }).

```ts
import { DurableObject } from "cloudflare:workers";
import { OpenCodeHarness } from "agents/harness/opencode";
import { Lifecycle } from "agents/lifecycle";
import { createAI } from "agents/models/opencode";
import { Streams } from "agents/streams";

export class MyAgent extends DurableObject<Env> {
  readonly ai = createAI({ binding: this.env.AI });
  readonly streams = new Streams();

  readonly harness = new OpenCodeHarness({
    streams: this.streams,
    providers: [this.ai.provider],
    defaults: { model: this.ai("@cf/moonshotai/kimi-k2.7-code") }
  });

  readonly lifecycle = Lifecycle.install(this)
    .use(this.harness)
    .use(this.streams);

  async prompt(text: string) {
    return this.harness.prompt(text);
  }
}

The example has the same layout as the Pi harness example: the React client, the socket protocol on session.events(), the view and transcript models, Workspace tools from @cloudflare/computer, and the tests. It is built on agents/harness/opencode and agents/models/opencode.

It also supports the OpenCode CLI. The object's onRequest serves OpenCode's HTTP API under /agents/open-code-agent/ (src/cli.ts).

npx @opencode/cli@2 --server <origin>/agents/open-code-agent/<name> --session <ses_id>

Devin Review

This uses OpenCode's embedded SDK (@opencode/sdk/workerd) in
a Durable Object as one Lifecycle capability. It has the same interface
as PiHarness.

The createAI({ binding }) from agents/models/opencode keeps OpenCode's
Workers AI provider and catalog, but uses the AI binding. Pass
ai.provider to OpenCodeHarness({ providers }).
The example has the same layout as the Pi harness example: the React
client, the socket protocol on session.events(), the view and transcript
models, Workspace tools from @cloudflare/computer, and the tests. It is
built on agents/harness/opencode and agents/models/opencode.

It also supports the OpenCode CLI. The object's onRequest serves
OpenCode's HTTP API under /agents/open-code-agent/<name> (src/cli.ts).

  npx @opencode/cli@2 --server <origin>/agents/open-code-agent/<name>
--session <ses_id>
@changeset-bot

changeset-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c8698ce

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 12 potential issues.

Devin Review

Comment on lines +159 to +170
// A step that called tools completes before the next step starts.
if (
nextUser < 0 &&
reply.content?.some(
(part) =>
part.type === "tool" &&
(part.state as { status?: unknown } | undefined)?.status !==
"completed" &&
(part.state as { status?: unknown } | undefined)?.status !== "error"
)
) {
return "active";

@devin-ai-integration devin-ai-integration Bot Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Tool turns return before final answers

After a tool finishes, inspectOperation accepts the completed tool-call step as the final reply. wait() can return empty text before the model produces its answer.

Learn more

An OpenCode turn can write several assistant messages: one completed step calls a tool, then a later step writes the final answer. inspectOperation checks only whether the last tool part is unfinished. Once the tool succeeds, that check fails, even though the turn continues. settled calls inspectOperation independently of the execution terminal event and returns immediately upon any result. This can also cause reconcile to close the operation stream early.

Example: A prompt asks multiply 7; the tool-call assistant message completes, its tool state becomes completed, and the next assistant message has not been written. wait() reports done with empty text rather than waiting for 21.

Recommended fix: Only classify a successful response as terminal using OpenCode's execution terminal marker or an explicit terminal assistant-step indicator. Do not infer completion solely from a completed assistant message or completed tool part; test the interval between tool completion and the next model step.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +932 to +949
const existing = this.#writers.get(operationId);
if (existing) {
this.#current.set(session, operationId);
return existing;
}
const streamId = this.#streamId(operationId, session);
const writer = await this.#streams.open(streamId, {
tag: session,
metadata: { session, operationId }
});
const operationWriter = new OperationStreamWriter({
streamId,
operationId,
writer
});
this.#writers.set(operationId, operationWriter);
this.#current.set(session, operationId);
return operationWriter;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Shared operation IDs mix session streams

When two sessions submit the same operationId, #openOperation reuses the first session's writer. Events enter the wrong stream, leaving the second operation without its own record.

Learn more

Operation ids are scoped to a session: streamId includes both identifiers, and enqueue checks for duplicates only within its session. The in-memory #writers map uses the operation id alone. On a collision, #openOperation returns the other session's writer without opening a stream for this session. Later projection and settlement can consume or close the wrong writer, while wait() checks the absent stream for the second session.

Example: Alice's session submits operationId: "op-1"; Bob's session also submits "op-1". Bob's operation is accepted, but its writer is Alice's and its stream never opens.

Recommended fix: Key #writers and settlement waiters by the session-and-operation composite, consistently across opening, projection, reconciliation, settlement, and forgetting.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +638 to +653
async #root(): Promise<string> {
if (this.#rootSession) return this.#rootSession;
await this.lifecycle.ready();
const stored = await this.lifecycle.storage.get<string>(ROOT_SESSION_KEY);
if (stored) {
this.#rootSession = stored;
await this.#readLog(stored);
return stored;
}
const host = await this.#host();
const created = await host.sessions.create({});
await this.#configure(created.id);
await this.lifecycle.storage.put(ROOT_SESSION_KEY, created.id);
this.#rootSession = created.id;
await this.#readLog(created.id);
return created.id;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Concurrent first requests split the root session

When first requests overlap, #root can create two sessions before either sets #rootSession. Different callers receive different roots, and the later write hides one session.

Learn more

Durable Objects can interleave requests at await points. Both callers see an unset #rootSession and absent stored id before awaiting sessions.create. Each writes its own id to ROOT_SESSION_KEY and returns it. Only the last value remains the root alias; the first caller can be submitting into a different session.

Example: Two browser sockets connect to a fresh object simultaneously. One resolves root to ses_A and the other to ses_B, although both asked for root; later connections see whichever id was written last.

Recommended fix: Memoize one in-flight root initialization promise before the first await, or atomically create and publish the root id under a storage transaction. Ensure failure clears the promise for retry.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +777 to +781
async #openOperations(session: string): Promise<string[]> {
const rows = await this.#streams.list({
tag: session,
state: "streaming"
});

@devin-ai-integration devin-ai-integration Bot Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Older queued operations remain open after settlement

With over 50 open prompts in one session, #openOperations only sees the newest streams. Streams.list defaults to 50 rows, so older settled prompts remain open and retain the lease.

Learn more

The list method returns at most DEFAULT_LIST_LIMIT streams and sorts newest first. Both reconcile and release use #openOperations as if it contains every open prompt. Once a session accumulates more than 50 queued follow-ups, older streams can fall outside this page and never close; the lease keeps seeing open work.

Example: Submit 51 follow-ups before their responses settle. The oldest stream falls off the first page; after its answer appears, reconciliation never examines that stream.

Recommended fix: Page through all matching open streams, or query only this harness's streams using an explicit unbounded limit where bounded session volume is guaranteed. Cover more than 50 simultaneous pending operations in a test.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +947 to +949
this.#writers.set(operationId, operationWriter);
this.#current.set(session, operationId);
return operationWriter;

@devin-ai-integration devin-ai-integration Bot Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Queued follow-up steals the running turn

Submitting a follow-up while busy makes #openOperation replace #current before OpenCode delivers that prompt. Running-turn events enter the follow-up's stream, and its terminal event can settle the wrong operation.

Learn more

The current-operation map identifies the owner of live events and terminal execution events. enqueue opens the new operation before calling sessions.prompt; a busy followUp stays in the inbox until the current run ends. This assignment immediately replaces the owner anyway. settleTerminal then considers the follow-up the operation that just ended and can close it before it runs.

Example: Operation A is running a slow tool; operation B is queued. B replaces #current while A still runs, so A's later deltas belong to B and A's terminal event attempts to settle B.

Recommended fix: Open a stream without changing current ownership on submission. Switch #current only at delivery/start of that specific prompt; maintain the current owner independently of queued writers, including after an isolate restart.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +38 to +39
`NOTES.md` has the design decisions and everything that was hard or is still
missing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Missing design notes for example readers

The README refers readers to NOTES.md for design decisions and limitations, but that document is absent from this example.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +77 to +89
options(): WebSocketsOptions {
return {
getConnectionTags: (_connection, ctx) => [
sessionTag(sessionFromRequest(ctx.request, ROOT_SESSION))
],
handlers: {
onConnect: (connection, ctx) => this.#onConnect(connection, ctx),
onMessage: (connection, message) =>
this.#onMessage(connection, message),
onClose: (connection) => this.#unwatch(connection),
onError: (connection) => this.#unwatch(connection)
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Custom socket protocol adds example complexity

The example uses manual socket messages for chat commands instead of the preferred callable useAgent pattern. Review whether this extra protocol helps teach the harness.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

"src/voice/tests/vitest.config.ts",
"src/voice/react-tests/vitest.config.ts",
"src/harness/pi/tests/vitest.config.ts",
"src/harness/opencode/tests/vitest.config.ts",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Embedded SDK tests join default suite

The default Vitest suite now boots OpenCode in real Durable Objects. Check its runtime and isolation against the existing CI test budget.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +69 to +72
/** OpenCode's own HTTP API, for the OpenCode CLI. See `cli.ts`. */
async onRequest(request: Request): Promise<Response> {
return this.harness.fetch(openCodeRequest(request));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Public OpenCode API permits unauthorized session access

Anyone with an object URL can call harness.fetch without authentication. OpenCode's API exposes sessions and tool execution, allowing outsiders to read transcripts and operate the workspace.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
try {
return (
(await routeAgentRequest(request, env, { cors: true })) ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Permissive CORS on session API

cors: true permits cross-origin callers to use the public agent route. They can access the same OpenCode API as the app, including session and tool endpoints.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@agent-think

agent-think Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

🟢 agents import sizes: 2 entry points changed, no growth

Entry point Exports Largest gzip change Size now
🆕 agents/harness/opencode 4 new — 2676.5 KiB
🆕 agents/models/opencode 2 new — 2.2 KiB
Changed exports (6)
Import Gzip change Size now
🆕 agents/harness/opencode#OpenCodeHarness — 2676.5 KiB
🆕 agents/harness/opencode#OpenCodeSession — 2669.2 KiB
🆕 agents/harness/opencode#OpenCodeSessions — 2669.1 KiB
🆕 agents/harness/opencode#ROOT_SESSION — 2669 KiB
🆕 agents/models/opencode#createAI — 2.2 KiB
🆕 agents/models/opencode#CLOUDFLARE_PROVIDER_ID — 1.1 KiB
How this works

Each runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement.

Compared 26798f1b → c8698ce9 · workflow run · reported by agent-think[bot]

@pkg-pr-new

pkg-pr-new Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2483

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2483

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2483

hono-agents

npm i https://pkg.pr.new/hono-agents@2483

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2483

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2483

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2483

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2483

commit: c8698ce

Remove comments that restate the code and tighten the layout left by comment removal. Use clearer names for the binding route and the in-flight operation, and explain the OpenCode-specific ordering and runtime constraints where names alone cannot carry the reason. Correct the example snippet so it uses this inside its class.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +71 to +84
export type OpenCodeHarnessOptions = {
readonly streams: Streams;
readonly providers?: readonly OpenCodeProvider[];
readonly plugins?: readonly Plugin[];
readonly config?: OpenCodeConfig;
readonly defaults?: OpenCodeSessionDefaults;
readonly workerd?: Omit<
OpenCodeWorkerd.CreateOptions,
"storage" | "config" | "plugins"
>;
readonly lease?: {
readonly ttlMs?: number;
readonly stallLimit?: number;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Published API loses usage guidance

The exported harness options lose their JSDoc, including what streams, defaults, and lease settings mean. Keep the non-obvious contracts visible to consumers of the public API.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +32 to +39
function clone(options: {
url: string;
dir?: string;
ref?: string;
depth?: number;
paths?: string[];
singleBranch?: boolean;
noTags?: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Model-facing git instructions lose constraints

EXEC_DESCRIPTION is shown to the model. Removing its annotations hides the HTTPS-only clone constraint, clone defaults, and timestamp units. Restore constraints that help the model choose valid tool arguments.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@mattzcarey

Copy link
Copy Markdown
Member

Thanks Aron. #2513 landed an OpenCodeHarness at the same paths, so this can't merge as is. I've moved the parts we want onto it in #2568, with credit:

  • the normalised event union, on session.events() with the { snapshot, start, stop } shape PiHarness uses
  • the Workspace tools from @cloudflare/computer in the example

harness.fetch and the CLI route are waiting on OpenCode exposing its in-process handler, so we don't have to patch OpenCode.make: #2567, anomalyco/opencode#54803.

Not taken: the global fetch patch in agents/models/opencode, the table-discovery workaround (main prefixes OpenCode's tables instead), and the leases with the agents/streams copy of events. OpenCode's own log (session.log()) and main's wake job cover those.

@mattzcarey mattzcarey closed this Oct 12, 2026
mattzcarey added a commit that referenced this pull request Oct 12, 2026
…the example (#2568)

* feat(agents): OpenCodeHarness session events, and Workspace tools in the example

session.events() now returns { snapshot, start, stop } with OpenCodeSessionEvents,
the same kinds of event PiHarness and ContainerHarness report, translated from
OpenCode's typed events. OpenCode's raw events stay available from session.log().

The example replaces its notes plugin with the Workspace tools from
@cloudflare/computer, offered to the model by name.

Takes the event union and the Workspace tools from #2483 (Aron).

* test(opencode): return the stream snapshot's transcript as texts over RPC

* fix(opencode): idempotent event replay over the snapshot, no delivery after stop

- An input delivered while events() reads the snapshot was both seeded from
  pending and replayed, so it ended twice. The projector now skips deliveries
  of inputs it already has or has ended.
- The answer is reset when inputs settle and when a new run starts, so a run
  with no assistant step no longer reports the previous run's text.
- start() after stop() delivers nothing, and stop() drops buffered events.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants