Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions internal-docs/message-steering-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ flush), fire-and-forget, exactly as the composer's Ctrl/Cmd+Enter does.
| Over the steer rate limit | `{queued:true, steered:false, steerDeclined:"rate_limited"}` | queued |
| Deduped retry | `{messageId}` only | nothing touched, nothing interrupted |

Since 2026-09-26 (`steer-delivery-design.md`), a steer at a busy Claude receiver aborts nothing: the next
main-thread tool batch delivers the queue inside the turn, or the turn-end flush does. The ack shape is unchanged;
for those receivers `steered:true` means "delivered at the next tool batch, or as the next turn", and the rate limit
does not count them. Codex and OpenCode receivers keep the abort row above, and every flush after a send-now abort
names the cause of the interruption.
Since 2026-09-26 (`steer-delivery-design.md`), a steer at a busy receiver aborts nothing on any engine: the next
main-thread tool boundary delivers the queue inside the turn, or the turn-end flush does (Claude since
2026-09-26, Codex since 2026-10-19, OpenCode since the 2.2.5 pin on 2026-11-11). The ack shape is unchanged;
for those receivers `steered:true` means "delivered at the next tool boundary, or as the next turn", and the rate
limit does not count them. Every flush after a send-now abort names the cause of the interruption.

`queued:false` on a steer is deliberate: `queued` answers "does this wait for their current turn", and a steer
exists precisely so it does not. `steered:true` is asserted at interrupt-issue time, not delivery time.
Expand Down
93 changes: 86 additions & 7 deletions internal-docs/steer-delivery-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
> returned the text" (no transcript read), and §5 is settled. Tasks 0a248523 (P0, steer read as a human rejection) and 7529b23b (P3, deliver
> at the next tool boundary). Builds on `message-steering-design.md` §2.1, which shipped the `steer` flag.
> Codex receivers joined in task 85706f13 (2026-10-19): §3.4 delivers their steers with `turn/steer`.
> OpenCode receivers joined on the `@opencode/cli` 2.2.5 pin (2026-11-11): §3.5 delivers their steers through
> the session's own inbox. Every engine now takes agent steers at a boundary.

## 1. The failure, reproduced

Expand Down Expand Up @@ -65,6 +67,17 @@ that races the end of the run starts a fresh run on its own (43 of 48 timed race
follow-on turn of the July attempt, and the source shows a narrow window where such a prompt gets no reply at
all. OpenCode therefore keeps the abort.

Re-probed on the current pin (2026-11-11, `@opencode/cli` 2.2.5 source at tag v2.2.5 plus the pinned binary with
the gate mock; 48 timed race trials per mode): the v2 inbox changes the picture. `POST /api/session/:id/prompt`
durably admits its input to the session's SQLite inbox and answers at once; a busy run promotes rows admitted
with `delivery:"steer"` (the default) at its next step boundary, inside the same run - the tool finishes, no
interrupt. `resume:false` admits WITHOUT scheduling a run (a run starts only through the coordinator's wake), so
a steer that misses the run's last boundary check stays a pending row instead of starting anything: 0 of 48 race
trials produced an unowned run or a lost reply (31 folded into the run, 17 stayed pending and cancelled). The
default path (an ordinary prompt) still starts a successor run when the wake lands in the settle window - 13 of
48 trials - but the no-reply window of 1.18.35 is gone: the row is durable and every drain re-checks it. §3.5
ships the `resume:false` form.


Probe scripts: `internal-docs/evidence/steer-delivery-0926/` (`probe.ts` for the table above, `probe2.ts` for §3.1).
The Codex and OpenCode probes of 2026-10-19 were scratch runs and are not kept.
Expand Down Expand Up @@ -170,8 +183,9 @@ A real human permission denial followed by an ordinary agent message carries no
same turn stays a denial even when a steer follows it.

The sender half of that line has since moved to the messaging page of the agent reference
(`server/agent-reference/messaging.md`), which since task 85706f13 reads "a Claude or Codex agent receives it without being
interrupted; an OpenCode agent is interrupted."
(`server/agent-reference/messaging.md`), which since task 85706f13 read "a Claude or Codex agent receives it without being
interrupted; an OpenCode agent is interrupted", and since §3.5 (2026-11-11) names no engine: a busy recipient
receives a steer at a tool boundary without being interrupted, whichever engine runs it.

### 3.4 Codex: agent steers go in with `turn/steer` (85706f13)

Expand Down Expand Up @@ -210,16 +224,63 @@ first (4 of 4).
**Edit and fork.** On Codex the steer is a backend user message inside a turn. Its text starts with the note and
the sender prefix, so it never matches a member's text, and `editMessage` already excludes `tool_boundary` entries.

### 3.5 OpenCode: agent steers go in through the session's own inbox (2.2.5 pin, 2026-11-11)

The OpenCode backend declares the same `toolBoundaryDelivery` capability, so the enqueue branch of §3.1 and the
whole orchestrator half of §3.4 are unchanged: same session gate, eligible-batch rule, `boundaryText`, push at
the steer's enqueue and at each main-thread `tool_call`, rate-limit exemption. OpenCode is a push session like
Codex; its `steer()` rides the v2 inbox that §2's re-probe qualified.

**Push.** `OpenCodeSession.steer(text, steerId)` (transport.steer) sends
`POST /api/session/:id/prompt` with `{id: "msg_<steerId>", text, delivery: "steer", resume: false}`.
`resume:false` is the precondition the 1.18.35 probe found missing: the admit is durable, but only the
coordinator's wake starts a run (upstream `core/src/session/session.ts:187`,
`core/src/session/run-coordinator.ts:119-140`), and this call never wakes. A run that is already active promotes
the row at its next step boundary (`core/src/session/runner/llm.ts:95-98,187-199`); a steer that misses the
run's last boundary check stays pending and can never start an unowned run. The caller-chosen inbox id (the
route only requires the `msg_` prefix, `schema/src/session-message.ts:21`) is registered before the POST, so the
delivered frame is recognized even when it outruns the admit's answer. The same guards as Codex answer false
with no request (no active turn, no prompt out yet, abort in flight); a refused admit (any HTTP failure, a 409
id conflict) answers false and never throws.

**Delivery is the inbox event.** OpenCode promotes the row inside the run and publishes
`session.inbox.delivered` on the same ordered event stream, always before the run's terminal frame. The
transport maps a delivered frame of one of its own inbox ids to `steer_delivered` (once per id), and the
orchestrator logs the items at that point, as in §3.4.

**Release.** The transport releases every admitted-but-undelivered steer inside the terminal-frame handling, in
`abort()` after the interrupt (which parks rows: no `resume`), and on the failed turn's stop chain that the next
turn waits out; a pre-prompt cancel catches survivors of a server replacement. The cancel route is no oracle:
2.2.5 answers 204 for every `DELETE /api/session/:id/inbox/:inboxId` of an existing session, swallowing the
conflict of a delivered or unknown row (`server/src/handlers/session.ts`, `session.inbox.cancel`). So the
pending list decides first: `GET /api/session/:id/inbox` still listing the row means the run never took it -
the DELETE cancels it; a row gone from the list was promoted (its delivered frame lost with a broken stream),
which is reported as `steer_delivered` before the completion. The session is idle at every one of those points
(Isomux sends the only wakes), so the list cannot change between the read and the cancel. Cancelled items go
back to the queue with the turn's drain (`drainBoundaryClaim`), and the flush that follows delivers them once.
At-least-once, as §3.1/§3.4: a duplicate remains possible only when the list read or the DELETE itself fails
against a live server, since a row it misses is promoted by the next run before the flush's copy.

**Edit and fork.** As on Codex, the steer is a backend user message inside the turn; its text starts with the
note and the sender prefix, and `editMessage` already excludes `tool_boundary` entries (§3.1).

**Upstream guarantee.** OpenCode's own suite pins only half of this: "only records the prompt when resume is
false" (`packages/core/test/session-prompt.test.ts`) checks that `resume: false` never wakes the session. A test
for the other half, a steer row that stays pending and cancellable, was proposed and declined
([anomalyco/opencode#63356](https://github.com/anomalyco/opencode/pull/63356), closed as not planned). Only the
live tests in `server/test-support/agent-manager.opencode.live.test.ts` guard it, so run
`bun run test:opencode` on every OpenCode version bump.

## 4. What changes for callers

| Surface | Before | After |
|---|---|---|
| Ack `steered:true` | turn aborted | Claude receiver: delivered at its next batch boundary, or as its next turn if the turn ends first or the queue holds a member/skill/handoff item. Codex receiver (since 85706f13): delivered at its next step (after the running tool or reply, or at the next poll of a long command, which keeps running), or as its next turn if codex refuses it or drops it. OpenCode: turn aborted (unchanged). Shape unchanged. |
| Ack `queued:false` on a steer | "does not wait for the current turn" | Claude or Codex receiver: can wait for the rest of the turn (long tool call, no further boundary, ineligible batch) |
| Ack `steered:true` | turn aborted | Claude receiver: delivered at its next batch boundary, or as its next turn if the turn ends first or the queue holds a member/skill/handoff item. Codex receiver (since 85706f13): delivered at its next step (after the running tool or reply, or at the next poll of a long command, which keeps running), or as its next turn if codex refuses it or drops it. OpenCode receiver (since the 2.2.5 pin, §3.5): as Codex - delivered at the run's next step boundary, or as its next turn if the run ended first or OpenCode refused the admit. Shape unchanged. |
| Ack `queued:false` on a steer | "does not wait for the current turn" | Claude, Codex or OpenCode receiver: can wait for the rest of the turn (long tool call, no further boundary, ineligible batch) |
| Rate limit (3/min/receiver) | counts steers that interrupted | unchanged meaning: boundary steers interrupt nothing, so they are neither counted nor refused |
| Long tool call at the receiver (for example a 15-minute suite) | steer cuts it | Claude: steer waits for it. Codex: the command keeps running and the steer can go in at its next poll. OpenCode: steer cuts it |
| Long tool call at the receiver (for example a 15-minute suite) | steer cuts it | Claude: steer waits for it. Codex: the command keeps running and the steer can go in at its next poll. OpenCode: the tool runs to its end and the steer goes in at the step boundary after it |
| Ordinary agent that must stop a Claude agent now | steer | no path; privileged agents and members keep `/abort` and Send now. Since task f4452169 (Nil, 2026-10-02) every agent may `/abort` any agent it may message; agent stops share the steer window and leave the target a stop notice |
| Chat log | "Agent interrupted." then the message | no interruption line. Claude: the message appears after the tool result. Codex: where codex recorded it, which for a long command can be between the command card and its result |
| Chat log | "Agent interrupted." then the message | no interruption line. Claude: the message appears after the tool result. Codex: where codex recorded it, which for a long command can be between the command card and its result. OpenCode: after the tool result of the step whose boundary promoted it |
| Curl card label for a steer | "Interrupt {who} with a message" | "Message {who} mid-turn" (en; es, ca, zh follow) |

## 5. Decisions (PM, 2026-09-26)
Expand All @@ -228,7 +289,9 @@ the sender prefix, so it never matches a member's text, and `editMessage` alread
privileged agents keep `/abort`. The PM takes the contract change to Nil.
2. The rate limit counts only steers that abort.
3. Codex `turn/steer`: a follow-up task. Codex and OpenCode keep the abort plus the §3.2 note. Done for Codex in
task 85706f13 (§3.4, 2026-10-19); OpenCode keeps the abort (§2).
task 85706f13 (§3.4, 2026-10-19); done for OpenCode on the 2.2.5 pin (§3.5, 2026-11-11) after the §2
re-probe qualified the native inbox steer. An agent steer now aborts no engine; the §3.2 notes remain for
the abort paths that are left (a member's Send now, an agent's `/abort`, and the send-now route).

## 6. Verification

Expand Down Expand Up @@ -269,3 +332,19 @@ the sender prefix, so it never matches a member's text, and `editMessage` alread
recorded the text at a poll boundary; the chat showed it before the Bash card, which the adapter reported only
when the command ended until task 9f8d6942); the Receiver did steps 2 and 3 and replied with the line count. It asked nobody anything and did
not treat the steer as injected text.
- Unit, OpenCode push (§3.5; `transport.test.ts` "OpenCode 2 native steer", `adapter.test.ts` "OpenCode session
steer"): an admitted steer folds into the running turn - `steer_delivered` before `turn_completed`, a foreign
inbox row's delivery reports nothing, no cancel owed; false with no request when no turn runs; a refused admit
answers false and leaves nothing pending; a parked steer is cancelled before the completion is emitted; a row
gone from the pending list (the cancel route answers 204 for both outcomes) is reported as the delivery, once,
before the completion, with no DELETE; abort cancels a parked steer after the interrupt; the backend declares
the capability; the session's stream surfaces the delivery. The fake answers the steer route (`resume:false`
prompts), the inbox GET/DELETE routes (204 for every cancel, as 2.2.5), and plays each turn's boundary as
`deliver`, `silent` (a frame the stream lost) or `park` (the steer missed the run).
- Probe, OpenCode 2.2.5 (§2 re-probe, 2026-11-11): pinned darwin-arm64 binary with the gate mock, 48 timed race
trials per mode. `resume:false`: 31 folded into the run, 17 stayed pending and cancelled (204), 0 successor
runs, 0 lost. Default prompts: 35 folded, 13 successor runs, 0 lost. Mid-run steer during a 6 s tool: the tool
ran to its end, one run, the next model request carried the text. Idle admit with `resume:false`: no execution
started while the row was pending. Scratch runs, not kept.
- Unverified for OpenCode: a pending row across an OpenCode server restart (durable in its SQLite by design, and
the next turn's pre-prompt cancel guards it); the §1 live repro at an OpenCode receiver.
2 changes: 1 addition & 1 deletion server/agent-reference/messaging.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Messaging

Send to another agent with `POST /api/agents/:id/messages`: the body is the message as `text/plain`, and options go in the query: `steer=1`, `clientMessageId=<id>`. The token supplies sender identity. A response with `queued:true` waits for the current turn to finish; an idle recipient receives it now. To reach a busy recipient during its current turn, add `steer=1`: a Claude or Codex agent receives it without being interrupted; an OpenCode agent is interrupted. When starting an exchange, make sure one side steers and tell the peer. Otherwise both sides can continue on stale information. `clientMessageId` makes retries safe for five minutes.
Send to another agent with `POST /api/agents/:id/messages`: the body is the message as `text/plain`, and options go in the query: `steer=1`, `clientMessageId=<id>`. The token supplies sender identity. A response with `queued:true` waits for the current turn to finish; an idle recipient receives it now. To reach a busy recipient during its current turn, add `steer=1`: the recipient receives it without being interrupted, as soon as its running tool calls end. When starting an exchange, make sure one side steers and tell the peer. Otherwise both sides can continue on stale information. `clientMessageId` makes retries safe for five minutes.

To stop another agent's current turn without a message, use `POST /api/agents/:id/abort` with `{}`. It also denies a permission prompt the agent is parked on. Every agent can stop an agent it can message.

Expand Down
91 changes: 91 additions & 0 deletions server/backends/opencode/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { STATE_ROOT } from "../../config.ts";
import { AGENT_ROOT } from "../../split/roots.ts";
import {
FAKE_SESSION,
fakeOpenCode,
fakeSupervisor,
fakeToolEnvironment,
Expand Down Expand Up @@ -1546,3 +1547,93 @@ describe("OpenCode pinned transport", () => {
);
}
});

// Agent steers reach a busy OpenCode session through its inbox, without an
// abort (internal-docs/steer-delivery-design.md §3.5). The orchestrator's
// push path (message-queue pushBoundarySteer) reads the capability flag and
// the session's steer(); the transport covers the wire behavior
// (transport.test.ts, "OpenCode 2 native steer").
describe("OpenCode session steer", () => {
it("the OpenCode backend delivers agent steers without aborting", () => {
// The enqueue path reads this flag; without it every steer still aborts.
expect(getBackend("opencode").toolBoundaryDelivery).toBe(true);
});

it("steers the running turn and surfaces the delivery on its stream", async () => {
const f = framesOf(FAKE_SESSION);
const turn: FakeTurn = {
frames: [
f.delivered("INBOX"),
f.stepStarted("m1"),
...f.toolCalled("m1", "call_1", "shell", { command: "sleep 5" }),
f.asked("per_1", "sleep 5"),
f.toolSuccess("m1", "call_1", "slept"),
f.stepEnded("m1"),
f.textEnded("m1", "done"),
f.succeeded(),
] as Frame[],
session: FAKE_SESSION,
inbox: "INBOX",
};
const fake = fakeOpenCode({ turns: [turn] });
cleanup.push(fake.restore);
const backend = createOpenCodeBackend({
supervisor: fakeSupervisor().supervisor,
});
const session = backend.createSession({
...opts,
modelFamily: "provider/model",
});
cleanup.push(() => session.close());

const events: NormalizedEvent[] = [];
const streamed = (async () => {
for await (const event of session.stream()) {
events.push(event);
if (event.kind === "turn_completed") return event;
}
return undefined;
})();
const sending = session.send("work");
const askAt = Date.now() + 3_000;
while (
!events.some((e) => e.kind === "approval_request") &&
Date.now() < askAt
)
await Bun.sleep(2);
const ask = events.find((e) => e.kind === "approval_request") as Extract<
NormalizedEvent,
{ kind: "approval_request" }
>;
expect(ask).toBeDefined();
expect(await session.steer!("FYI from Sender", "steer-1")).toBe(true);
await session.approve(ask.approvalId, { kind: "allow_once" });
await sending;
const end = await streamed;

expect(end?.status).toBe("completed");
const delivered = events.filter((e) => e.kind === "steer_delivered");
expect(delivered).toEqual([
{ kind: "steer_delivered", steerId: "steer-1" },
]);
expect(events.indexOf(delivered[0]!)).toBeLessThan(
events.findIndex((e) => e.kind === "turn_completed"),
);
expect(fake.inboxRow("msg_steer-1")?.state).toBe("delivered");
});

it("refuses a steer with no turn running", async () => {
const fake = fakeOpenCode({});
cleanup.push(fake.restore);
const backend = createOpenCodeBackend({
supervisor: fakeSupervisor().supervisor,
});
const session = backend.createSession({
...opts,
modelFamily: "provider/model",
});
cleanup.push(() => session.close());
expect(await session.steer!("early", "steer-0")).toBe(false);
expect(fake.steerLog).toEqual([]);
});
});
Loading