Skip to content

ACP: slash commands execute but never stream their output #54371

Open

Description

@arkhovansky

Description

User-visible symptoms

Running as an ACP agent (in JetBrains Rider 2026.2): slash commands (built-in like /init or custom) execute, but their output is not visible in the chat.

Plain prompts in the same session work normally.
Another ACP agent in the same IDE does show its slash-command output, so the client's rendering path is fine.

At the protocol level

session/prompt replies end_turn immediately; no session/update follows for that turn.

Example

Rider's ACP log (llm.agent.extended.logging=true):

20:44:23,425  → session/prompt id=5  {"prompt":[{"type":"text","text":"/my-test"}]}
20:44:23,457  ← {"stopReason":"end_turn","_meta":{}}      ← 32 ms
              … no ACP traffic for 4 m 16 s …
20:48:39,687  → session/set_config_option id=6

The command did run (it produced its effect).
OpenCode's session_message table shows that the turn ran to completion after it was closed on wire:

seq 20  user       20:44:23.531  <command body>   ← AFTER end_turn
seq 21  assistant  20:44:24.281 → completed 20:44:32.895  <agent reply>
...
seq 28  idle       20:44:32.901  outcome=succeeded

my-test was advertised in available_commands_update so this is the recognized-command path (not the unknown-/ fallthrough).


OpenCode version

2.0.26


Steps to reproduce

  1. Install OpenCode as an ACP agent in Rider.
    I used a custom agent path.
    1.1. Install OpenCode independently from Rider.
    1.2. Add OpenCode as a custom agent:
  "default_mcp_settings": {},
  "agent_servers": {
    "OpenCode": {
      "command": "full/path/to/opencode.exe",
      "args": ["acp"]
    }
  }
}
  1. Run any slash command.

Operating System

Windows 10


Suspected cause

packages/cli/src/acp/turn.ts:

yield* submit(attached, prompt)
if (prompt.command) return "succeeded" as const   // ←
return yield* Fiber.join(events)

For command prompts the turn returns succeeded before Fiber.join(events), and then the release step closes the per-turn event subscription, so no session/update (and no permission.asked) can be emitted.

Possible fix

PR #49208 (open; probably needs rebase onto current v2).

Activity

  1. opencode-agent commented on Oct 10, 2026

    @opencode-agent
    Contributor

    Thanks for the clear report and the protocol log. I reproduced this on 2.0.26 with a simulated model and a config command my-test, driving opencode acp over stdio:

    • A plain prompt streams agent_message_chunk and usage_update, then returns end_turn (~1.6s).
    • /my-test returns end_turn after ~14ms, and no session/update arrives for that turn, even though the model request for the command is still made.

    It still happens on the latest dev (7b3d4ce). The script below reproduces it. Your reading of the cause matches what I found: in packages/cli/src/acp/turn.ts, command prompts return before the turn's event stream is joined. #49208 targets this, and #53914 covers a related case where a command runs several cycles.

    Reproduction script (acp-command.ts)
    // Reproduces #54371: ACP session/prompt for a slash command returns end_turn
    // immediately and the command's model output is never streamed.
    // Usage: bun /workspace/repro/acp-command.ts            (uses `opencode` from PATH)
    //        OC_DEV=/path/to/opencode-checkout bun acp-command.ts   (runs from source)
    import fs from "node:fs/promises"
    import os from "node:os"
    import path from "node:path"
    
    const root = await fs.mkdtemp(path.join(os.tmpdir(), "acp-cmd-"))
    const home = path.join(root, "workspace")
    const xdg = (d: string) => path.join(root, d)
    await fs.mkdir(home, { recursive: true })
    await fs.mkdir(path.join(xdg("config"), "opencode"), { recursive: true })
    
    const llmRequests: number[] = []
    const llm = Bun.serve({
      hostname: "127.0.0.1",
      port: 0,
      async fetch(req) {
        if (new URL(req.url).pathname !== "/v1/chat/completions") return new Response("nf", { status: 404 })
        await req.json()
        llmRequests.push(Date.now())
        await Bun.sleep(1500) // model takes a moment, like a real one
        const chunks = [
          { choices: [{ delta: { role: "assistant" }, finish_reason: null }], usage: null },
          { choices: [{ delta: { content: "MODEL-REPLY-TEXT" }, finish_reason: null }], usage: null },
          { choices: [{ delta: {}, finish_reason: "stop" }], usage: null },
          { choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
        ]
        return new Response(chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") + "data: [DONE]\n\n", {
          headers: { "content-type": "text/event-stream" },
        })
      },
    })
    
    const model = {
      name: "Test Model",
      capabilities: { tools: true, input: ["text"], output: ["text"] },
      cost: { input: 0, output: 0 },
      limit: { context: 100000, output: 10000 },
    }
    await Bun.write(
      path.join(xdg("config"), "opencode", "opencode.json"),
      JSON.stringify({
        update: "disable",
        model: "test/test-model",
        providers: {
          test: {
            name: "Test",
            package: "aisdk:@ai-sdk/openai-compatible",
            settings: { apiKey: "x", baseURL: `http://127.0.0.1:${llm.port}/v1` },
            models: { "test-model": model },
          },
        },
        commands: { "my-test": { template: "Say hello", description: "test command" } },
      }),
    )
    const models = path.join(root, "models.json")
    await Bun.write(models, "{}")
    
    const dev = process.env.OC_DEV
    const cmd = dev
      ? ["bun", "run", "src/index.ts", "acp"] // run from <checkout>/packages/cli, like the repo's ACP subprocess tests
      : ["opencode", "acp"]
    const child = Bun.spawn(cmd, {
      cwd: dev ? path.join(dev, "packages/cli") : home,
      stdin: "pipe",
      stdout: "pipe",
      stderr: "pipe",
      env: {
        ...process.env,
        HOME: root,
        XDG_CONFIG_HOME: xdg("config"),
        XDG_DATA_HOME: xdg("data"),
        XDG_STATE_HOME: xdg("state"),
        XDG_CACHE_HOME: xdg("cache"),
        OPENCODE_DISABLE_AUTOUPDATE: "true",
        OPENCODE_MODELS_PATH: models,
      },
    })
    
    const t0 = Date.now()
    const log = (dir: string, m: any) => {
      const s = JSON.stringify(m)
      console.log(`+${String(Date.now() - t0).padStart(6)}ms ${dir} ${s.length > 220 ? s.slice(0, 220) + "…" : s}`)
    }
    const pending = new Map<number, (m: any) => void>()
    const updates: { at: number; m: any }[] = []
    ;(async () => {
      const dec = new TextDecoder()
      let buf = ""
      for await (const chunk of child.stdout) {
        buf += dec.decode(chunk)
        let i
        while ((i = buf.indexOf("\n")) >= 0) {
          const line = buf.slice(0, i).trim()
          buf = buf.slice(i + 1)
          if (!line) continue
          const m = JSON.parse(line)
          if (m.method === "session/update") updates.push({ at: Date.now(), m })
          if (m.method !== "session/update" || m.params?.update?.sessionUpdate !== "available_commands_update") log("<-", m)
          if (m.id !== undefined && !m.method) pending.get(m.id)?.(m)
        }
      }
    })()
    let id = 0
    const request = (method: string, params: any) => {
      const msg = { jsonrpc: "2.0", id: ++id, method, params }
      log("->", msg)
      child.stdin.write(JSON.stringify(msg) + "\n")
      child.stdin.flush()
      return new Promise<any>((r) => pending.set(msg.id, r))
    }
    
    await request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "repro", version: "0" } })
    const s = await request("session/new", { cwd: home, mcpServers: [] })
    const sessionId = s.result.sessionId
    await Bun.sleep(1000)
    
    async function turn(text: string) {
      const before = updates.length
      const sent = Date.now()
      const res = await request("session/prompt", { sessionId, prompt: [{ type: "text", text }] })
      const replyAt = Date.now()
      await Bun.sleep(5000) // wait for anything late
      const during = updates.slice(before).filter((u) => u.at <= replyAt)
      const chunks = during.filter((u) => u.m.params.update.sessionUpdate === "agent_message_chunk")
      console.log(
        `\n== "${text}": reply ${res.result?.stopReason ?? JSON.stringify(res.error)} after ${replyAt - sent}ms; ` +
          `agent_message_chunk updates before reply: ${chunks.length}; total updates after send: ${updates.length - before}\n`,
      )
    }
    
    await turn("plain prompt")
    await turn("/my-test")
    console.log(`LLM requests received: ${llmRequests.length}`)
    child.kill()
    llm.stop(true)
    process.exit(0)

    Run it: bun acp-command.ts

  2. german-gd commented on Nov 3, 2026

    @german-gd

    Still happens on 2.2.5 (Linux, opencode acp driven by OpenHands Agent Canvas 1.34, and by a plain stdio script). A project command whose body runs sleep 20; echo pong gets end_turn at once, with no session/update for the turn, while its shell call has already started in the session. Built-in /compact only looks fine because it has nothing to stream: it returns end_turn in about a second and the compaction is there. Plain prompts stream normally.

  3. tazeverywhere commented on Nov 3, 2026

    @tazeverywhere

    Confirming this on 2.2.5 with Zed 1.26.2 on macOS 26.7.2 (Darwin 25.6.0, arm64), so it is not specific to Rider/OpenHands.

    Setup: Zed custom agent, no extension:

    "agent_servers": {
      "OpenCode": {
        "type": "custom",
        "command": "opencode",
        "args": ["acp"],
        "default_config_options": { "model": "opencode/deepseek-v4.1-flash" }
      }
    }

    Reproduction:

    1. Add a project command at .opencode/commands/test.md (body: Run the shell command: echo pong).
    2. Open the agent panel and submit /test.
    3. Nothing is streamed: no assistant text, no tool call, no usage update. The turn appears to end immediately.
    4. Click Reload agent in Zed (or restart Zed) — the full output, including the tool call, appears, because it was written to the session.

    Notes:

    • Plain prompts in the same session stream normally, which matches the prompt.command early-return described above.
    • The command is advertised in available_commands_update (it autocompletes in the panel), so this is the recognized-command path.
    • Pasting the expanded command body as a regular prompt streams normally — that is the workaround we use.
    • No ACP traffic reaches the client for the turn, so Zed has nothing to render; this looks like the same root cause as ACP ends registered-command streaming before later execution cycles finish #53914 (stream closed before later execution cycles).

    Happy to test a build that includes the fix if it helps.

  4. sbalyakin commented on Nov 6, 2026

    @sbalyakin

    Still happens on 2.2.5 (macOS arm64, the latest release). The ACP client is Orca 1.4.250, running opencode acp over stdio.

    I sent a custom slash command, /implement-verify <args>, whose body is "Load the implement-verify skill and follow it for this task: $ARGUMENTS". session/prompt returned end_turn after 13 ms. Orca's journal shows the turn starting and ending 13 ms apart with outcome: success. According to OpenCode's own history, the command kept running for about 84 s, and no session/update reached the client. The user sees an empty turn labelled "Worked for 0s".

    The same client also gets nothing for a turn the server resumes after a background shell finishes. That is the case nbrumont described in #55760. In the 2.2.5 binary, the ACP layer opens one event subscription per prompt and closes it when the prompt returns, so nothing is forwarded afterwards.

    A fix would wait for the turn's terminal event on the command path, and keep one subscription per attached session instead of one per prompt.

  5. abdoulayeYATERA commented on Nov 7, 2026

    @abdoulayeYATERA

    Still no fix here ?

  6. AmoabaKelvin commented on Nov 11, 2026

    @AmoabaKelvin

    Still reproduces on 2.2.7 (macOS, arm64), driving opencode acp over stdio with OPENCODE_CLIENT=acp, an isolated HOME/XDG_*, a fresh session and model opencode/big-pickle:

    • Plain prompt (Reply with the single word pong.): agent_message_chunk + usage_update, then end_turn about 4.3 s after session/prompt.
    • /review the uncommitted change: {"stopReason":"end_turn"} 9 ms after session/prompt, then no session/update for the next 60 s.
    • session_message for that session: an expanded reviewer user message created right after the response, four assistant messages over about 40 s, then idle outcome=succeeded.
    • A later session/load of the same session replays the full turn (1 user chunk, 4 thought chunks, 6 tool calls + 6 tool-call updates, 1 agent message chunk).

    packages/cli/src/acp/turn.ts on dev still has if (prompt.command) return "succeeded" as const before Fiber.join(events).

  7. paulovlobato commented on Nov 11, 2026

    @paulovlobato

    Still unfixed.
    I am having issues with my Orca integration and early terminations of my prompts because of it, so I've opened a PR #63097 that fixes that

    hopefully someone can take a look and merge it. I am running my fix on a patched version of OpenCode locally and it's working flawlessly, I hope this can be merged to main soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions