Skip to content

fix(cli): run adds literal quotes to spaced prompt arguments #56120

Description

@coygeek

Summary

opencode run adds literal double quotes around a positional prompt passed as a single argument containing spaces. The stored user message differs from the same text supplied as separate arguments or stdin. Scripts that need exact prompt text receive altered input.

Steps to reproduce

Use the published release listed under Runtime or environment. Install it in a disposable prefix and use a fresh HOME and XDG state. The following commands were executed from an empty git project with no project configuration. All model commands use a private standalone server. The model catalog already listed the selected free model before the runs.

W=$(mktemp -d /tmp/agent-work.XXXXXX)
npm install --prefix "$W/oc" @opencode/[email protected]
mkdir -p "$W/h" "$W/project"
git init -q -b main "$W/project"
cd "$W/project"
B="$W/oc/node_modules/.bin/opencode"
oc() {
  env -i PATH="$PATH" HOME="$W/h" \
    XDG_CONFIG_HOME="$W/h/.config" \
    XDG_DATA_HOME="$W/h/.local/share" \
    XDG_CACHE_HOME="$W/h/.cache" \
    XDG_STATE_HOME="$W/h/.local/state" \
    OPENCODE_DISABLE_PROJECT_CONFIG=1 "$B" "$@"
}
oc models --standalone
oc run --standalone --format json --model opencode/glm-5.3-flash-free \
  --title 'Prompt preservation check' 'Reply with: one two' \
  </dev/null > "$W/single.jsonl"
oc run --standalone --format json --model opencode/glm-5.3-flash-free \
  --title 'Prompt preservation check' Reply with: one two \
  </dev/null > "$W/separate.jsonl"
printf '%s' 'Reply with: one two' | \
  oc run --standalone --format json --model opencode/glm-5.3-flash-free \
  --title 'Prompt preservation check' > "$W/stdin.jsonl"
for case in single separate stdin; do
  S=$(jq -r 'select(.sessionID) | .sessionID' "$W/$case.jsonl" | head -n 1)
  oc session export --standalone "$S" | jq -c \
    '{user:.messages[0].text,assistant:[.messages[]|select(.type=="assistant")|{model:.model.id,cost,finish}]}'
done

The three CLI invocations and subsequent exports above reflect the executed argument forms. The investigator used a subprocess runner with an external deadline and wrote outputs into per-case folders; that wrapper does not modify argv or stdin. If npm disables lifecycle scripts, run the installed package's postinstall.mjs before using its executable. The isolated reproduction required that installation step because the local package manager disabled scripts.

Expected behavior

The positional argument is documented as the message to send. Shell grouping quotes determine argv boundaries and should not become literal message content. All three forms above should store the same text, Reply with: one two. Quotes intentionally present within the supplied text should remain distinguishable from shell syntax.

Actual behavior

All three runs exited with code 0. Each export showed an assistant completion using glm-5.3-flash-free, recorded cost 0, and finish stop. The single-argument form stored extra quote characters. The separate-argument and stdin controls preserved the input text.

{"case":"single","user":"\"Reply with: one two\"","assistant":[{"model":"glm-5.3-flash-free","cost":0,"finish":"stop"}]}
{"case":"separate","user":"Reply with: one two","assistant":[{"model":"glm-5.3-flash-free","cost":0,"finish":"stop"}]}
{"case":"stdin","user":"Reply with: one two","assistant":[{"model":"glm-5.3-flash-free","cost":0,"finish":"stop"}]}

Evidence

  • Expected source: The measured opencode run --help describes the positional message... argument as Message to send; the release command declaration agrees. The two successful controls establish the same expected text for these inputs.
  • Failure source: Fresh standalone CLI runs and session exports produced the JSON quoted under Actual behavior.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

The local run outputs are the primary violation evidence. The published binary was executed. The current default branch was inspected statically, not built or executed.

The release input handling calls formatMessage on the parsed positional arguments before merging stdin. The release helper adds literal wrapping quotes to each argument containing a space, then joins the arguments. When there is no positional message, mergeInput returns the piped text unchanged. The default-branch helper still contains the same implementation. The release prompt submission passes that message as text to the prompt API.

Restoration check

Repeat the three argument forms and inspect exported user text. Each should contain exactly Reply with: one two, without new wrapping quotes. Keep the separate-argument and stdin controls passing. Also verify that intentional embedded quotes and multiline text retain their literal content, so removing the extra wrapper does not introduce a different alteration. Those additional inputs were not exercised in this local rerun.

Runtime or environment

  • Published executable: opencode v2.0.31, installed from npm package @opencode/cli 2.0.31, the current npm latest dist-tag when checked.
  • Corresponding release source: tag v2.0.31, commit 22728d67461260d56bb9796b9b8f36e1de47202e.
  • Current default source: dev, commit ed772f5009755b5262805a7ba07e66e1417f140a; static inspection only.
  • Platform: Darwin 27.0.0, arm64.
  • Provider and model: OpenCode Zen, opencode/glm-5.3-flash-free, fresh state without provider credentials.
  • Server route: --standalone for model listing, runs, and session exports.

Additional context

Issue #43923 originally reported this behavior on v1. A later comment confirmed the stored-message alteration and stdin workaround on v2.0.10. The issue was then closed in the automated v1 issue sweep. Its closing comment asks users to reopen or open a new issue with their 2.0 version if the behavior is still relevant. This is that 2.0 report: the published release listed above reproduces it, and the inspected dev source retains the same helper.

PR #50245 is a closed, unmerged fix proposal for the same helper and spaced-argument trigger. Its withdrawal explanation refers to the existing issue. It does not establish a shipped fix.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: A supported automation entry point changes literal prompt text for a spaced positional argument. The reproduction confirms stored-message alteration, while downstream model-answer consequences and population frequency were not measured.
  • Workaround status: available
  • Workaround basis: Sending the identical prompt through stdin with printf '%s' preserved the stored text in the successful control run without adding a newline or requiring broader permissions.

Activity

  1. opencode-agent commented on Oct 16, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on 2.0.31, and it still happens on the latest dev (ed772f5).

    I used a fresh HOME/XDG directory, an empty git project and a local mock OpenAI-compatible provider, so no credentials were needed. I ran the three forms from your report and checked each stored user message with opencode session export:

    • opencode run 'Reply with: one two' stores "Reply with: one two", with literal quotes
    • opencode run Reply with: one two stores Reply with: one two
    • printf '%s' 'Reply with: one two' | opencode run stores Reply with: one two

    The cause matches what you found: formatMessage in packages/cli/src/run/run.ts wraps each argument that contains a space in quotes before joining them. Until this changes, piping the prompt through stdin keeps the text exactly as given.

    The script below reproduces it. Related: #43923 (the original v1 report) and #50245 (an earlier fix proposal that was closed without being merged).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces anomalyco/opencode#56120: `opencode run 'a b'` stores the prompt wrapped in literal quotes.
    # Uses a local mock OpenAI-compatible provider (no credentials needed).
    # OC: command to run opencode (default: opencode from PATH).
    set -u
    OC=${OC:-opencode}
    W=$(mktemp -d /tmp/opencode/oc56120.XXXXXX)
    mkdir -p "$W/h/.config/opencode" "$W/project"
    git init -q "$W/project"
    PORT=18456
    cat > "$W/mock.ts" <<'TS'
    Bun.serve({ port: Number(process.env.PORT), async fetch(req) {
      const body = await req.text()
      if (!new URL(req.url).pathname.endsWith("/chat/completions")) return new Response("{}", { status: 404 })
      const chunk = (o: unknown) => `data: ${JSON.stringify(o)}\n\n`
      const base = { id: "x", object: "chat.completion.chunk", created: 0, model: "mock" }
      const s = chunk({ ...base, choices: [{ index: 0, delta: { role: "assistant", content: "ok" }, finish_reason: null }] })
        + chunk({ ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })
        + "data: [DONE]\n\n"
      return new Response(s, { headers: { "content-type": "text/event-stream" } })
    }})
    TS
    PORT=$PORT bun "$W/mock.ts" & MOCK=$!
    trap 'kill $MOCK' EXIT
    cat > "$W/h/.config/opencode/opencode.json" <<JSON
    { "provider": { "mock": { "npm": "@ai-sdk/openai-compatible", "name": "Mock",
      "options": { "baseURL": "http://127.0.0.1:$PORT/v1", "apiKey": "x" },
      "models": { "m": { "name": "m" } } } } }
    JSON
    cd "$W/project"
    oc() { env -i PATH="$PATH" OPENCODE_PASSWORD=repro56120 HOME="$W/h" XDG_CONFIG_HOME="$W/h/.config" XDG_DATA_HOME="$W/h/.local/share" \
      XDG_CACHE_HOME="$W/h/.cache" XDG_STATE_HOME="$W/h/.local/state" $OC "$@"; }
    oc --version
    # CONN: --standalone by default; set SERVE=1 to start `opencode serve` and connect with --server
    # (needed when running from a source checkout, where the standalone child loses bun's preload).
    CONN=--standalone
    if [ -n "${SERVE:-}" ]; then
      oc serve --port 18457 >/dev/null 2>&1 & SRV=$!
      trap 'kill $MOCK $SRV' EXIT
      for i in $(seq 60); do curl -s -o /dev/null http://127.0.0.1:18457 && break; sleep 1; done
      CONN="--server http://127.0.0.1:18457"
    fi
    oc run $CONN --format json -m mock/m 'Reply with: one two' </dev/null > "$W/single.jsonl"
    oc run $CONN --format json -m mock/m Reply with: one two </dev/null > "$W/separate.jsonl"
    printf '%s' 'Reply with: one two' | oc run $CONN --format json -m mock/m > "$W/stdin.jsonl"
    for c in single separate stdin; do
      S=$(jq -r 'select(.sessionID) | .sessionID' "$W/$c.jsonl" | head -n1)
      printf '%-9s ' "$c"; oc session export $CONN "$S" | jq -c '{user: .messages[0].text}'
    done

    Run it: bash repro.sh

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