Skip to content

Steer mid-turn chat messages for opencode (needs upstream ACP support) #284

Open

Description

@bsklaroff

Problem

In chat (acp) mode, messages sent while an agent is working are added to the running turn ("steered") for claude, codex and pi, the same as their TUIs do. opencode still holds the message until the turn ends. The pane shows it as "queued until the agent finishes", and it can be removed before it runs (see "Sending mid-turn" in docs/agent-modes.md).

opencode's own TUI steers by default: Enter submits with delivery: "steer", and <leader>return queues. So chat mode does not behave like the TUI for opencode.

Why yaac can't fix it on its side

opencode acp (checked on the pinned 2.0.12 and the latest 2.0.21) has no way to accept a second message mid-turn:

  • A second session/prompt while one is running fails with Session already has an active ACP prompt.
  • It does not implement the _session/steering extension that claude-agent-acp and codex-acp do, and its initialize reply does not advertise _meta.steering.
  • Its own prompts already go to opencode's server with delivery: "steer"; there is just no entry point for a second one.

We fixed pi-acp the same way with an install-time patch (dockerfiles/agent-patches/pi-acp.js). That isn't reasonable for opencode:

  • The ACP adapter is compiled into opencode's Bun binary. Patching it means rewriting the binary's module table so Bun runs edited source instead of the bytecode.
  • The darwin binary would need re-signing (codesign --force -s -).
  • The minified names change every release.

A side channel to the private opencode serve child that opencode acp spawns (scraping its port and OPENCODE_PASSWORD from /proc) was also rejected as too fragile.

Upstream status

How to fix it upstream

Proposed change to opencode's ACP adapter (packages/opencode/src/acp/service.ts on the dev branch), matching the contract claude-agent-acp and codex-acp already implement.

  1. Advertise it. Add _meta: { steering: { supported: true } } to the initialize result, merged with the existing opencode/child-session-updates key.

  2. Add a _session/steering request handler. Register it beside session/prompt; the adapter's ACP SDK accepts custom methods via onRequest(method, parser, handler). The params are { sessionId, prompt: ContentBlock[], _meta?: { steering?: { idleBehavior?: "promptRequired" } } }.

    • A turn is running (the adapter holds an active ACP prompt for the session): convert the blocks the way session/prompt does, then submit through the same SDK client:

      session.prompt({ sessionID, text, files, delivery: "steer", resume: false })
      

      Reply { outcome: "injected" }. The running turn's event forwarding already picks up the steered work. The original session/prompt reply arrives once, after the steered output; we verified this through the HTTP side channel.

    • Idle, and the client sent idleBehavior: "promptRequired": consume nothing and reply { outcome: "promptRequired", reason: "noRunningTurn" }. The client then sends a normal session/prompt.

    • Idle, no opt-in: start a turn and reply { outcome: "startedNewTurn" }, as the other adapters do. Better still, treat it like the opt-in. codex-acp ignoring the opt-in is the reason yaac has to follow codex's thread status at all.

  3. Close the turn-end race. A steer accepted just as the turn finishes must not be stranded. With resume: false, an inbox item posted after execution ends stays pending and nothing runs it.

    • Keep the active-prompt entry until after execution has fully ended.
    • Before replying to the original session/prompt, check session.inbox for items this turn accepted. If any are still pending, either resume execution and keep forwarding, or delete them (DELETE /api/session/{id}/inbox/{inboxID}) so the steer's injected reply was not a lie.
    • On session/cancel, delete this turn's pending steers.
  4. Echoes. No change is needed for yaac: it shows the steered message from the recorded request and reply. Still, emitting a user_message_chunk for it on session/load replay would keep history complete.

The adapter's behaviour can be checked without model credentials by running it against a scripted model server and driving opencode acp over stdio. That is how the side-channel findings above were verified.

yaac side once a release has it

  • Bump the opencode pin (AGENT_CLIS.opencode, dockerfiles/Dockerfile.tools).
  • Set steers: true for opencode in packages/server/src/runtime/agents/acp-adapters.ts.
  • Update the "opencode queues" paragraph in docs/agent-modes.md.
  • The queue path stays for anything that hands a message back (promptRequired).
  • Verify against the new binary: a mid-turn message is steered, the original prompt sends exactly one reply, and an idle steer is handed back.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions