Skip to content

bug: opencode agent profiles never work — adapter requests OpenAPI operationIds as URL paths #212

Description

@ThalynLabs

Summary

Every prompt sent to an opencode agent profile fails immediately with:

Expecting value: line 1 column 1 (char 0)

The root cause is in cptr/utils/agents/opencode.py: the adapter requests opencode's OpenAPI operationId values as if they were URL paths. Any route whose real path contains the {sessionID} parameter is therefore unreachable.

The affected route lists are byte-identical in 0.9.3, 0.9.7, 0.9.12, 0.9.16, 0.9.19 and 0.9.20, so this is not a regression — opencode profiles appear never to have worked in a released version.

Environment

  • cptr 0.9.19 and 0.9.20 (both reproduce)
  • opencode 1.18.5
  • macOS, Python 3.11, host install via uvx "cptr[agents]@latest"

Symptom

The assistant message contains only the error above. It appears ~90 ms after send — no model call is ever made. Server log:

File "cptr/utils/chat_task.py", line 1726, in _run_agent_target
  raise RuntimeError(event.message)
    AgentError(message='Expecting value: line 1 column 1 (char 0)')
RuntimeError: Expecting value: line 1 column 1 (char 0)

Root cause

_request() tries a list of candidate paths and returns the first that parses. Those candidates are opencode's operationIds, not routes. From opencode 1.18.5's own /doc:

adapter requests what the string actually is real route
session.create operationId POST /session
event.subscribe operationId GET /event
session.promptAsync operationId POST /session/{sessionID}/prompt_async
session.abort operationId POST /session/{sessionID}/abort
session.prompt operationId POST /session/{sessionID}/message

Why it half-works. For session.create and event.subscribe the real paths are the bare collection nouns /session and /event, and the adapter's third fallback happens to be exactly that — so session creation and the event stream succeed by coincidence. _start_opencode_prompt and the abort call map to routes containing {sessionID}, which no static candidate string can produce, so they always fail.

Why the error is a JSON error and not a 404. opencode serves its web UI for unmatched routes with HTTP 200, so response.raise_for_status() passes and response.json() then fails on <!doctype html> — which is exactly Expecting value: line 1 column 1 (char 0).

Reproduction

Configure any opencode agent profile with any model and send a message. Or directly against a running opencode serve:

# adapter's three prompt candidates -- all return the web UI with HTTP 200
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -X POST \
  "http://127.0.0.1:$PORT/session/prompt" \
  -H 'Content-Type: application/json' -d '{"sessionID":"...","parts":[]}'
# 200 2884      <- HTML, not JSON

# the real route
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -X POST \
  "http://127.0.0.1:$PORT/session/$SID/prompt_async" \
  -H 'Content-Type: application/json' \
  -d '{"sessionID":"'"$SID"'","model":{"providerID":"...","modelID":"..."},"parts":[{"type":"text","text":"hi"}]}'
# 204 0         <- accepted

Second defect, only observable once the routes are fixed

With the prompt route corrected, the reply is garbled two ways:

  1. Assistant text is emitted twice. opencode streams incremental message.part.delta events and then a full-text message.part.updated snapshot. The delta branch of _text_from_event never records into emitted, so the snapshot sees previous == "" and re-emits the whole text.
  2. The user's own prompt is echoed into the reply. opencode publishes the user's message as an ordinary text part, and message.part.updated is emitted for it without any role check.

Combined result for a one-line prompt: the prompt text followed by the answer repeated.

Suggested fix

In cptr/utils/agents/opencode.py:

  • Prompt — try f"session/{session_id}/prompt_async" first, keeping the existing names as fallbacks.
  • Abort — likewise f"session/{session_id}/abort".
  • _request — return {} when response.content is empty. The corrected prompt route answers 204 No Content, which otherwise trips the identical JSON error. Empty body only: an HTML body must keep raising, or the session.create probe stops falling through to /session.
  • _text_from_event — build a role ledger from message.updated (it carries properties.sessionID, so it survives the session filter, plus properties.info.{id,role}), skip parts whose messageID belongs to a user message, and let the delta branch advance emitted so the trailing snapshot is a no-op.

I've been running these four changes against opencode 1.18.5 for a day: clean single-copy replies, no prompt echo, and working tool calls.

Possibly related

#170 reports being unable to connect Computer to opencode. Different surface (Docker networking) and no error text quoted, so I filed separately, but it may share this root cause.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions