diff --git a/CHANGELOG.md b/CHANGELOG.md index 789df3b..5866c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +Review fixes for the OpenCode 2.x port; request shapes verified against a live OpenCode 2.2.5 server. + +- Select agents through the session agent surface: new sessions take `agent` at creation, existing sessions switch with `POST /api/session/{id}/agent`. The 2.x prompt endpoint silently drops an `agent` field. +- Queue prompts with `delivery: "queue"` so a prompt dispatched while a turn runs executes as its own turn; the server's default `steer` merges it into the running turn. +- Page session messages with the opaque `cursor` while observing, so a job still finishes when its turn spans more than the server's page size. +- End the observed turn at its idle marker instead of the next user message. A steered follow-up no longer truncates the turn, and the merged turn's idle marker completes the original job. +- `opencode_ask` and `opencode_reply` no longer present a partial answer as final when the wait deadline expires: the result is flagged `timedOut`, marked as an error, and carries a `TIMEOUT` note with the session to resume. Failures of the wait endpoint itself now surface instead of being swallowed. +- Read pending job inputs from the per-session `GET /api/session/{id}/form` and `GET /api/session/{id}/permission` routes instead of the location-scoped lists, which only cover the server's current directory. +- Windows smoke runs: `scripts/mcp-smoke-test.mjs` silently exited 0 without running anything when the direct-invocation path check mismatched (drive junctions, substituted or differently-spelled paths); it now compares real paths, so the checks actually execute. Disposable-fixture cleanup no longer crashes a passing run on `EBUSY`: deletion retries through transient handle lag, and when a long-lived OpenCode server still holds a project-watcher handle on the fixture directory, the script warns with the path and keeps the exit code tied to the smoke results. + +## [4.0.0] - 2026-10-22 + +### Breaking changes + +- Require an OpenCode 2.x server (tested against 2.1.2). All requests use the `/api/*` server API through the `@opencode-ai/sdk` v2 client (`^1.19.3`, `./v2/client`). An OpenCode 1.x server is detected at startup and rejected with an upgrade message; stay on 3.x to keep using OpenCode 1.x. +- OpenCode 2.x always enforces HTTP Basic authentication. For an external server, `OPENCODE_SERVER_PASSWORD` must match the server's password (username `opencode` by default); a server started without one prints a generated password at startup. A rejected password now produces an explicit credentials error. +- Remove prompt-level structured output. OpenCode 2.x dropped response formats, so the `format` parameter is gone from `opencode_ask`, `opencode_reply`, `opencode_run`, `opencode_fire`, `opencode_provider_test`, `opencode_message_send`, and `opencode_message_send_async`. Ask for machine-readable output in the prompt text instead. +- `opencode_question_reply` now answers an OpenCode 2.x form: it takes `answer`, an object keyed by form field key, instead of `answers`. It and `opencode_question_reject` accept an optional `sessionId`, looked up from the pending form list when omitted. +- Tool parameter changes following the 2.x API: + - `opencode_message_send` drops `noReply` and `system`, and adds `timeoutMs`; `opencode_ask` drops `system`. + - `opencode_command_execute` drops `agent`, `providerID`, `modelID`, and `variant`; `opencode_shell_execute` drops `agent`, `providerID`, and `modelID`. + - `opencode_session_summarize` compacts a session and no longer takes a provider/model/variant. + - `opencode_session_revert` replaces `partID` with `files`; `opencode_session_unrevert` discards staged revert changes. + - `opencode_find_text` matches file and directory paths; OpenCode 2.x has no line-level text search. `opencode_find_file` drops `searchDirectory`. + - `opencode_auth_set` drops `type` and adds an optional `label`. `opencode_provider_oauth_authorize` takes an optional `methodID` instead of `method`/`inputs`; `opencode_provider_oauth_callback` takes `attemptID` and an optional `code` instead of `callbackData`. + - `opencode_config_update` is limited to the fields OpenCode 2.x can patch (currently `shell`). + +### Changed + +- Select models per session. New sessions are created with the chosen model; passing a model or `variant` for an existing session switches its model for later turns. +- Submit prompts to OpenCode's asynchronous session inbox and correlate completion with the idle marker that closes the submitted turn. Interrupted and failed turns map to `cancelled` and `failed`. +- Questions are backed by OpenCode 2.x forms. Job input still takes one answer array per field in field order and maps it to field keys, option values, booleans, and numbers. +- Cancellation (`opencode_job_cancel`, `opencode_session_abort`) uses OpenCode's session interrupt. `opencode_session_abort` adds an optional `resume` flag. +- `opencode_session_create` accepts optional `agent`, `providerID`, and `modelID`; `opencode_session_permission` accepts an optional `message`. +- Health checks use `GET /api/info`. Events come from the single `/api/event` stream, scoped by directory for project polling and unscoped for global polling. +- `OPENCODE_AUTO_SERVE=true` now spawns `opencode serve` directly with a randomly generated private password instead of using the SDK launcher. +- The 3.1.0 OpenChamber sign-in and `OPENCODE_SERVER_TOKEN` bearer auth carry over and apply to API requests, the `/api/event` stream and the `/api/info` probe. They need OpenChamber 2.0 or newer, which fronts OpenCode 2.x and passes `/api/*` through unchanged. + +### Removed + +- Tools without an OpenCode 2.x equivalent: `opencode_find_symbol`, `opencode_formatter_status`, `opencode_instance_dispose`, `opencode_log`, `opencode_lsp_status`, `opencode_session_init`, `opencode_session_share`, `opencode_session_todo`, `opencode_session_unshare`, `opencode_tool_ids`, and `opencode_tool_list`. +- All nine TUI control tools (`opencode_tui_*`). +- The OpenCode 1.18.31 response-format history-serialization workaround and the `StructuredOutput` permission guidance. +- Tool count: 67 in the full profile (down from 87); the essential profile still exposes 25. + +### Migration notes + +- Upgrade OpenCode to 2.x and restart its server before upgrading the MCP. +- Set `OPENCODE_SERVER_PASSWORD` in the MCP client environment to the server's password, or use `OPENCODE_AUTO_SERVE=true` for a local child server. +- Remove `format` arguments from prompt calls and request the desired output shape in the prompt. Replace calls to removed tools; session todos are no longer exposed. +- Update direct `opencode_question_reply` callers to send `answer: {"": value}`. Use `opencode_question_list` to inspect field keys. `opencode_job_input` responses are unchanged. +- Passing a model on a follow-up now changes the session's model for later turns, not only the current prompt. +- OpenChamber users: upgrade to OpenChamber 2.0 or newer and drop the `/api` suffix from `OPENCODE_BASE_URL`. + ## [3.1.0] - 2026-10-29 This release supports OpenCode 1.x only and was tested with OpenCode 1.19.3. OpenCode 2.x servers are not supported in 3.x; support is planned for 4.0. diff --git a/README.md b/README.md index 711851c..fb79019 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,24 @@ **Delegate coding work to OpenCode from your MCP client.** -opencode-mcp connects Claude, Cursor, VS Code, and other MCP clients to OpenCode's headless API. Ask questions, implement features, monitor background work, respond to questions and permissions, and review changes across projects. +opencode-mcp connects Claude, Cursor, VS Code, and other MCP clients to OpenCode's headless API (OpenCode 2.x). Ask questions, implement features, monitor background work, respond to questions and permissions, and review changes across projects. -> Version 3.x requires **Node.js 22 or newer** and works with **OpenCode 1.x** (tested with 1.18.31 and 1.19.3). Support for OpenCode 2.x is planned for 4.0. Upgrading from 2.x? See the [migration notes](CHANGELOG.md#300---2026-09-16). +> Version 4.x requires an **OpenCode 2.x server** (tested with 2.1.2 and 2.2.0) and **Node.js 22 or newer**. OpenCode 1.x servers are detected and rejected; OpenCode 1.x users stay on 3.x. Upgrading from 3.x? See the [migration notes](CHANGELOG.md#400---2026-10-22). ## Quick Start -Install [OpenCode](https://opencode.ai/docs/) and start its server from your project: +Install [OpenCode](https://opencode.ai/docs/) 2.x and start its server from your project: ```bash -opencode serve --hostname 127.0.0.1 --port 4096 +OPENCODE_SERVER_PASSWORD= opencode serve --hostname 127.0.0.1 --port 4096 ``` -If you use the TUI, start it with `opencode --port 4096` and share that server. Set `OPENCODE_BASE_URL` for another endpoint. +OpenCode 2.x always requires HTTP Basic authentication. Give the MCP the same password through `OPENCODE_SERVER_PASSWORD` (username `opencode`). If you start the server without a password, OpenCode prints a generated one at startup. If you use the TUI, start it with `opencode --port 4096` and share that server. Set `OPENCODE_BASE_URL` for another endpoint. For Claude Code: ```bash -claude mcp add opencode -- npx -y opencode-mcp +claude mcp add opencode --env OPENCODE_SERVER_PASSWORD= -- npx -y opencode-mcp ``` For clients using an `mcpServers` configuration: @@ -33,13 +33,14 @@ For clients using an `mcpServers` configuration: "mcpServers": { "opencode": { "command": "npx", - "args": ["-y", "opencode-mcp"] + "args": ["-y", "opencode-mcp"], + "env": { "OPENCODE_SERVER_PASSWORD": "" } } } } ``` -Restart the client and call `opencode_setup`. Choose a provider from its configured providers, then use `opencode_provider_models` to select a model. Set `OPENCODE_DEFAULT_PROVIDER` and `OPENCODE_DEFAULT_MODEL` together or pass the selected IDs in each prompt call. +Restart the client and call `opencode_setup`. Choose a provider from its configured providers, then use `opencode_provider_models` to select a model. Set `OPENCODE_DEFAULT_PROVIDER` and `OPENCODE_DEFAULT_MODEL` together or pass the selected IDs in prompt calls. OpenCode 2.x selects models per session: passing a model for an existing session switches that session's model for later turns. [Client-specific configuration](docs/configuration.md) includes VS Code, Windsurf, Continue, Zed, and Amazon Q. To test unreleased changes, [build from source](CONTRIBUTING.md) and configure your client to run `node` with the absolute path to `dist/index.js`. @@ -69,7 +70,7 @@ Async results distinguish `accepted`, `running`, `input_required`, `completed`, Modern clients can use the MCP Tasks extension for `opencode_run`. Clients without that extension use ordinary tools, including `opencode_fire` and `opencode_check`. Task status is retrieved by polling; this package does not promise to wake an idle assistant with completion notifications. -Tools retain readable text and provide structured results for clients that consume them. Prompt tools accept optional model variants and OpenCode structured-output formats. See the [generated tools reference](docs/tools.md) and [examples](docs/examples.md). +Tools retain readable text and provide structured results for clients that consume them. Prompt tools accept optional model variants; OpenCode 2.x has no prompt-level structured-output format, so ask for machine-readable output in the prompt text. See the [generated tools reference](docs/tools.md) and [examples](docs/examples.md). ## Multi-Project Use @@ -81,16 +82,16 @@ Independent sessions do not isolate filesystem changes. Use separate project dir ## Configuration -All settings are optional; an OpenCode server must already be running by default. +An OpenCode 2.x server must already be running by default, and its password must be supplied through `OPENCODE_SERVER_PASSWORD` unless the MCP starts the server itself. | Variable | Purpose | |---|---| | `OPENCODE_BASE_URL` | Server endpoint; defaults to `http://127.0.0.1:4096` | -| `OPENCODE_SERVER_USERNAME`, `OPENCODE_SERVER_PASSWORD` | Optional server HTTP authentication | +| `OPENCODE_SERVER_USERNAME`, `OPENCODE_SERVER_PASSWORD` | Server HTTP Basic authentication; username defaults to `opencode`, password must match the server's | | `OPENCODE_SERVER_TOKEN` | Optional bearer token, e.g. an OpenChamber client token | | `OPENCODE_OPENCHAMBER_PASSWORD`, `OPENCODE_OPENCHAMBER_URL` | Sign in to an [OpenChamber](https://github.com/openchamber/openchamber)-fronted server | | `OPENCODE_OPENCHAMBER_CLIENT_ID` | Overrides the per-process OpenChamber client identifier; see [configuration](docs/configuration.md#openchamber) | -| `OPENCODE_AUTO_SERVE` | Set to `true` to opt in to launching a local server | +| `OPENCODE_AUTO_SERVE` | Set to `true` to opt in to launching a local server with a generated private password | | `OPENCODE_DEFAULT_PROVIDER`, `OPENCODE_DEFAULT_MODEL` | Default prompt provider/model pair | | `OPENCODE_TOOL_PROFILE` | `full` (default) or a smaller `essential` tool set | | `OPENCODE_TASK_STORE` | Override the local directory for persisted job records | diff --git a/docs/architecture.md b/docs/architecture.md index c86ecb3..e84ad28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,20 +3,20 @@ ## Overview ```text -MCP client ── stdio ── opencode-mcp ── HTTP / SSE ── OpenCode server +MCP client ── stdio ── opencode-mcp ── HTTP / SSE ── OpenCode 2.x server │ └── local job metadata and result storage ``` -opencode-mcp is a stdio bridge. OpenCode owns coding sessions and model execution. The MCP process owns tool registration, response formatting, job observation, and its local job records. A remote OpenCode server can be used through HTTP; the MCP-facing transport remains stdio. +opencode-mcp is a stdio bridge. OpenCode owns coding sessions and model execution. The MCP process owns tool registration, response formatting, job observation, and its local job records. A remote OpenCode server can be used through HTTP; the MCP-facing transport remains stdio. Version 4.0.0 targets the OpenCode 2.x `/api/*` server API only. ## Main Components | Area | Responsibility | |---|---| | Entry point and MCP adapter | Register the catalog, select a profile, negotiate protocol support, manage stdio | -| `client.ts` | SDK-backed HTTP requests, error handling, bounded retries, SSE, directory routing | -| `server-manager.ts` | Health probe, optional local child startup, owned-child shutdown | +| `client.ts` | HTTP requests through the `@opencode-ai/sdk` v2 client, `{data, location}` envelope unwrapping, error handling, bounded retries, SSE, directory routing | +| `server-manager.ts` | `GET /api/info` health probe, OpenCode 1.x and credential-failure detection, optional local child startup, owned-child shutdown | | `async.ts` | Shared request deadlines, cancellation, and abortable waits | | Job service and job tools | Persist handles/results, correlate completion, observe, cancel, and request required input | | `helpers.ts` | Input helpers, result formatting, redaction, truncation | @@ -27,9 +27,9 @@ The full catalog is available by default. The essential profile advertises a sma ## Async State and Recovery -The async workflow distinguishes submission from completion. A job can be `accepted`, `running`, `input_required`, `completed`, `failed`, `cancelled`, or `unknown`. Completion is correlated with the submitted work and an assistant message; an omitted idle entry in OpenCode's status map is not sufficient evidence by itself. +The async workflow distinguishes submission from completion. A job can be `accepted`, `running`, `input_required`, `completed`, `failed`, `cancelled`, or `unknown`. OpenCode 2.x queues prompts in a session inbox and returns immediately. Completion is correlated with the submitted user message and the idle marker that closes its turn, whose outcome distinguishes completed, failed, and interrupted work; absence from OpenCode's active-session list is not sufficient evidence by itself. -`opencode_fire` returns promptly after dispatch. `opencode_check` observes work; `opencode_wait` and `opencode_run` wait within a bounded observation period. Expiring that period returns progress with a timeout indication. It does not abort the OpenCode session. Requests for permission or answers surface as `input_required` so the caller can respond explicitly. Cancellation of an observation stops waiting; use the job-cancel or session-abort tool to stop remote work. +`opencode_fire` returns promptly after dispatch. `opencode_check` observes work; `opencode_wait` and `opencode_run` wait within a bounded observation period. Expiring that period returns progress with a timeout indication. It does not abort the OpenCode session. Requests for permission or answers surface as `input_required` so the caller can respond explicitly. Cancellation of an observation stops waiting; use the job-cancel or session-abort tool to stop remote work. Both call OpenCode's session interrupt. Local job handles and results persist across MCP restarts, with a default 24-hour retention period measured from creation. Expiring a handle does not cancel the remote session. This supports rediscovery and resumed observation of existing OpenCode work, not checkpointing or restarting model execution. An ambiguous submission is not automatically resubmitted, because that could run a task twice. Backend failures remain errors rather than becoming successful completion. @@ -43,7 +43,7 @@ No task status notifications are advertised. Clients poll explicitly. There is n ### Interactive Input -Job input can present pending permission or question requests through a negotiated modern client interaction. Explicit permission and question tools provide a fallback. Replies are validated and forwarded to OpenCode; the bridge does not approve permissions on the user's behalf. +Job input can present pending permission requests and questions (OpenCode 2.x forms) through a negotiated modern client interaction. Explicit permission and question tools provide a fallback. Replies are validated and forwarded to OpenCode; the bridge does not approve permissions on the user's behalf. ## Request Scope and Lifecycle @@ -51,13 +51,13 @@ Project-scoped calls include an absolute `directory` on the OpenCode server. Val Authentication routes are global. `opencode_project_init` is a separate local-filesystem operation: it accepts `path`, checks protected roots and symlinks, creates the directory if necessary, then opens it through OpenCode. It does not create remote projects. -On startup the process probes the configured endpoint. With automatic startup enabled, the SDK can launch `opencode serve` on a local loopback HTTP endpoint. Concurrent starts are coalesced by endpoint. Shutdown handlers stop only owned child processes; externally managed servers remain running. +On startup the process probes the configured endpoint with `GET /api/info` and HTTP Basic credentials. An OpenCode 1.x server (recognized by its `/global/health` route) is rejected with an upgrade message, and a 401 produces an explicit credentials error. With automatic startup enabled, the MCP spawns `opencode serve` on a local loopback HTTP endpoint with a randomly generated private password and authenticates with it. Concurrent starts are coalesced by endpoint. Shutdown handlers stop only owned child processes; externally managed servers remain running. ## Transport and Observation Costs -HTTP requests share a total deadline across attempts and use cancellation-aware waits. Project event polling forwards the directory; explicitly global event polling rejects a directory to avoid ambiguous scope. Polling preserves partial errors. +HTTP requests share a total deadline across attempts and use cancellation-aware waits. Events come from OpenCode's single `/api/event` stream: project polling forwards the directory, while explicitly global polling omits it and rejects a directory argument to avoid ambiguous scope. Polling preserves partial errors. -Job observation reads bounded recent messages to correlate completion. It uses session summary counts rather than downloading full diffs on each check. This is not a promise of zero network or history reads: the first fresh observation still contacts OpenCode; already persisted terminal results can be returned locally. +Job observation reads bounded recent messages to correlate completion. It uses session metadata rather than downloading full diffs on each check. This is not a promise of zero network or history reads: the first fresh observation still contacts OpenCode; already persisted terminal results can be returned locally. ## Results, Resources, and Prompts @@ -73,6 +73,6 @@ Unit tests cover formatting, validation, job state, and tool behavior. Local HTT ### Shared-session cancellation -OpenCode aborts a whole session. Job cancellation refuses a known newer turn, but another client can submit work between that check and the abort request. Use the default dedicated session per job when cancellation must not affect concurrent work. +OpenCode's interrupt stops a whole session. Job cancellation refuses a known newer turn, but another client can submit work between that check and the interrupt request. Use the default dedicated session per job when cancellation must not affect concurrent work. A native `tasks/cancel` acknowledgement records cancellation intent; it does not prove execution has stopped. Poll `tasks/get` for the eventual state, especially after a lost backend response. diff --git a/docs/configuration.md b/docs/configuration.md index b0e897c..9a87e26 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,13 +2,13 @@ ## Environment Variables -An OpenCode server must already be running unless automatic startup is explicitly enabled. Node.js 22 or newer is required for version 3.0.0 and later. +An OpenCode 2.x server must already be running unless automatic startup is explicitly enabled. Version 4.0.0 requires OpenCode 2.x; a 1.x server is detected at startup and rejected with an upgrade message. Node.js 22 or newer is required for version 3.0.0 and later. | Variable | Default | Description | |---|---|---| | `OPENCODE_BASE_URL` | `http://127.0.0.1:4096` | OpenCode HTTP endpoint | -| `OPENCODE_SERVER_USERNAME` | `opencode` | Username when HTTP authentication is enabled | -| `OPENCODE_SERVER_PASSWORD` | unset | HTTP password; set matching credentials on OpenCode and MCP | +| `OPENCODE_SERVER_USERNAME` | `opencode` | HTTP Basic authentication username | +| `OPENCODE_SERVER_PASSWORD` | unset | HTTP Basic authentication password; must match the external server's password. Ignored for a server started with `OPENCODE_AUTO_SERVE` | | `OPENCODE_SERVER_TOKEN` | unset | Bearer token sent as `Authorization: Bearer `; replaces Basic auth | | `OPENCODE_OPENCHAMBER_PASSWORD` | unset | OpenChamber UI password, exchanged for a client token; see below | | `OPENCODE_OPENCHAMBER_URL` | origin of `OPENCODE_BASE_URL` | OpenChamber origin that serves `/auth/session` | @@ -19,23 +19,27 @@ An OpenCode server must already be running unless automatic startup is explicitl | `OPENCODE_TOOL_PROFILE` | `full` | `full` or `essential`; changes the advertised tool set | | `OPENCODE_TASK_STORE` | see below | Root directory for persisted job records | -Choose provider/model IDs from `opencode_setup` and `opencode_provider_models`. Authentication credentials are global to OpenCode; `directory` does not make provider credentials project-specific. +Choose provider/model IDs from `opencode_setup` and `opencode_provider_models`. OpenCode 2.x stores the model on the session: new sessions are created with the selected model, and passing a model (or `variant`) for an existing session switches it for later turns. Authentication credentials are global to OpenCode; `directory` does not make provider credentials project-specific. -The `essential` profile keeps the common delegation, observation, and required-input workflows available with fewer tool definitions. Select `full` for low-level API tools, TUI control, or provider administration. The profile is a discovery choice, not a security boundary: coding workflows can still modify files or run commands through OpenCode according to its permissions. +### Server Authentication + +OpenCode 2.x always enforces HTTP Basic authentication. For an external server, start it with `OPENCODE_SERVER_PASSWORD` set and give the MCP the same value. If the server was started without one, OpenCode prints a generated password at startup; copy that value into the MCP's `OPENCODE_SERVER_PASSWORD`. A rejected password produces an explicit credentials error (HTTP 401) rather than a generic connection failure. Keep the password in the client's environment or secret settings, not in shared project configuration. + +The `essential` profile keeps the common delegation, observation, and required-input workflows available with fewer tool definitions. Select `full` for low-level API tools or provider administration. The profile is a discovery choice, not a security boundary: coding workflows can still modify files or run commands through OpenCode according to its permissions. Job records hold local operational metadata and results, independent of OpenCode's session storage. The default root is `$XDG_STATE_HOME/opencode-mcp/tasks` (falling back to `~/.local/state/opencode-mcp/tasks`); Windows uses `%LOCALAPPDATA%/opencode-mcp/tasks`. Records are partitioned by server and caller scope. Records expire 24 hours after creation; expiry removes the handle and does not abort the OpenCode session. Keep the task store private to your user account and persist it if the MCP process is ephemeral. See [architecture](architecture.md) for recovery behavior and retention. ## OpenChamber -[OpenChamber](https://github.com/openchamber/openchamber) proxies OpenCode under `/api` and rejects Basic auth. Point `OPENCODE_BASE_URL` at the proxy and choose one credential: +[OpenChamber](https://github.com/openchamber/openchamber) fronts OpenCode and rejects Basic auth. Point `OPENCODE_BASE_URL` at the OpenChamber origin and choose one credential: -> opencode-mcp 3.x speaks the OpenCode 1.x API, so it needs an OpenChamber 1.x release, which fronts OpenCode 1.x. Password sign-in also needs client tokens, added in OpenChamber 1.12, so use 1.12 to 1.24. OpenChamber 2.0 and newer front OpenCode 2.x and pass its `/api/*` routes through unchanged: sign-in succeeds, but the tools then fail against routes they do not know. +> opencode-mcp 4.x speaks the OpenCode 2.x API, so it needs OpenChamber 2.0 or newer, which fronts OpenCode 2.x and passes its `/api/*` routes through unchanged. opencode-mcp 3.x speaks the OpenCode 1.x API and needs OpenChamber 1.12 to 1.24 (1.x proxies OpenCode under `/api`, so 3.x sets `OPENCODE_BASE_URL` to `https://openchamber.example.com/api`). - `OPENCODE_OPENCHAMBER_PASSWORD`: on first use, opencode-mcp posts the UI password to `/auth/session` and receives a trusted-device client token (7 days by default). The token is kept in memory only. When a request is rejected with 401, it signs in once more and resends that request; the rejected request never reached OpenCode, so writes are not duplicated. Each MCP process gets its own client entry in OpenChamber's client list, keyed by a random identifier generated at startup, so independent MCP instances on the same host (e.g. two editor windows) never replace each other's token. Because the identifier is new on every start, each restart adds another trusted-device entry that stays valid until it expires. If your MCP client restarts the server often, set `OPENCODE_OPENCHAMBER_CLIENT_ID` to a stable value so restarts reuse one entry. Use a different value for each concurrent instance, because instances that share a value replace each other's token. - `OPENCODE_SERVER_TOKEN`: a client token you minted yourself. It is not renewed; replace it when it expires or is revoked. ```bash -OPENCODE_BASE_URL=https://openchamber.example.com/api +OPENCODE_BASE_URL=https://openchamber.example.com OPENCODE_OPENCHAMBER_PASSWORD=... # keep in your MCP client's secret store ``` @@ -49,19 +53,19 @@ For project-scoped tools, `directory` must be an absolute path on the OpenCode s ## MCP Client Configurations -These examples launch the published npm package. When testing a source build, replace the command with `node` and the arguments with the absolute path to your built `dist/index.js`. Modern MCP extensions are negotiated; support for tools does not imply that a client supports tasks or interactive input. +These examples launch the published npm package. When testing a source build, replace the command with `node` and the arguments with the absolute path to your built `dist/index.js`. Modern MCP extensions are negotiated; support for tools does not imply that a client supports tasks or interactive input. Unless `OPENCODE_AUTO_SERVE=true`, add `OPENCODE_SERVER_PASSWORD` to each server entry's environment using the client's environment or secret mechanism. ### Claude Code ```bash # Current project, private to your account -claude mcp add opencode -- npx -y opencode-mcp +claude mcp add opencode --env OPENCODE_SERVER_PASSWORD= -- npx -y opencode-mcp # Available across projects -claude mcp add opencode --scope user -- npx -y opencode-mcp +claude mcp add opencode --scope user --env OPENCODE_SERVER_PASSWORD= -- npx -y opencode-mcp # Custom endpoint -claude mcp add opencode --env OPENCODE_BASE_URL=http://127.0.0.1:8080 -- npx -y opencode-mcp +claude mcp add opencode --env OPENCODE_BASE_URL=http://127.0.0.1:8080 --env OPENCODE_SERVER_PASSWORD= -- npx -y opencode-mcp ``` See [Claude Code MCP configuration](https://code.claude.com/docs/en/mcp) for scopes and environment expansion. @@ -76,7 +80,7 @@ Use the following server entry in the client's MCP configuration: "opencode": { "command": "npx", "args": ["-y", "opencode-mcp"], - "env": { "OPENCODE_TOOL_PROFILE": "essential" } + "env": { "OPENCODE_SERVER_PASSWORD": "", "OPENCODE_TOOL_PROFILE": "essential" } } } } @@ -154,9 +158,9 @@ OpenCode can pause a session for permission or a question. Async observation rep |---|---| | `opencode_permission_list` | Inspect pending permission requests in a project | | `opencode_session_permission` | Reply `once`, `always`, or `reject` | -| `opencode_question_list` | Inspect pending questions | -| `opencode_question_reply` | Provide selected answers | -| `opencode_question_reject` | Dismiss a pending question | +| `opencode_question_list` | Inspect pending questions (OpenCode 2.x forms) | +| `opencode_question_reply` | Answer a form with one value per field key | +| `opencode_question_reject` | Dismiss a pending form | When supported by the client, job input can present an interactive request. These tools never silently approve permissions. Set a deliberate permission policy in the project's `opencode.json`; blanket `"permission": "allow"` is an optional policy choice, not a prerequisite for headless use. @@ -165,11 +169,11 @@ When supported by the client, job input can present an interactive request. Thes Start a server explicitly: ```bash -opencode serve --hostname 127.0.0.1 --port 4096 +OPENCODE_SERVER_PASSWORD= opencode serve --hostname 127.0.0.1 --port 4096 ``` -Or share the TUI's server by starting it with `opencode --port 4096`. For custom flags, start OpenCode manually. `OPENCODE_SERVE_ARGS` is unsupported by the SDK launcher. +Or share the TUI's server by starting it with `opencode --port 4096`. For custom flags, start OpenCode manually. `OPENCODE_SERVE_ARGS` is unsupported. -To opt in to a separate local child server, add `"OPENCODE_AUTO_SERVE": "true"` under your MCP server's `env`. Automatic startup accepts loopback HTTP endpoints only and shuts down only the child it launched. An existing external server remains running when the MCP client disconnects. +To opt in to a separate local child server, add `"OPENCODE_AUTO_SERVE": "true"` under your MCP server's `env`. The MCP then runs `opencode serve` itself with a randomly generated private password, so no password configuration is needed. Automatic startup requires `opencode` on the MCP process's PATH, accepts loopback HTTP endpoints only, and shuts down only the child it launched. An existing external server remains running when the MCP client disconnects. When other TUI instances are running, prefer a shared explicitly configured server. [Issue #18](https://github.com/AlaeddineMessadi/opencode-mcp/issues/18) reports hangs when a second server shares OpenCode storage; the underlying cause has not been confirmed. MCP does not scan processes to select a TUI. diff --git a/docs/examples.md b/docs/examples.md index 2d1a488..79513e6 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,6 +1,6 @@ # Usage Examples -These examples describe version 3.1.0. Replace provider/model placeholders with IDs discovered from `opencode_setup` and `opencode_provider_models`. All `directory` values refer to absolute paths on the OpenCode server. +These examples describe version 4.0.0 against an OpenCode 2.x server. Replace provider/model placeholders with IDs discovered from `opencode_setup` and `opencode_provider_models`. All `directory` values refer to absolute paths on the OpenCode server. ## Quick Question and Follow-Up @@ -21,7 +21,7 @@ opencode_reply({ }) ``` -If both default provider/model environment variables are configured, you may omit them from individual calls. An optional `variant` selects an available model variant; use a value supported by the chosen provider/model. +If both default provider/model environment variables are configured, you may omit them from individual calls. An optional `variant` selects an available model variant; use a value supported by the chosen provider/model. OpenCode 2.x stores the model on the session, so a model or variant passed to `opencode_reply` switches the session for later turns as well. ## Background Work and Recovery @@ -44,7 +44,7 @@ opencode_job_get({ jobId: "" }) After an MCP restart, use `opencode_job_list` to rediscover retained jobs. A durable handle helps resume observation; it does not restart a stopped OpenCode server or replay an interrupted prompt. -Use `opencode_job_cancel({jobId: ""})` to request cancellation. For a session without a recorded job, use `opencode_session_abort` with its session ID and directory. +Use `opencode_job_cancel({jobId: ""})` to request cancellation. For a session without a recorded job, use `opencode_session_abort` with its session ID and directory; it calls OpenCode 2.x's session interrupt. ## Wait Without Losing the Handle @@ -82,42 +82,9 @@ opencode_job_input({ }) ``` -Question answers are an array per question, each containing the selected answers. To reject a question, send `{id: "", kind: "question", reject: true}`. Permission replies are `once`, `always`, or `reject`; choose them according to the user's decision. +OpenCode 2.x presents questions as forms. Question answers are an array per form field, in field order, each containing the selected answers; the MCP maps them to the form's field keys and resolves option labels to option values. Boolean fields take `"true"` or `"false"`; number fields take a numeric string. To reject (dismiss) a question, send `{id: "", kind: "question", reject: true}`. Permission replies are `once`, `always`, or `reject`; choose them according to the user's decision. -Lower-level callers can use `opencode_question_list`, `opencode_question_reply({requestId, answers, directory})`, and `opencode_question_reject({requestId, directory})`. Existing `opencode_permission_list` and `opencode_session_permission` remain available. - -## Request Structured Model Output - -```javascript -opencode_run({ - directory: "/home/user/project", - prompt: "Summarize the project using the requested schema", - providerID: "", - modelID: "", - format: { - type: "json_schema", - schema: { - type: "object", - properties: { summary: { type: "string" } }, - required: ["summary"], - additionalProperties: false - }, - retryCount: 2 - } -}) -``` - -`format` is forwarded to OpenCode. `{type: "text"}` requests ordinary text. Model-generated structured output and MCP `structuredContent` serve different purposes: the former shapes the model's answer; the latter carries the tool's machine-readable status and result fields. - -OpenCode produces the answer by calling its `StructuredOutput` tool. If the project's permission policy denies all tools, explicitly allow this tool in `opencode.json`: - -```json -{ "permission": { "*": "deny", "StructuredOutput": "allow" } } -``` - -This policy is suitable for an output-only task. Add permissions for the reads or edits your task needs. The MCP does not change permissions automatically. Denying `StructuredOutput` can leave a JSON-schema prompt running without producing an answer; an observation timeout does not stop inference. Explicitly abort an owned session when abandoning a run. - -OpenCode 1.18.31 can return HTTP 400 when reading history containing a persisted response format. When a job/message ID is known, async observation retries a read of only the latest message and accepts a completed answer only if its parent matches that ID. If it cannot prove completion, it reports `unknown` and does not expose pending inputs from incomplete history. Keep polling the same job rather than resubmitting. Full history and cancellation ownership checks can still fail on the upstream serialization error; use the low-level session abort only after confirming that you own its current work. +Lower-level callers can use `opencode_question_list`, `opencode_question_reply({requestId, answer, directory})`, and `opencode_question_reject({requestId, directory})`. For `opencode_question_reply`, `answer` is an object keyed by form field key, for example `{"": ""}`; multiselect fields take an array of strings. Both tools accept an optional `sessionId`; when omitted, the owning session is looked up from the pending form list. Existing `opencode_permission_list` and `opencode_session_permission` remain available. ## Independent Projects and Parallel Work diff --git a/docs/getting-started.md b/docs/getting-started.md index f9402d0..5c43ec7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -3,27 +3,27 @@ ## Prerequisites - Node.js **22 or newer** for version 3.0.0 and later. -- [OpenCode](https://opencode.ai/docs/) installed and a provider configured for model calls. +- [OpenCode](https://opencode.ai/docs/) **2.x** installed and a provider configured for model calls. Version 4.0.0 rejects OpenCode 1.x servers. - An MCP client capable of launching a stdio server. -These instructions cover version 3.1.0. Review the [migration notes](../CHANGELOG.md#300---2026-09-16) when upgrading from 2.x. +These instructions cover version 4.0.0. Review the [migration notes](../CHANGELOG.md#400---2026-10-22) when upgrading from 3.x. ## 1. Start OpenCode From your project directory: ```bash -opencode serve --hostname 127.0.0.1 --port 4096 +OPENCODE_SERVER_PASSWORD= opencode serve --hostname 127.0.0.1 --port 4096 ``` -Alternatively, share your TUI's server by starting it with `opencode --port 4096`. Automatic startup is disabled by default. Opt in with `OPENCODE_AUTO_SERVE=true` only when you want a separate child server. +OpenCode 2.x always requires HTTP Basic authentication; the MCP needs the same password. Without `OPENCODE_SERVER_PASSWORD`, OpenCode prints a generated password at startup. Alternatively, share your TUI's server by starting it with `opencode --port 4096`. Automatic startup is disabled by default. Opt in with `OPENCODE_AUTO_SERVE=true` only when you want a separate child server; the MCP then generates its own private password and no password setting is needed. ## 2. Add MCP to Your Client For Claude Code: ```bash -claude mcp add opencode -- npx -y opencode-mcp +claude mcp add opencode --env OPENCODE_SERVER_PASSWORD= -- npx -y opencode-mcp ``` Other clients have different configuration shapes. Follow the [client configuration guide](configuration.md); for example VS Code uses `.vscode/mcp.json` with a `servers` object, while Continue uses YAML. @@ -39,7 +39,7 @@ Ask your client to: 3. Call `opencode_context` with the absolute project `directory`. 4. Pass the selected `providerID` and `modelID` when asking a question. -You can instead set both `OPENCODE_DEFAULT_PROVIDER` and `OPENCODE_DEFAULT_MODEL` in the MCP server's environment. +You can instead set both `OPENCODE_DEFAULT_PROVIDER` and `OPENCODE_DEFAULT_MODEL` in the MCP server's environment. OpenCode 2.x keeps the model on the session, so follow-ups reuse it unless you pass a different one. ## 4. Start Work @@ -47,7 +47,7 @@ Use `opencode_ask` for a quick question. For longer coding work, use `opencode_f If the state is `input_required`, inspect the pending permission or question and respond explicitly. A timeout means the observation period ended; continue monitoring instead of submitting the same task again. Once complete, use `opencode_review_changes` to inspect the result. -See [examples](examples.md) for task recovery, structured output, and independent projects. +See [examples](examples.md) for task recovery, required input, and independent projects. ## Troubleshooting @@ -57,7 +57,11 @@ Start OpenCode on the configured port and check `OPENCODE_BASE_URL`. For opt-in ### Unauthorized -Configure matching `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` values on the OpenCode server and MCP process. The username defaults to `opencode`. Do not place credentials in shared project configuration. +OpenCode 2.x always requires authentication. A credentials error (HTTP 401) means `OPENCODE_SERVER_PASSWORD` does not match the server's password. Use the password the server was started with, or the generated one OpenCode printed at startup. The username defaults to `opencode`; change `OPENCODE_SERVER_USERNAME` only if the server uses another. Do not place credentials in shared project configuration. + +### OpenCode 1.x detected + +opencode-mcp 4.x supports only OpenCode 2.x. Upgrade OpenCode, restart its server, and reconnect. To keep using an OpenCode 1.x server, stay on opencode-mcp 3.x. ### Tools missing diff --git a/docs/prompts.md b/docs/prompts.md index 79d94d9..f0637d4 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -21,6 +21,6 @@ When work reports `input_required`, the prompt asks the client to present the pe Use the absolute directory on the OpenCode server for relevant tool calls. These prompt schemas do not contain a directory argument; the client must use the project directory established in the conversation. Do not assume that a previous tool call changed the server's default project. -Provider/model IDs come from discovery or both configured environment defaults. The essential profile supports the common workflow tools, while specialist file/API tools require the full profile. Prompts should only invoke capabilities actually advertised to the client. +Provider/model IDs come from discovery or both configured environment defaults. OpenCode 2.x has no prompt-level response format, so the best-practices prompt advises asking for machine-readable output in the prompt text itself. The essential profile supports the common workflow tools, while specialist file/API tools require the full profile. Prompts should only invoke capabilities actually advertised to the client. See [examples](examples.md) for concrete tool calls and [configuration](configuration.md) for profiles and lifecycle behavior. diff --git a/docs/releasing.md b/docs/releasing.md index 161e899..769f66b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -2,7 +2,7 @@ ## Local smoke checks -Build the package, start a local OpenCode server, then run: +Build the package, start a local OpenCode 2.x server, then run: ```bash npm run build @@ -13,7 +13,7 @@ The runner creates a temporary Git project and a session owned by that test run. No model calls run by default. Tools outside these fixture checks appear as **SKIP**, with a reason; a successful smoke run does not claim coverage of those capabilities. Any unexpected tool error, failed fixture assertion, or failed session cleanup makes the command exit nonzero. Response bodies and server diagnostics are not printed because they can contain credentials or private configuration. -The runner uses a local server because the disposable project must be on the same filesystem as OpenCode. It rejects remote URLs instead of interpreting a local temporary path on another machine. Authentication uses `OPENCODE_SERVER_USERNAME`/`OPENCODE_SERVER_PASSWORD`, `OPENCODE_SERVER_TOKEN`, or `OPENCODE_OPENCHAMBER_PASSWORD` (with optional `OPENCODE_OPENCHAMBER_URL`/`OPENCODE_OPENCHAMBER_CLIENT_ID`), forwarded to the MCP child; keep secrets out of command arguments. `OPENCODE_AUTO_SERVE` is always disabled for this test. +The runner uses a local server because the disposable project must be on the same filesystem as OpenCode. It rejects remote URLs instead of interpreting a local temporary path on another machine. OpenCode 2.x always requires authentication: set `OPENCODE_SERVER_PASSWORD` (and `OPENCODE_SERVER_USERNAME` if not `opencode`) to the local server's credentials, or use `OPENCODE_SERVER_TOKEN` or `OPENCODE_OPENCHAMBER_PASSWORD` (with optional `OPENCODE_OPENCHAMBER_URL`/`OPENCODE_OPENCHAMBER_CLIENT_ID`). They are forwarded to the MCP child; keep secrets out of command arguments. `OPENCODE_AUTO_SERVE` is always disabled for this test. ### Optional inference checks @@ -47,7 +47,7 @@ The runner sends a text-only request through `opencode_fire`, checks progress, w npm audit git diff --check ``` - Confirm the required CI matrix passed on the exact release commit. Record the tested OpenCode version. The default fixture smoke checks should pass; explicitly select a provider/model when validating inference. Record skipped live capabilities accurately. + Confirm the required CI matrix passed on the exact release commit. Record the tested OpenCode 2.x version. The default fixture smoke checks should pass; explicitly select a provider/model when validating inference. Record skipped live capabilities accurately. 4. **Inspect and test the actual archive.** ```bash diff --git a/docs/resources.md b/docs/resources.md index 84a1299..d795575 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -1,6 +1,6 @@ # Resources Reference -Resources expose project data through MCP reads. They are independent of tool calls and return valid JSON, including when large data must be shortened. This server does **not** implement resource subscriptions or change notifications; read a resource again to refresh it. +Resources expose project data through MCP reads. URIs are unchanged in 4.0.0; their data comes from the OpenCode 2.x `/api/*` endpoints, so payload shapes follow OpenCode 2.x. They are independent of tool calls and return valid JSON, including when large data must be shortened. This server does **not** implement resource subscriptions or change notifications; read a resource again to refresh it. ## Static Resources @@ -8,12 +8,12 @@ These URIs read the OpenCode server's default project. Reading a project-scoped | URI | Data | |---|---| -| `opencode://project/current` | Current project | +| `opencode://project/current` | Current location (directory and project) | | `opencode://config` | Configuration with sensitive fields redacted | | `opencode://providers` | Providers and models with sensitive fields redacted | | `opencode://agents` | Available agents | | `opencode://commands` | Available commands | -| `opencode://health` | Server health and version | +| `opencode://health` | Server info and version from `GET /api/info` | | `opencode://vcs` | Version control information returned by OpenCode | | `opencode://sessions` | Sessions in the default project | | `opencode://mcp-servers` | Configured MCP server status | @@ -25,7 +25,7 @@ Use these templates to select a different project without changing server defaul | URI template | Data | |---|---| -| `opencode://projects/{directory}/current` | Project metadata | +| `opencode://projects/{directory}/current` | Location and project metadata | | `opencode://projects/{directory}/sessions` | Project sessions | | `opencode://projects/{directory}/sessions/{sessionId}` | One session | | `opencode://projects/{directory}/sessions/{sessionId}/messages` | Session messages | diff --git a/docs/tools.md b/docs/tools.md index 9798d14..ca9e321 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -2,7 +2,7 @@ -The full profile exposes **87 tools**; the essential profile exposes **25 tools**. This reference is generated from the registered input/output schemas and behavior annotations. +The full profile exposes **67 tools**; the essential profile exposes **25 tools**. This reference is generated from the registered input/output schemas and behavior annotations. Set `OPENCODE_TOOL_PROFILE=essential` for the smaller catalog; `full` is the default. Profile availability is listed for each tool. Profiles control discovery, not OpenCode permissions. @@ -21,22 +21,21 @@ Annotations are behavior hints, not an authorization boundary. Nested structures - [Workflow Tools (13)](#workflow-tools) - [Job Tools (4)](#job-tools) - [Question Tools (3)](#question-tools) -- [Session Tools (20)](#session-tools) +- [Session Tools (16)](#session-tools) - [Message Tools (6)](#message-tools) -- [File & Search Tools (6)](#file--search-tools) +- [File & Search Tools (5)](#file--search-tools) - [Config Tools (3)](#config-tools) - [Provider & Auth Tools (6)](#provider--auth-tools) -- [TUI Control Tools (9)](#tui-control-tools) - [Project Tools (3)](#project-tools) - [Event Tools (1)](#event-tools) - [Global Tools (1)](#global-tools) -- [System & Monitoring Tools (12)](#system--monitoring-tools) +- [System & Monitoring Tools (6)](#system--monitoring-tools) ## Workflow Tools ### `opencode_ask` -Ask OpenCode a question in one step. Creates a new session, sends your prompt, and returns the AI response. This is the easiest way to interact with OpenCode. +Ask OpenCode a question in one step. Creates a new session, sends your prompt, waits for the turn to finish, and returns the AI response. This is the easiest way to interact with OpenCode. **Profiles:** full, essential. @@ -52,8 +51,6 @@ Ask OpenCode a question in one step. Creates a new session, sends your prompt, a | `modelID` | string | no | Model ID (e.g. 'claude-3-5-sonnet-20241022') | | `variant` | string | no | Model variant (e.g. 'fast', 'smart') | | `agent` | string | no | Agent to use (e.g. 'build', 'plan') | -| `system` | string | no | Optional system prompt override | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -97,54 +94,6 @@ Ask OpenCode a question in one step. Creates a new session, sends your prompt, a "description": "Agent to use (e.g. 'build', 'plan')", "type": "string" }, - "system": { - "description": "Optional system prompt override", - "type": "string" - }, - "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "json_schema" - }, - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "retryCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] - }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -180,7 +129,7 @@ Ask OpenCode a question in one step. Creates a new session, sends your prompt, a ### `opencode_check` -Get current task status, pending input, todos and file counts. Uses session summary metadata instead of fetching patches. Pass jobId for exact durable turn correlation; detailed includes response text. +Get current task status, pending input and file counts. Uses session metadata instead of fetching patches. Pass jobId for exact durable turn correlation; detailed includes response text. **Profiles:** full, essential. @@ -293,7 +242,7 @@ Get current task status, pending input, todos and file counts. Uses session summ ### `opencode_context` -Get full project context in one call: current project, path, VCS info, config, and available agents. Useful to understand the current state before starting work. +Get full project context in one call: current location and project, VCS info, config, and available agents. Useful to understand the current state before starting work. **Profiles:** full, essential. @@ -446,7 +395,6 @@ Dispatch a durable background task and return its job, session and message IDs i | `variant` | string | no | Model variant | | `agent` | string | no | OpenCode agent name | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | #### Structured output @@ -503,50 +451,6 @@ Dispatch a durable background task and return its job, session and message IDs i "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" - }, - "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "json_schema" - }, - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "retryCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] } }, "required": [ @@ -618,7 +522,6 @@ Quick-test whether a provider is working. Creates a temporary session, sends a t | `providerId` | string | yes | Provider ID to test (e.g. 'anthropic', 'openrouter') | | `modelID` | string | no | Specific model ID to test. If omitted, discovers the provider default or first available model. | | `variant` | string | no | Model variant | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -650,50 +553,6 @@ Quick-test whether a provider is working. Creates a temporary session, sends a t "description": "Model variant", "type": "string" }, - "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "json_schema" - }, - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "retryCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] - }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -729,7 +588,7 @@ Quick-test whether a provider is working. Creates a temporary session, sends a t ### `opencode_reply` -Send a follow-up message to an existing session. Use this to continue a conversation started with opencode_ask or opencode_session_create. +Send a follow-up message to an existing session and wait for the answer. Use this to continue a conversation started with opencode_ask or opencode_session_create. **Profiles:** full, essential. @@ -745,7 +604,6 @@ Send a follow-up message to an existing session. Use this to continue a conversa | `modelID` | string | no | Model ID | | `variant` | string | no | Model variant | | `agent` | string | no | Agent to use | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -789,50 +647,6 @@ Send a follow-up message to an existing session. Use this to continue a conversa "description": "Agent to use", "type": "string" }, - "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "json_schema" - }, - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "retryCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] - }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -880,7 +694,7 @@ Get a formatted summary of all file changes made in a session. Shows diffs in a | Parameter | Type | Required | Description | |---|---|---|---| | `sessionId` | string | yes | Session ID | -| `messageID` | string | no | Specific message ID to get diff for | +| `messageID` | string | no | User message whose turn to diff (defaults to the newest turn) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -905,7 +719,7 @@ Get a formatted summary of all file changes made in a session. Shows diffs in a "description": "Session ID" }, "messageID": { - "description": "Specific message ID to get diff for", + "description": "User message whose turn to diff (defaults to the newest turn)", "type": "string" }, "directory": { @@ -961,7 +775,6 @@ Send a task and wait for its correlated response. Returns a durable job ID; an o | `variant` | string | no | Model variant | | `agent` | string | no | OpenCode agent name | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `maxDurationSeconds` | number | no | Maximum observation duration in seconds (default 600); timeout does not abort the job; maximum: 3600 | #### Structured output @@ -1020,50 +833,6 @@ Send a task and wait for its correlated response. Returns a durable job ID; an o "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" }, - "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "json_schema" - }, - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "retryCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] - }, "maxDurationSeconds": { "description": "Maximum observation duration in seconds (default 600); timeout does not abort the job", "type": "number", @@ -1799,7 +1568,7 @@ List locally retained jobs in this OpenCode server and credential scope. ### `opencode_question_list` -List pending OpenCode questions, optionally filtered to a session. +List pending OpenCode forms, optionally filtered to a session. **Profiles:** full, essential. @@ -1864,7 +1633,7 @@ List pending OpenCode questions, optionally filtered to a session. ### `opencode_question_reject` -Reject a pending OpenCode question explicitly. +Dismiss a pending OpenCode form explicitly. **Profiles:** full, essential. @@ -1875,6 +1644,7 @@ Reject a pending OpenCode question explicitly. | Parameter | Type | Required | Description | |---|---|---|---| | `requestId` | string | yes | minimum length: 1 | +| `sessionId` | string | no | Session that owns the form; auto-detected from the pending form list when omitted | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -1898,6 +1668,10 @@ Reject a pending OpenCode question explicitly. "type": "string", "minLength": 1 }, + "sessionId": { + "description": "Session that owns the form; auto-detected from the pending form list when omitted", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -1933,7 +1707,7 @@ Reject a pending OpenCode question explicitly. ### `opencode_question_reply` -Answer an OpenCode question request. Supply one array of selected labels or free text per question. +Answer a pending OpenCode form. Supply one value per field, keyed by field key. **Profiles:** full, essential. @@ -1944,7 +1718,8 @@ Answer an OpenCode question request. Supply one array of selected labels or free | Parameter | Type | Required | Description | |---|---|---|---| | `requestId` | string | yes | minimum length: 1 | -| `answers` | array> | yes | | +| `sessionId` | string | no | Session that owns the form; auto-detected from the pending form list when omitted | +| `answer` | object | yes | | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -1968,13 +1743,33 @@ Answer an OpenCode question request. Supply one array of selected labels or free "type": "string", "minLength": 1 }, - "answers": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } + "sessionId": { + "description": "Session that owns the form; auto-detected from the pending form list when omitted", + "type": "string" + }, + "answer": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } }, "directory": { @@ -1984,7 +1779,7 @@ Answer an OpenCode question request. Supply one array of selected labels or free }, "required": [ "requestId", - "answers" + "answer" ], "additionalProperties": false }, @@ -2076,7 +1871,7 @@ List all pending permission requests across all sessions. When a session is bloc ### `opencode_session_abort` -Abort a running session +Interrupt a running session **Profiles:** full, essential. @@ -2086,7 +1881,8 @@ Abort a running session | Parameter | Type | Required | Description | |---|---|---|---| -| `id` | string | yes | Session ID to abort | +| `id` | string | yes | Session ID to interrupt | +| `resume` | "true" \| "false" | no | Whether the session may resume afterwards (default false) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2108,7 +1904,15 @@ Abort a running session "properties": { "id": { "type": "string", - "description": "Session ID to abort" + "description": "Session ID to interrupt" + }, + "resume": { + "description": "Whether the session may resume afterwards (default false)", + "type": "string", + "enum": [ + "true", + "false" + ] }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", @@ -2214,7 +2018,7 @@ Get child sessions of a session ### `opencode_session_create` -Create a new session. Optionally provide a parentID to create a child session, and a title. +Create a new session. Optionally provide a parentID, agent, model, and title. **Profiles:** full, essential. @@ -2226,6 +2030,9 @@ Create a new session. Optionally provide a parentID to create a child session, a |---|---|---|---| | `parentID` | string | no | Parent session ID | | `title` | string | no | Session title | +| `agent` | string | no | Agent ID to start the session with | +| `providerID` | string | no | Provider ID for the session model | +| `modelID` | string | no | Model ID for the session model | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2254,6 +2061,18 @@ Create a new session. Optionally provide a parentID to create a child session, a "description": "Session title", "type": "string" }, + "agent": { + "description": "Agent ID to start the session with", + "type": "string" + }, + "providerID": { + "description": "Provider ID for the session model", + "type": "string" + }, + "modelID": { + "description": "Model ID for the session model", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -2445,7 +2264,7 @@ Fork an existing session, optionally at a specific message | Parameter | Type | Required | Description | |---|---|---|---| | `id` | string | yes | Session ID to fork | -| `messageID` | string | no | Message ID to fork at (optional) | +| `messageID` | string | no | Message ID to fork before (optional) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2470,7 +2289,7 @@ Fork an existing session, optionally at a specific message "description": "Session ID to fork" }, "messageID": { - "description": "Message ID to fork at (optional)", + "description": "Message ID to fork before (optional)", "type": "string" }, "directory": { @@ -2575,98 +2394,6 @@ Get details of a specific session by ID -### `opencode_session_init` - -Analyze the app and create AGENTS.md for a session. NOTE: This is a long-running operation that may take 30-60+ seconds depending on project size. - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `id` | string | yes | Session ID | -| `messageID` | string | yes | Message ID | -| `providerID` | string | yes | Provider ID (e.g. 'anthropic') | -| `modelID` | string | yes | Model ID (e.g. 'claude-3-5-sonnet-20241022') | -| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Session ID" - }, - "messageID": { - "type": "string", - "description": "Message ID" - }, - "providerID": { - "type": "string", - "description": "Provider ID (e.g. 'anthropic')" - }, - "modelID": { - "type": "string", - "description": "Model ID (e.g. 'claude-3-5-sonnet-20241022')" - }, - "variant": { - "description": "Model variant (e.g. 'fast', 'smart')", - "type": "string" - }, - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "required": [ - "id", - "messageID", - "providerID", - "modelID" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- ### `opencode_session_list` List all sessions @@ -2743,6 +2470,7 @@ Respond to a permission request in a session. Use `opencode_permission_list` to | `id` | string | yes | Session ID | | `permissionID` | string | yes | Permission request ID | | `reply` | "once" \| "always" \| "reject" | yes | Response to the permission request: 'once' to approve once, 'always' to auto-approve matching future requests, 'reject' to deny | +| `message` | string | no | Optional message accompanying the decision | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2779,6 +2507,10 @@ Respond to a permission request in a session. Use `opencode_permission_list` to ], "description": "Response to the permission request: 'once' to approve once, 'always' to auto-approve matching future requests, 'reject' to deny" }, + "message": { + "description": "Optional message accompanying the decision", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -2816,7 +2548,7 @@ Respond to a permission request in a session. Use `opencode_permission_list` to ### `opencode_session_revert` -Revert a message in a session +Revert a session to a specific message (stages and commits the revert) **Profiles:** full. @@ -2827,8 +2559,8 @@ Revert a message in a session | Parameter | Type | Required | Description | |---|---|---|---| | `id` | string | yes | Session ID | -| `messageID` | string | yes | Message ID to revert | -| `partID` | string | no | Part ID to revert (optional) | +| `messageID` | string | yes | Message ID to revert to | +| `files` | boolean | no | Also revert file changes (default true) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2854,11 +2586,11 @@ Revert a message in a session }, "messageID": { "type": "string", - "description": "Message ID to revert" + "description": "Message ID to revert to" }, - "partID": { - "description": "Part ID to revert (optional)", - "type": "string" + "files": { + "description": "Also revert file changes (default true)", + "type": "boolean" }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", @@ -2963,19 +2695,18 @@ Search sessions by keyword in title. Useful for finding a specific session among -### `opencode_session_share` +### `opencode_session_status` -Share a session publicly +Get running sessions and the last outcome of each session **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `id` | string | yes | Session ID to share | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2995,18 +2726,11 @@ Share a session publicly "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "id": { - "type": "string", - "description": "Session ID to share" - }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, - "required": [ - "id" - ], "additionalProperties": false }, "outputSchema": { @@ -3032,18 +2756,19 @@ Share a session publicly -### `opencode_session_status` +### `opencode_session_summarize` -Get status for all sessions (running, idle, etc.) +Compact (summarize) a session's history to reclaim context. NOTE: This is a long-running operation. **Profiles:** full. -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| +| `id` | string | yes | Session ID | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3063,11 +2788,18 @@ Get status for all sessions (running, idle, etc.) "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { + "id": { + "type": "string", + "description": "Session ID" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, + "required": [ + "id" + ], "additionalProperties": false }, "outputSchema": { @@ -3093,9 +2825,9 @@ Get status for all sessions (running, idle, etc.) -### `opencode_session_summarize` +### `opencode_session_unrevert` -Summarize a session using a specified model. NOTE: This is a long-running operation that may take 30-60+ seconds. +Discard staged (uncommitted) revert changes in a session **Profiles:** full. @@ -3106,9 +2838,6 @@ Summarize a session using a specified model. NOTE: This is a long-running operat | Parameter | Type | Required | Description | |---|---|---|---| | `id` | string | yes | Session ID | -| `providerID` | string | yes | Provider ID (e.g. 'anthropic') | -| `modelID` | string | yes | Model ID (e.g. 'claude-3-5-sonnet-20241022') | -| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3132,27 +2861,13 @@ Summarize a session using a specified model. NOTE: This is a long-running operat "type": "string", "description": "Session ID" }, - "providerID": { - "type": "string", - "description": "Provider ID (e.g. 'anthropic')" - }, - "modelID": { - "type": "string", - "description": "Model ID (e.g. 'claude-3-5-sonnet-20241022')" - }, - "variant": { - "description": "Model variant (e.g. 'fast', 'smart')", - "type": "string" - }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, "required": [ - "id", - "providerID", - "modelID" + "id" ], "additionalProperties": false }, @@ -3179,19 +2894,20 @@ Summarize a session using a specified model. NOTE: This is a long-running operat -### `opencode_session_todo` +### `opencode_session_update` -Get the todo list for a session +Update session properties (e.g. title) **Profiles:** full. -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| | `id` | string | yes | Session ID | +| `title` | string | no | New title for the session | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3215,6 +2931,10 @@ Get the todo list for a session "type": "string", "description": "Session ID" }, + "title": { + "description": "New title for the session", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -3248,9 +2968,11 @@ Get the todo list for a session -### `opencode_session_unrevert` +## Message Tools + +### `opencode_command_execute` -Restore all reverted messages in a session +Execute a slash command in a session (e.g. /init, /review) **Profiles:** full. @@ -3260,7 +2982,9 @@ Restore all reverted messages in a session | Parameter | Type | Required | Description | |---|---|---|---| -| `id` | string | yes | Session ID | +| `sessionId` | string | yes | Session ID | +| `command` | string | yes | The slash command to execute (e.g. 'init', 'review') | +| `arguments` | string | no | Arguments for the command (sent as the command text) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3280,17 +3004,26 @@ Restore all reverted messages in a session "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "id": { + "sessionId": { "type": "string", "description": "Session ID" }, + "command": { + "type": "string", + "description": "The slash command to execute (e.g. 'init', 'review')" + }, + "arguments": { + "description": "Arguments for the command (sent as the command text)", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, "required": [ - "id" + "sessionId", + "command" ], "additionalProperties": false }, @@ -3317,19 +3050,20 @@ Restore all reverted messages in a session -### `opencode_session_unshare` +### `opencode_message_get` -Unshare a previously shared session +Get details of a specific message in a session **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `id` | string | yes | Session ID to unshare | +| `sessionId` | string | yes | Session ID | +| `messageId` | string | yes | Message ID | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3349,9 +3083,13 @@ Unshare a previously shared session "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "id": { + "sessionId": { + "type": "string", + "description": "Session ID" + }, + "messageId": { "type": "string", - "description": "Session ID to unshare" + "description": "Message ID" }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", @@ -3359,7 +3097,8 @@ Unshare a previously shared session } }, "required": [ - "id" + "sessionId", + "messageId" ], "additionalProperties": false }, @@ -3386,20 +3125,20 @@ Unshare a previously shared session -### `opencode_session_update` +### `opencode_message_list` -Update session properties (e.g. title) +List all messages in a session (newest first) with formatted output showing roles and content **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `id` | string | yes | Session ID | -| `title` | string | no | New title for the session | +| `sessionId` | string | yes | Session ID | +| `limit` | number | no | Maximum number of messages to return | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3419,21 +3158,21 @@ Update session properties (e.g. title) "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "id": { + "sessionId": { "type": "string", "description": "Session ID" }, - "title": { - "description": "New title for the session", - "type": "string" - }, + "limit": { + "description": "Maximum number of messages to return", + "type": "number" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, "required": [ - "id" + "sessionId" ], "additionalProperties": false }, @@ -3460,11 +3199,9 @@ Update session properties (e.g. title) -## Message Tools - -### `opencode_command_execute` +### `opencode_message_send` -Execute a slash command in a session (e.g. /init, /undo, /redo) +Send a prompt to a session and wait for the turn to finish. OpenCode 2.x prompts are queued in the session inbox; this tool waits for the run to complete and returns the assistant's answer. **Profiles:** full. @@ -3475,12 +3212,12 @@ Execute a slash command in a session (e.g. /init, /undo, /redo) | Parameter | Type | Required | Description | |---|---|---|---| | `sessionId` | string | yes | Session ID | -| `command` | string | yes | The slash command to execute (e.g. 'init', 'undo') | -| `arguments` | string | no | Arguments for the command | +| `text` | string | yes | The text message to send | +| `providerID` | string | no | Provider ID (e.g. 'anthropic'); switches the session model when combined with modelID | +| `modelID` | string | no | Model ID (e.g. 'claude-sonnet-4'); switches the session model when combined with providerID | +| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | | `agent` | string | no | Agent to use | -| `providerID` | string | no | Provider ID | -| `modelID` | string | no | Model ID | -| `variant` | string | no | Model variant | +| `timeoutMs` | integer | no | How long to wait for the run to finish (default 300000 ms); minimum: 1000; maximum: 600000 | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3504,30 +3241,32 @@ Execute a slash command in a session (e.g. /init, /undo, /redo) "type": "string", "description": "Session ID" }, - "command": { + "text": { "type": "string", - "description": "The slash command to execute (e.g. 'init', 'undo')" - }, - "arguments": { - "description": "Arguments for the command", - "type": "string" - }, - "agent": { - "description": "Agent to use", - "type": "string" + "description": "The text message to send" }, "providerID": { - "description": "Provider ID", + "description": "Provider ID (e.g. 'anthropic'); switches the session model when combined with modelID", "type": "string" }, "modelID": { - "description": "Model ID", + "description": "Model ID (e.g. 'claude-sonnet-4'); switches the session model when combined with providerID", "type": "string" }, "variant": { - "description": "Model variant", + "description": "Model variant (e.g. 'fast', 'smart')", + "type": "string" + }, + "agent": { + "description": "Agent to use", "type": "string" }, + "timeoutMs": { + "description": "How long to wait for the run to finish (default 300000 ms)", + "type": "integer", + "minimum": 1000, + "maximum": 600000 + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -3535,7 +3274,7 @@ Execute a slash command in a session (e.g. /init, /undo, /redo) }, "required": [ "sessionId", - "command" + "text" ], "additionalProperties": false }, @@ -3562,20 +3301,24 @@ Execute a slash command in a session (e.g. /init, /undo, /redo) -### `opencode_message_get` +### `opencode_message_send_async` -Get details of a specific message in a session +Send a prompt asynchronously and return its messageId. Pass sessionId and messageId to opencode_wait to observe this exact turn. **Profiles:** full. -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| | `sessionId` | string | yes | Session ID | -| `messageId` | string | yes | Message ID | +| `text` | string | yes | The text message to send | +| `providerID` | string | no | Provider ID (e.g. 'anthropic'); switches the session model when combined with modelID | +| `modelID` | string | no | Model ID (e.g. 'claude-sonnet-4'); switches the session model when combined with providerID | +| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | +| `agent` | string | no | Agent to use | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3599,9 +3342,25 @@ Get details of a specific message in a session "type": "string", "description": "Session ID" }, - "messageId": { + "text": { "type": "string", - "description": "Message ID" + "description": "The text message to send" + }, + "providerID": { + "description": "Provider ID (e.g. 'anthropic'); switches the session model when combined with modelID", + "type": "string" + }, + "modelID": { + "description": "Model ID (e.g. 'claude-sonnet-4'); switches the session model when combined with providerID", + "type": "string" + }, + "variant": { + "description": "Model variant (e.g. 'fast', 'smart')", + "type": "string" + }, + "agent": { + "description": "Agent to use", + "type": "string" }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", @@ -3610,7 +3369,7 @@ Get details of a specific message in a session }, "required": [ "sessionId", - "messageId" + "text" ], "additionalProperties": false }, @@ -3637,20 +3396,20 @@ Get details of a specific message in a session -### `opencode_message_list` +### `opencode_shell_execute` -List all messages in a session with formatted output showing roles and content +Run a shell command through the opencode session **Profiles:** full. -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| | `sessionId` | string | yes | Session ID | -| `limit` | number | no | Maximum number of messages to return | +| `command` | string | yes | Shell command to execute | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3674,9 +3433,9 @@ List all messages in a session with formatted output showing roles and content "type": "string", "description": "Session ID" }, - "limit": { - "description": "Maximum number of messages to return", - "type": "number" + "command": { + "type": "string", + "description": "Shell command to execute" }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", @@ -3684,7 +3443,8 @@ List all messages in a session with formatted output showing roles and content } }, "required": [ - "sessionId" + "sessionId", + "command" ], "additionalProperties": false }, @@ -3711,27 +3471,21 @@ List all messages in a session with formatted output showing roles and content -### `opencode_message_send` +## File & Search Tools + +### `opencode_file_list` -Send a prompt message to a session and wait for the AI response. Use parts to send text, and optionally specify a model. +List files and directories at a path **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `sessionId` | string | yes | Session ID | -| `text` | string | yes | The text message to send | -| `providerID` | string | no | Provider ID (e.g. 'anthropic') | -| `modelID` | string | no | Model ID (e.g. 'claude-3-5-sonnet-20241022') | -| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | -| `agent` | string | no | Agent to use | -| `noReply` | boolean | no | If true, inject context without triggering AI response (useful for plugins) | -| `system` | string | no | System prompt override | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `path` | string | no | Relative path to list (defaults to project root) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3751,91 +3505,15 @@ Send a prompt message to a session and wait for the AI response. Use parts to se "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "sessionId": { - "type": "string", - "description": "Session ID" - }, - "text": { - "type": "string", - "description": "The text message to send" - }, - "providerID": { - "description": "Provider ID (e.g. 'anthropic')", - "type": "string" - }, - "modelID": { - "description": "Model ID (e.g. 'claude-3-5-sonnet-20241022')", - "type": "string" - }, - "variant": { - "description": "Model variant (e.g. 'fast', 'smart')", - "type": "string" - }, - "agent": { - "description": "Agent to use", - "type": "string" - }, - "noReply": { - "description": "If true, inject context without triggering AI response (useful for plugins)", - "type": "boolean" - }, - "system": { - "description": "System prompt override", + "path": { + "description": "Relative path to list (defaults to project root)", "type": "string" }, - "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "json_schema" - }, - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "retryCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] - }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, - "required": [ - "sessionId", - "text" - ], "additionalProperties": false }, "outputSchema": { @@ -3861,25 +3539,19 @@ Send a prompt message to a session and wait for the AI response. Use parts to se -### `opencode_message_send_async` +### `opencode_file_read` -Send a prompt asynchronously and return its messageId. Pass sessionId and messageId to opencode_wait to observe this exact turn. +Read the content of a file relative to the project root **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `sessionId` | string | yes | Session ID | -| `text` | string | yes | The text message to send | -| `providerID` | string | no | Provider ID (e.g. 'anthropic') | -| `modelID` | string | no | Model ID (e.g. 'claude-3-5-sonnet-20241022') | -| `variant` | string | no | Model variant (e.g. 'fast', 'smart') | -| `agent` | string | no | Agent to use | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `path` | string | yes | File path to read, relative to the project root | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3899,73 +3571,9 @@ Send a prompt asynchronously and return its messageId. Pass sessionId and messag "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "sessionId": { - "type": "string", - "description": "Session ID" - }, - "text": { + "path": { "type": "string", - "description": "The text message to send" - }, - "providerID": { - "description": "Provider ID (e.g. 'anthropic')", - "type": "string" - }, - "modelID": { - "description": "Model ID (e.g. 'claude-3-5-sonnet-20241022')", - "type": "string" - }, - "variant": { - "description": "Model variant (e.g. 'fast', 'smart')", - "type": "string" - }, - "agent": { - "description": "Agent to use", - "type": "string" - }, - "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "json_schema" - }, - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "retryCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": [ - "type", - "schema" - ], - "additionalProperties": false - } - ] + "description": "File path to read, relative to the project root" }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", @@ -3973,8 +3581,7 @@ Send a prompt asynchronously and return its messageId. Pass sessionId and messag } }, "required": [ - "sessionId", - "text" + "path" ], "additionalProperties": false }, @@ -4001,23 +3608,18 @@ Send a prompt asynchronously and return its messageId. Pass sessionId and messag -### `opencode_shell_execute` +### `opencode_file_status` -Run a shell command through the opencode session +Get status for tracked files (VCS changes: modified, added, deleted) **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `sessionId` | string | yes | Session ID | -| `command` | string | yes | Shell command to execute | -| `agent` | string | yes | Agent to use for the shell command | -| `providerID` | string | no | Provider ID | -| `modelID` | string | no | Model ID | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -4037,36 +3639,11 @@ Run a shell command through the opencode session "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "sessionId": { - "type": "string", - "description": "Session ID" - }, - "command": { - "type": "string", - "description": "Shell command to execute" - }, - "agent": { - "type": "string", - "description": "Agent to use for the shell command" - }, - "providerID": { - "description": "Provider ID", - "type": "string" - }, - "modelID": { - "description": "Model ID", - "type": "string" - }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, - "required": [ - "sessionId", - "command", - "agent" - ], "additionalProperties": false }, "outputSchema": { @@ -4092,11 +3669,9 @@ Run a shell command through the opencode session -## File & Search Tools - -### `opencode_file_list` +### `opencode_find_file` -List files and directories at a path +Find files and directories by name **Profiles:** full. @@ -4106,7 +3681,9 @@ List files and directories at a path | Parameter | Type | Required | Description | |---|---|---|---| -| `path` | string | no | Path to list (defaults to project root) | +| `query` | string | yes | Search string for file/directory names | +| `type` | "file" \| "directory" | no | Limit results to 'file' or 'directory' | +| `limit` | number | no | Max number of results (1-200) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -4126,15 +3703,30 @@ List files and directories at a path "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "path": { - "description": "Path to list (defaults to project root)", - "type": "string" + "query": { + "type": "string", + "description": "Search string for file/directory names" + }, + "type": { + "description": "Limit results to 'file' or 'directory'", + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "limit": { + "description": "Max number of results (1-200)", + "type": "number" }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, + "required": [ + "query" + ], "additionalProperties": false }, "outputSchema": { @@ -4160,9 +3752,9 @@ List files and directories at a path -### `opencode_file_read` +### `opencode_find_text` -Read the content of a file +Search for files and directories whose path matches a pattern. OpenCode 2.x matches names and paths (no line-level text search). **Profiles:** full. @@ -4172,7 +3764,7 @@ Read the content of a file | Parameter | Type | Required | Description | |---|---|---|---| -| `path` | string | yes | File path to read | +| `pattern` | string | yes | Text pattern matched against file and directory paths | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -4192,9 +3784,9 @@ Read the content of a file "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "path": { + "pattern": { "type": "string", - "description": "File path to read" + "description": "Text pattern matched against file and directory paths" }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", @@ -4202,7 +3794,7 @@ Read the content of a file } }, "required": [ - "path" + "pattern" ], "additionalProperties": false }, @@ -4229,9 +3821,11 @@ Read the content of a file -### `opencode_file_status` +## Config Tools + +### `opencode_config_get` -Get status for tracked files (VCS changes: modified, added, deleted, etc.) +Get the current opencode configuration **Profiles:** full. @@ -4290,9 +3884,9 @@ Get status for tracked files (VCS changes: modified, added, deleted, etc.) -### `opencode_find_file` +### `opencode_config_providers` -Find files and directories by name (fuzzy match) +List all configured providers and the default model **Profiles:** full. @@ -4302,10 +3896,6 @@ Find files and directories by name (fuzzy match) | Parameter | Type | Required | Description | |---|---|---|---| -| `query` | string | yes | Search string for file/directory names | -| `type` | "file" \| "directory" | no | Limit results to 'file' or 'directory' | -| `searchDirectory` | string | no | Override the project root for the search | -| `limit` | number | no | Max number of results (1-200) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -4321,1347 +3911,15 @@ Find files and directories by name (fuzzy match) ```json { - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search string for file/directory names" - }, - "type": { - "description": "Limit results to 'file' or 'directory'", - "type": "string", - "enum": [ - "file", - "directory" - ] - }, - "searchDirectory": { - "description": "Override the project root for the search", - "type": "string" - }, - "limit": { - "description": "Max number of results (1-200)", - "type": "number" - }, - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "required": [ - "query" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - - - -### `opencode_find_symbol` - -Find workspace symbols by name (functions, classes, variables, etc.) - -**Profiles:** full. - -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `query` | string | yes | Symbol name to search for | -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Symbol name to search for" - }, - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "required": [ - "query" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_find_text` - -Search for text patterns in project files (regex supported). Returns file paths, line numbers, and matching lines. - -**Profiles:** full. - -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `pattern` | string | yes | Text or regex pattern to search for in files | -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "Text or regex pattern to search for in files" - }, - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "required": [ - "pattern" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -## Config Tools - -### `opencode_config_get` - -Get the current opencode configuration - -**Profiles:** full. - -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_config_providers` - -List all configured providers and their default models - -**Profiles:** full. - -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_config_update` - -Update the opencode configuration. Pass a partial config object with fields to update. - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `config` | object | yes | Partial config object with fields to update | -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "config": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {}, - "description": "Partial config object with fields to update" - }, - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "required": [ - "config" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -## Provider & Auth Tools - -### `opencode_auth_set` - -Set authentication credentials for a provider (e.g. API key). Credentials are stored globally and shared across all projects. - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `providerId` | string | yes | Provider ID (e.g. 'anthropic') | -| `type` | string | yes | Auth type (e.g. 'api') | -| `key` | string | yes | API key or credential value | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "providerId": { - "type": "string", - "description": "Provider ID (e.g. 'anthropic')" - }, - "type": { - "type": "string", - "description": "Auth type (e.g. 'api')" - }, - "key": { - "type": "string", - "description": "API key or credential value" - } - }, - "required": [ - "providerId", - "type", - "key" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_provider_auth_methods` - -Get available authentication methods for all providers - -**Profiles:** full. - -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_provider_list` - -List all configured providers with their connection status. Returns a compact summary — use opencode_provider_models to see models for a specific provider. - -**Profiles:** full, essential. - -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_provider_models` - -List available models for a specific provider. Call opencode_provider_list first to see provider IDs. - -**Profiles:** full, essential. - -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `providerId` | string | yes | Provider ID (e.g. 'anthropic', 'openrouter', 'google') | -| `limit` | number | no | Max models to show (default 30). Use 0 for all. | -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "providerId": { - "type": "string", - "description": "Provider ID (e.g. 'anthropic', 'openrouter', 'google')" - }, - "limit": { - "description": "Max models to show (default 30). Use 0 for all.", - "type": "number" - }, - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "required": [ - "providerId" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_provider_oauth_authorize` - -Start OAuth authorization for a provider - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `providerId` | string | yes | Provider ID to authorize | -| `method` | integer | yes | Auth method index from opencode_provider_auth_methods (default 0); minimum: 0; maximum: 9007199254740991; default: 0 | -| `inputs` | object | no | Additional inputs requested by the selected authentication method | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "providerId": { - "type": "string", - "description": "Provider ID to authorize" - }, - "method": { - "default": 0, - "description": "Auth method index from opencode_provider_auth_methods (default 0)", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "inputs": { - "description": "Additional inputs requested by the selected authentication method", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "providerId", - "method" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_provider_oauth_callback` - -Handle OAuth callback for a provider - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `providerId` | string | yes | Provider ID | -| `callbackData` | object | yes | OAuth callback data | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "providerId": { - "type": "string", - "description": "Provider ID" - }, - "callbackData": { - "type": "object", - "properties": { - "method": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "The method index used for authorization" - }, - "code": { - "description": "Authorization code for methods that request one", - "type": "string" - } - }, - "required": [ - "method" - ], - "additionalProperties": false, - "description": "OAuth callback data" - } - }, - "required": [ - "providerId", - "callbackData" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -## TUI Control Tools - -### `opencode_tui_append_prompt` - -Append text to the TUI's prompt input field - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | Text to append to the prompt | -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Text to append to the prompt" - }, - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_tui_clear_prompt` - -Clear the current prompt text in the TUI - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_tui_execute_command` - -Execute a slash command through the TUI (e.g. '/init', '/undo') - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `command` | string | yes | Command to execute (e.g. '/init') | -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Command to execute (e.g. '/init')" - }, - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "required": [ - "command" - ], - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_tui_open_help` - -Open the help dialog in the TUI - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_tui_open_models` - -Open the model selector in the TUI - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_tui_open_sessions` - -Open the session selector in the TUI - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_tui_open_themes` - -Open the theme selector in the TUI - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" - } - }, - "additionalProperties": false - }, - "outputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "isError": { - "type": "boolean" - }, - "data": {} - }, - "required": [ - "text", - "isError" - ], - "additionalProperties": {} - } -} -``` - -
- -### `opencode_tui_show_toast` - -Show a toast notification in the TUI - -**Profiles:** full. - -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. - -#### Parameters - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `message` | string | yes | Toast message text | -| `title` | string | no | Optional toast title | -| `variant` | "info" \| "success" \| "warning" \| "error" | no | Toast variant (default: info) | -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | - -#### Structured output - -| Field | Type | Required | Description | -|---|---|---|---| -| `text` | string | yes | | -| `isError` | boolean | yes | | -| `data` | unknown | no | | - -
-Complete input and output JSON schemas - -```json -{ - "inputSchema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Toast message text" - }, - "title": { - "description": "Optional toast title", - "type": "string" - }, - "variant": { - "description": "Toast variant (default: info)", - "type": "string", - "enum": [ - "info", - "success", - "warning", - "error" - ] - }, + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, - "required": [ - "message" - ], "additionalProperties": false }, "outputSchema": { @@ -5687,9 +3945,9 @@ Show a toast notification in the TUI
-### `opencode_tui_submit_prompt` +### `opencode_config_update` -Submit the current prompt in the TUI (equivalent to pressing Enter) +Update opencode configuration. OpenCode 2.x supports patching the default shell (`shell` field); pass a partial object with fields to update. **Profiles:** full. @@ -5699,6 +3957,7 @@ Submit the current prompt in the TUI (equivalent to pressing Enter) | Parameter | Type | Required | Description | |---|---|---|---| +| `config` | object | yes | Partial config object with fields to update (2.x servers support `shell`) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -5718,11 +3977,22 @@ Submit the current prompt in the TUI (equivalent to pressing Enter) "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "Partial config object with fields to update (2.x servers support `shell`)" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, + "required": [ + "config" + ], "additionalProperties": false }, "outputSchema": { @@ -5748,21 +4018,23 @@ Submit the current prompt in the TUI (equivalent to pressing Enter) -## Project Tools +## Provider & Auth Tools -### `opencode_project_current` +### `opencode_auth_set` -Get the current active project +Set an API key credential for a provider's integration. Credentials are stored globally and shared across all projects. **Profiles:** full. -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | +| `providerId` | string | yes | Provider/integration ID (e.g. 'anthropic') | +| `key` | string | yes | API key value | +| `label` | string | no | Optional label for the stored credential | #### Structured output @@ -5781,11 +4053,23 @@ Get the current active project "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "providerId": { + "type": "string", + "description": "Provider/integration ID (e.g. 'anthropic')" + }, + "key": { + "type": "string", + "description": "API key value" + }, + "label": { + "description": "Optional label for the stored credential", "type": "string" } }, + "required": [ + "providerId", + "key" + ], "additionalProperties": false }, "outputSchema": { @@ -5811,19 +4095,19 @@ Get the current active project -### `opencode_project_init` +### `opencode_provider_auth_methods` -Initialize or open a project directory to host an independent OpenCode session. Use this to create new empty folders, or to explicitly open preexisting projects on the MCP host machine for parallel code generation workloads. This tool operates on the local filesystem; create remote server directories separately. +List integrations and the connection methods they support (API key, environment variable, OAuth, command). Provider IDs map to integration IDs. **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `path` | string | yes | The absolute file path where the project directory is located or should be created. | +| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -5842,14 +4126,11 @@ Initialize or open a project directory to host an independent OpenCode session. "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "path": { - "type": "string", - "description": "The absolute file path where the project directory is located or should be created." + "directory": { + "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "type": "string" } }, - "required": [ - "path" - ], "additionalProperties": false }, "outputSchema": { @@ -5875,11 +4156,11 @@ Initialize or open a project directory to host an independent OpenCode session. -### `opencode_project_list` +### `opencode_provider_list` -List all projects known to the opencode server +List all providers with their configuration status. Returns a compact summary — use opencode_provider_models to see models for a specific provider. -**Profiles:** full. +**Profiles:** full, essential. **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. @@ -5936,13 +4217,11 @@ List all projects known to the opencode server -## Event Tools - -### `opencode_events_poll` +### `opencode_provider_models` -Poll project events from OpenCode, or explicitly select global scope. Collects up to maxEvents within the duration. Connection failures are reported with any partial events; stopping observation does not abort remote work. +List available models for a specific provider. Call opencode_provider_list first to see provider IDs. -**Profiles:** full. +**Profiles:** full, essential. **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. @@ -5950,10 +4229,9 @@ Poll project events from OpenCode, or explicitly select global scope. Collects u | Parameter | Type | Required | Description | |---|---|---|---| -| `durationMs` | integer | no | How long to collect events in milliseconds (default: 3000, max: 30000); minimum: 1; maximum: 30000 | -| `maxEvents` | integer | no | Maximum number of events to collect (default: 50, max: 1000); minimum: 1; maximum: 1000 | +| `providerId` | string | yes | Provider ID (e.g. 'anthropic', 'openrouter', 'google') | +| `limit` | number | no | Max models to show (default 30). Use 0 for all. | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | -| `scope` | "project" \| "global" | no | Event scope (default: project). Global events include all projects and cannot be combined with directory. | #### Structured output @@ -5972,31 +4250,22 @@ Poll project events from OpenCode, or explicitly select global scope. Collects u "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "durationMs": { - "description": "How long to collect events in milliseconds (default: 3000, max: 30000)", - "type": "integer", - "minimum": 1, - "maximum": 30000 + "providerId": { + "type": "string", + "description": "Provider ID (e.g. 'anthropic', 'openrouter', 'google')" }, - "maxEvents": { - "description": "Maximum number of events to collect (default: 50, max: 1000)", - "type": "integer", - "minimum": 1, - "maximum": 1000 + "limit": { + "description": "Max models to show (default 30). Use 0 for all.", + "type": "number" }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" - }, - "scope": { - "description": "Event scope (default: project). Global events include all projects and cannot be combined with directory.", - "type": "string", - "enum": [ - "project", - "global" - ] } }, + "required": [ + "providerId" + ], "additionalProperties": false }, "outputSchema": { @@ -6022,21 +4291,20 @@ Poll project events from OpenCode, or explicitly select global scope. Collects u -## Global Tools - -### `opencode_health` +### `opencode_provider_oauth_authorize` -Check server health and version +Start OAuth authorization for a provider's integration **Profiles:** full. -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | +| `providerId` | string | yes | Provider/integration ID to authorize | +| `methodID` | string | no | Specific method ID to use (defaults to the first OAuth method) | #### Structured output @@ -6055,11 +4323,18 @@ Check server health and version "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "providerId": { + "type": "string", + "description": "Provider/integration ID to authorize" + }, + "methodID": { + "description": "Specific method ID to use (defaults to the first OAuth method)", "type": "string" } }, + "required": [ + "providerId" + ], "additionalProperties": false }, "outputSchema": { @@ -6085,21 +4360,21 @@ Check server health and version -## System & Monitoring Tools - -### `opencode_agent_list` +### `opencode_provider_oauth_callback` -List all available agents with their names, descriptions, and modes (primary/subagent) +Complete OAuth authorization for a provider's integration with the callback code **Profiles:** full. -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | +| `providerId` | string | yes | Provider/integration ID | +| `attemptID` | string | yes | Authorization attempt ID returned by opencode_provider_oauth_authorize | +| `code` | string | no | Authorization code (when the redirect supplies one) | #### Structured output @@ -6118,11 +4393,23 @@ List all available agents with their names, descriptions, and modes (primary/sub "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", + "providerId": { + "type": "string", + "description": "Provider/integration ID" + }, + "attemptID": { + "type": "string", + "description": "Authorization attempt ID returned by opencode_provider_oauth_authorize" + }, + "code": { + "description": "Authorization code (when the redirect supplies one)", "type": "string" } }, + "required": [ + "providerId", + "attemptID" + ], "additionalProperties": false }, "outputSchema": { @@ -6148,9 +4435,11 @@ List all available agents with their names, descriptions, and modes (primary/sub -### `opencode_command_list` +## Project Tools -List all available commands (built-in and custom slash commands) +### `opencode_project_current` + +Get the current active project **Profiles:** full. @@ -6209,19 +4498,19 @@ List all available commands (built-in and custom slash commands) -### `opencode_formatter_status` +### `opencode_project_init` -Get the status of configured formatters +Initialize or open a project directory to host an independent OpenCode session. Use this to create new empty folders, or to explicitly open preexisting projects on the MCP host machine for parallel code generation workloads. This tool operates on the local filesystem; create remote server directories separately. **Profiles:** full. -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | +| `path` | string | yes | The absolute file path where the project directory is located or should be created. | #### Structured output @@ -6240,11 +4529,14 @@ Get the status of configured formatters "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "directory": { - "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", - "type": "string" + "path": { + "type": "string", + "description": "The absolute file path where the project directory is located or should be created." } }, + "required": [ + "path" + ], "additionalProperties": false }, "outputSchema": { @@ -6270,13 +4562,13 @@ Get the status of configured formatters -### `opencode_instance_dispose` +### `opencode_project_list` -Dispose the current opencode instance (shuts it down). WARNING: This is destructive and will terminate the server. +List all projects known to the opencode server **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6331,23 +4623,24 @@ Dispose the current opencode instance (shuts it down). WARNING: This is destruct -### `opencode_log` +## Event Tools + +### `opencode_events_poll` -Write a log entry to the opencode server +Poll project events from OpenCode, or explicitly select global scope. Collects up to maxEvents within the duration. Connection failures are reported with any partial events; stopping observation does not abort remote work. **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `service` | string | yes | Service name for the log entry | -| `level` | "debug" \| "info" \| "warn" \| "error" | yes | Log level | -| `message` | string | yes | Log message | -| `extra` | object | no | Extra data to include in the log entry | +| `durationMs` | integer | no | How long to collect events in milliseconds (default: 3000, max: 30000); minimum: 1; maximum: 30000 | +| `maxEvents` | integer | no | Maximum number of events to collect (default: 50, max: 1000); minimum: 1; maximum: 1000 | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | +| `scope` | "project" \| "global" | no | Event scope (default: project). Global events include all projects and cannot be combined with directory. | #### Structured output @@ -6366,42 +4659,31 @@ Write a log entry to the opencode server "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "service": { - "type": "string", - "description": "Service name for the log entry" - }, - "level": { - "type": "string", - "enum": [ - "debug", - "info", - "warn", - "error" - ], - "description": "Log level" - }, - "message": { - "type": "string", - "description": "Log message" + "durationMs": { + "description": "How long to collect events in milliseconds (default: 3000, max: 30000)", + "type": "integer", + "minimum": 1, + "maximum": 30000 }, - "extra": { - "description": "Extra data to include in the log entry", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "maxEvents": { + "description": "Maximum number of events to collect (default: 50, max: 1000)", + "type": "integer", + "minimum": 1, + "maximum": 1000 }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" + }, + "scope": { + "description": "Event scope (default: project). Global events include all projects and cannot be combined with directory.", + "type": "string", + "enum": [ + "project", + "global" + ] } }, - "required": [ - "service", - "level", - "message" - ], "additionalProperties": false }, "outputSchema": { @@ -6427,9 +4709,11 @@ Write a log entry to the opencode server -### `opencode_lsp_status` +## Global Tools + +### `opencode_health` -Get the status of LSP (Language Server Protocol) servers +Check OpenCode 2.x server health and version **Profiles:** full. @@ -6488,20 +4772,20 @@ Get the status of LSP (Language Server Protocol) servers -### `opencode_mcp_add` +## System & Monitoring Tools + +### `opencode_agent_list` -Add an MCP server dynamically to opencode +List all available agents with their names, descriptions, and modes (primary/subagent) **Profiles:** full. -**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| -| `name` | string | yes | Name for the MCP server | -| `config` | object | yes | MCP server configuration object | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -6521,27 +4805,11 @@ Add an MCP server dynamically to opencode "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "name": { - "type": "string", - "description": "Name for the MCP server" - }, - "config": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {}, - "description": "MCP server configuration object" - }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, - "required": [ - "name", - "config" - ], "additionalProperties": false }, "outputSchema": { @@ -6567,9 +4835,9 @@ Add an MCP server dynamically to opencode -### `opencode_mcp_status` +### `opencode_command_list` -Get the status of all MCP servers configured in opencode +List all available commands (built-in and custom slash commands) **Profiles:** full. @@ -6628,18 +4896,20 @@ Get the status of all MCP servers configured in opencode -### `opencode_path_get` +### `opencode_mcp_add` -Get the current working path of the opencode server +Add or update an MCP server configuration in opencode **Profiles:** full. -**Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. +**Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| +| `name` | string | yes | Name for the MCP server | +| `config` | object | yes | MCP server configuration object (e.g. {type: 'local', command: [...]} or {type: 'remote', url: '...'}) | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -6659,11 +4929,27 @@ Get the current working path of the opencode server "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { + "name": { + "type": "string", + "description": "Name for the MCP server" + }, + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "description": "MCP server configuration object (e.g. {type: 'local', command: [...]} or {type: 'remote', url: '...'})" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, + "required": [ + "name", + "config" + ], "additionalProperties": false }, "outputSchema": { @@ -6689,9 +4975,9 @@ Get the current working path of the opencode server -### `opencode_tool_ids` +### `opencode_mcp_status` -List all available tool IDs that the LLM can use (experimental) +Get the status of all MCP servers configured in opencode **Profiles:** full. @@ -6750,9 +5036,9 @@ List all available tool IDs that the LLM can use (experimental) -### `opencode_tool_list` +### `opencode_path_get` -List tools with JSON schemas for a given provider and model (experimental) +Get the active directory and project of the OpenCode server **Profiles:** full. @@ -6762,8 +5048,6 @@ List tools with JSON schemas for a given provider and model (experimental) | Parameter | Type | Required | Description | |---|---|---|---| -| `provider` | string | yes | Provider ID | -| `model` | string | yes | Model ID | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -6783,23 +5067,11 @@ List tools with JSON schemas for a given provider and model (experimental) "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "provider": { - "type": "string", - "description": "Provider ID" - }, - "model": { - "type": "string", - "description": "Model ID" - }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, - "required": [ - "provider", - "model" - ], "additionalProperties": false }, "outputSchema": { @@ -6827,7 +5099,7 @@ List tools with JSON schemas for a given provider and model (experimental) ### `opencode_vcs_info` -Get VCS (version control) info for the current project (branch, remote, status) +Get VCS (version control) info for the current project (provider, branches) **Profiles:** full. diff --git a/package-lock.json b/package-lock.json index ac3ab11..9fb13a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "opencode-mcp", - "version": "3.1.0", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-mcp", - "version": "3.1.0", + "version": "4.0.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/core": "^2.4.0", "@modelcontextprotocol/server": "^2.4.0", - "@opencode-ai/sdk": "^1.18.31", + "@opencode-ai/sdk": "^1.19.3", "zod": "^4.6.6" }, "bin": { @@ -216,9 +216,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.18.31", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.31.tgz", - "integrity": "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w==", + "version": "1.19.3", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.19.3.tgz", + "integrity": "sha512-G63bZD2r6plqgjFdenWVj3HBFWmLQSVuG9CXMQuTjakzA5aL6wFLmI23Biz5ESE4lQQ08UTHcY2N4NOShJFb2w==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" diff --git a/package.json b/package.json index 61fdb18..a2886d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-mcp", - "version": "3.1.0", + "version": "4.0.0", "description": "MCP server for OpenCode coding workflows, durable async jobs, project resources, and explicit user input", "type": "module", "main": "dist/index.js", @@ -56,7 +56,7 @@ "dependencies": { "@modelcontextprotocol/core": "^2.4.0", "@modelcontextprotocol/server": "^2.4.0", - "@opencode-ai/sdk": "^1.18.31", + "@opencode-ai/sdk": "^1.19.3", "zod": "^4.6.6" }, "devDependencies": { diff --git a/scripts/generate-tool-docs.mjs b/scripts/generate-tool-docs.mjs index 4863f2e..4d11ba4 100644 --- a/scripts/generate-tool-docs.mjs +++ b/scripts/generate-tool-docs.mjs @@ -21,7 +21,6 @@ const groups = [ ["File & Search Tools", (n) => /^opencode_(file_|find_)/.test(n)], ["Config Tools", (n) => n.startsWith("opencode_config_")], ["Provider & Auth Tools", (n) => /^opencode_(provider_|auth_)/.test(n)], - ["TUI Control Tools", (n) => n.startsWith("opencode_tui_")], ["Project Tools", (n) => n.startsWith("opencode_project_")], ["Event Tools", (n) => n.startsWith("opencode_events_")], ["Global Tools", (n) => n === "opencode_health"], diff --git a/scripts/mcp-smoke-test.mjs b/scripts/mcp-smoke-test.mjs index bb40ca4..be8d78c 100644 --- a/scripts/mcp-smoke-test.mjs +++ b/scripts/mcp-smoke-test.mjs @@ -3,10 +3,11 @@ // Live smoke checks. Import runSmoke for deterministic injected-client tests. // The legacy client deliberately verifies interoperability with MCP v1 clients. import process from "node:process"; -import { mkdtemp, realpath, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; @@ -56,6 +57,26 @@ export function smokeEnvironment(options, env = process.env) { return childEnv; } +/** + * Best-effort removal of the disposable fixture. Two Windows-only races can + * hold handles on the tree: the MCP child may still be tearing down, and the + * OpenCode server itself keeps a project-watcher handle on every directory + * the run touched — an open handle blocks recursive deletion with EBUSY + * until that process exits (POSIX unlink is unaffected, so this only shows + * up on Windows). fs.rm's built-in retries absorb the transient case; when a + * still-running shared server holds the directory, report the path (never + * server output) and keep the exit code tied to the smoke results instead of + * crashing a passing run. + */ +async function removeFixture(directory) { + try { + await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + } catch (error) { + const code = error && typeof error.code === "string" ? error.code : "unknown"; + console.error(`Warning: could not remove the disposable smoke fixture (${code}). Remove ${directory} manually.`); + } +} + export async function createFixture() { const directory = await realpath(await mkdtemp(join(tmpdir(), "opencode-mcp-smoke-"))); try { @@ -64,9 +85,9 @@ export async function createFixture() { await writeFile(join(directory, "opencode.json"), JSON.stringify({ permission: "deny" })); execFileSync("git", ["-c", "init.templateDir=", "init", "--quiet", directory], { stdio: "pipe" }); execFileSync("git", ["-C", directory, "add", "README.md", "opencode.json"], { stdio: "pipe" }); - return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) }; + return { directory, cleanup: () => removeFixture(directory) }; } catch { - await rm(directory, { recursive: true, force: true }); + await removeFixture(directory); throw new Error("Could not create disposable smoke project (Git must be installed)"); } } @@ -132,13 +153,12 @@ export async function runSmoke({ client, directory, inference = false, providerI await call("opencode_file_list", { path: "." }, (r) => textOf(r).includes("README.md")); await call("opencode_file_read", { path: "README.md" }, (r) => textOf(r).includes(FIXTURE_MARKER)); await call("opencode_find_file", { query: "README.md" }, (r) => textOf(r).includes("README.md")); - await call("opencode_find_text", { pattern: FIXTURE_MARKER }, (r) => textOf(r).includes(FIXTURE_MARKER) && !textOf(r).startsWith("No matches")); + await call("opencode_find_text", { pattern: "README" }, (r) => textOf(r).includes("README.md")); const created = await call("opencode_session_create", { title: "[mcp-smoke] disposable fixture" }, (r) => Boolean(createdSessionId(r))); const sessionId = createdSessionId(created); if (sessionId) { owned.add(sessionId); await call("opencode_session_get", { id: sessionId }); - await call("opencode_session_todo", { id: sessionId }); await call("opencode_message_list", { sessionId }); await call("opencode_conversation", { sessionId }); await call("opencode_check", { sessionId }); @@ -196,7 +216,27 @@ export async function main(argv = process.argv.slice(2), env = process.env) { } } -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { +/** + * The script can be invoked through a different path spelling than ESM + * reports as its module URL (drive junctions or substituted drives on + * Windows, symlinks elsewhere): ESM resolves module URLs to the real path + * while `resolve(argv[1])` keeps the caller's spelling, so a naive URL + * comparison never matches — and the script silently exits 0 without + * running anything. Compare real filesystem paths instead, tolerating + * Windows case differences. + */ +function invokedDirectly() { + if (!process.argv[1]) return false; + try { + const invoked = realpathSync(resolve(process.argv[1])); + const self = realpathSync(fileURLToPath(import.meta.url)); + return process.platform === "win32" ? invoked.toLowerCase() === self.toLowerCase() : invoked === self; + } catch { + return false; + } +} + +if (invokedDirectly()) { main().then((code) => { process.exitCode = code; }).catch(() => { console.error("Smoke checks could not start. Check the local server, credentials, Git, CLI options, and build output."); process.exitCode = 1; diff --git a/src/app.ts b/src/app.ts index 26c8a1e..d81024c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,7 +13,6 @@ import { registerFileTools } from "./tools/file.js"; import { registerProviderTools } from "./tools/provider.js"; import { registerMiscTools } from "./tools/misc.js"; import { registerWorkflowTools } from "./tools/workflow.js"; -import { registerTuiTools } from "./tools/tui.js"; import { registerEventTools } from "./tools/events.js"; // Resources and prompts @@ -22,7 +21,7 @@ import { registerPrompts } from "./prompts.js"; /** Build registrations without connecting or performing network/filesystem I/O. */ export function createServer(client: OpenCodeClient, jobs: JobService, profile: ToolProfile = "full") { - const server = new McpServer({ name: "opencode-mcp", version: "3.1.0", + const server = new McpServer({ name: "opencode-mcp", version: "4.0.0", description: "Delegate coding work to OpenCode with durable jobs, explicit input, and project-scoped tools." }, { capabilities: { extensions: { [TASKS_EXTENSION]: {} } }, cacheHints: { "tools/list": { ttlMs: 60000, cacheScope: "private" }, "prompts/list": { ttlMs: 60000, cacheScope: "private" }, @@ -55,9 +54,6 @@ registerMiscTools(server, client); // ── High-level workflow tools ─────────────────────────────────────── registerWorkflowTools(server, client, jobs); -// ── TUI control ───────────────────────────────────────────────────── -registerTuiTools(server, client); - // ── Event streaming ───────────────────────────────────────────────── registerEventTools(server, client); diff --git a/src/client.ts b/src/client.ts index 6d55c35..8ebb3cb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,6 @@ -import { createOpencodeClient, OpencodeClient as NativeClient } from "@opencode-ai/sdk"; +import { createOpencodeClient, OpencodeClient as NativeClient } from "@opencode-ai/sdk/v2/client"; import { normalizeDirectory } from "./helpers.js"; -import { ensureServer, isServerRunning } from "./server-manager.js"; +import { ensureServer, isServerRunning, type ServerStatus } from "./server-manager.js"; import { AuthProvider } from "./auth.js"; import { createRequestContext, abortableSleep, throwIfAborted, withAbort, type RequestOptions } from "./async.js"; export type { RequestOptions } from "./async.js"; @@ -20,6 +20,23 @@ export interface OpenCodeClientOptions { autoServe?: boolean; } +/** + * OpenCode 2.x wraps most `/api/*` JSON payloads in an envelope of the form + * `{ data: ... }` (location-scoped endpoints add `location`, paginated ones add + * `cursor`). The MCP tools consume the inner payload, so unwrap it here once; + * pagination cursors are dropped. + */ +function unwrapV2Envelope(path: string, body: T): T { + if (typeof path !== "string" || !path.startsWith("/api/") || body === null || typeof body !== "object" || Array.isArray(body)) return body; + // Raw file reads return arbitrary JSON bodies; a file containing {"data": ...} + // must not be mistaken for an envelope. + if (path.startsWith("/api/fs/read/")) return body; + const keys = Object.keys(body as Record); + if (keys.length === 0 || keys.length > 3 || !keys.includes("data")) return body; + if (keys.some((key) => key !== "data" && key !== "location" && key !== "cursor")) return body; + return (body as unknown as { data: T }).data; +} + export class OpenCodeError extends Error { constructor( message: string, @@ -94,13 +111,37 @@ export class OpenCodeClient { this.api = this.buildSdkClient(); } + /** + * Target a server started by this process (auto-serve). It only accepts its + * generated Basic password, so that replaces any configured token or sign-in. + */ + adoptServer(url: string, password?: string): void { + this.baseUrl = url.replace(/\/$/, ""); + if (password) { + this.password = password; + this.auth = new AuthProvider({ username: this.username, password }, this.baseUrl); + } + this.api = this.buildSdkClient(); + } + + /** Startup: find (or, with auto-serve, start) the server and adopt a generated password. */ + async connect(): Promise { + const status = await ensureServer({ + baseUrl: this.baseUrl, autoServe: this.autoServe, username: this.username, password: this.password, + authorization: await this.auth.getHeader(), + }); + if (status.password) this.adoptServer(status.url ?? this.baseUrl, status.password); + return status; + } + /** * Rebuild the SDK client against the current `baseUrl` + auth state. Used * by the constructor and by the reconnect path when `ensureServer()` * surfaces a different URL than the one we were aiming at. */ private buildSdkClient(): NativeClient { - return createOpencodeClient({ baseUrl: this.baseUrl, fetch: (request: Request) => this.auth.fetch(request) }); + // The v2 SDK types `fetch` as `typeof fetch` but always calls it with a single Request. + return createOpencodeClient({ baseUrl: this.baseUrl, fetch: ((request: Request) => this.auth.fetch(request)) as typeof fetch }); } getBaseUrl(): string { @@ -115,7 +156,7 @@ export class OpenCodeClient { private async request( method: string, path: string, - opts: RequestOptions & { query?: Record; body?: unknown; directory?: string } = {}, + opts: RequestOptions & { query?: Record; body?: unknown; directory?: string; envelope?: boolean } = {}, ): Promise { // Validate before submission. A malformed local argument cannot be an // ambiguous remote mutation and must never trigger retries. @@ -130,7 +171,7 @@ export class OpenCodeClient { throwIfAborted(context.signal); if (attempt > 0) await abortableSleep(BASE_DELAY_MS * Math.pow(2, attempt - 1), context.signal); try { - const apiClient = (this.api as any)._client; + const apiClient = (this.api as any).client; submitted = true; const res = await withAbort(apiClient[method.toLowerCase()]({ url: path, query: opts.query, @@ -139,13 +180,20 @@ export class OpenCodeClient { }), context.signal) as any; // The SDK may return an error object after fetch was aborted. throwIfAborted(context.signal); - if (res.error || (res.response && !res.response.ok && res.response.status >= 400)) { - const status = res.response?.status || 500; + if (res.response && !res.response.ok && res.response.status >= 400) { + // The server answered with a definitive rejection. + const status = res.response.status; const bodyStr = typeof res.error === "string" ? res.error : JSON.stringify(res.error ?? {}); throw new OpenCodeError(`${method} ${path} failed (${status}): ${bodyStr}`, status, method, path, bodyStr); } + if (res.error !== undefined && res.error !== null) { + // No response arrived (connection loss, terminated socket). Preserve + // the transport error so connection classification, read retries and + // write-submission ambiguity keep their original semantics. + throw res.error instanceof Error ? res.error : new Error(String(res.error)); + } this.reconnectAttempts = 0; - return res.data as T; + return opts.envelope ? (res.data as T) : unwrapV2Envelope(path, res.data as T); } catch (e) { // Writes may already have been accepted. No generic HTTP status, // disconnect or timeout proves it is safe to send them a second time. @@ -171,13 +219,8 @@ export class OpenCodeClient { const ensured = await withAbort(ensureServer({ baseUrl: this.baseUrl, autoServe: true, username: this.username, password: this.password, authorization, }), context.signal); - if (ensured.url) { - const nextUrl = ensured.url.replace(/\/$/, ""); - if (nextUrl !== this.baseUrl) { - this.baseUrl = nextUrl; - this.api = this.buildSdkClient(); - } - } + // A server started by ensureServer carries its own generated password. + if (ensured.url) this.adoptServer(ensured.url, ensured.password); } return await this.request(method, path, { ...opts, signal: context.signal, deadline: context.deadline }); } catch (e) { @@ -201,6 +244,21 @@ export class OpenCodeClient { return this.request("GET", path, { ...options, query, directory }); } + /** + * GET that keeps the 2.x pagination envelope: returns the inner `data` + * payload together with the opaque `cursor` (`{previous, next}`) needed to + * request further pages. + */ + async getWithCursor( + path: string, query?: Record, directory?: string, options?: RequestOptions, + ): Promise<{ data: T; cursor?: { previous?: string | null; next?: string | null } }> { + const body = await this.request("GET", path, { ...options, query, directory, envelope: true }); + if (body === null || typeof body !== "object" || Array.isArray(body)) return { data: body as T }; + const record = body as { data?: unknown; cursor?: { previous?: string | null; next?: string | null } }; + if (Array.isArray(record.data)) return { data: record.data as T, cursor: record.cursor }; + return { data: body as T }; + } + async post(path: string, body?: unknown, opts?: RequestOptions & { directory?: string }): Promise { return this.request("POST", path, { ...opts, body }); } @@ -259,7 +317,17 @@ export class OpenCodeClient { throw new Error("SSE event exceeds the 1MB limit"); } if (line === "") { - if (data.length > 0) yield { event: event || "message", data: data.join("\n") }; + if (data.length > 0) { + const joined = data.join("\n"); + // OpenCode 2.x emits SSE without `event:` lines; the event name + // lives in the JSON `type` field. Surface it as the event name so + // consumers keep a uniform `{event, data}` shape. + let name = event || ""; + if (!name) { + try { const parsed = JSON.parse(joined); if (typeof parsed?.type === "string") name = parsed.type; } catch { /* not JSON */ } + } + yield { event: name || "message", data: joined }; + } event = ""; data = []; } else if (!line.startsWith(":")) { diff --git a/src/helpers.ts b/src/helpers.ts index 46e1458..fa9dd03 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -34,15 +34,8 @@ export const directoryParam = z "If omitted, the OpenCode server uses its own working directory.", ); -/** OpenCode SDK OutputFormat, accepted by synchronous and async prompts. */ -export const outputFormatParam = z.discriminatedUnion("type", [ - z.object({ type: z.literal("text") }), - z.object({ - type: z.literal("json_schema"), - schema: z.record(z.string(), z.unknown()), - retryCount: z.number().int().nonnegative().optional(), - }), -]).optional().describe("Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool."); +// OpenCode 2.x dropped prompt-level structured output (OutputFormat); prompts +// are plain text and models are selected per session. // ── Default Provider/Model ──────────────────────────────────────────── @@ -123,131 +116,142 @@ export function normalizeDirectory(directory?: string): string | undefined { * Pulls text content from parts, summarizes tool calls, etc. * Accepts any shape — casts internally for safety. */ +/** + * Format a single OpenCode 2.x message into readable text. + * + * Messages are flat objects tagged by `type`: + * - `user` → `{ id, time, type, text | payload.text }` + * - `assistant` → `{ id, time, type, content: [text|reasoning|tool], finish, error, cost, tokens }` + * - `idle` → `{ id, time, type, outcome, error }` (turn separators) + */ export function formatMessageResponse(response: unknown): string { const r = response as any; - // StructuredOutput is a tool call whose generic acknowledgement is not the - // answer. Prefer the validated value over tool receipts and token metadata. - if (r?.info?.structured !== undefined) return safeStringify(r.info.structured); + if (r === null || r === undefined) return ""; + if (typeof r !== "object") return String(r); const sections: string[] = []; - // Omit verbose message header for cleaner output; the caller (opencode_ask etc.) - // already provides session context. Keep a minimal role tag only when it adds info. + if (r.type === "user") { + const text = typeof r.text === "string" ? r.text : typeof r.payload?.text === "string" ? r.payload.text : undefined; + sections.push(text ?? safeStringify(r.payload ?? r)); + return sections.join("\n\n"); + } + if (r.type === "idle") { + const outcome = typeof r.outcome === "string" ? r.outcome : "unknown"; + sections.push(r.error ? `Run ${outcome}: ${JSON.stringify(r.error)}` : `Run ${outcome}.`); + return sections.join("\n\n"); + } - if (r?.parts && Array.isArray(r.parts)) { - for (const part of r.parts) { - switch (part.type) { + if (Array.isArray(r.content)) { + for (const part of r.content as any[]) { + switch (part?.type) { case "text": - sections.push(part.text ?? part.content ?? ""); + if (part.text) sections.push(part.text); + break; + case "reasoning": + // Reasoning traces are model-internal; omit from user-facing output. break; case "tool": { const state = part.state ?? {}; - const detail = state.error ?? state.output ?? summarizeToolInput(state.input); - sections.push(`[Tool: ${part.tool ?? "unknown"}] ${state.status === "error" ? "ERROR: " : ""}${typeof detail === "string" ? detail : JSON.stringify(detail)}`); - break; - } - case "tool-invocation": - case "tool-result": - sections.push( - `[Tool: ${part.toolName ?? "unknown"}] ${part.error ? `ERROR: ${part.error}` : typeof part.output === "string" ? part.output : JSON.stringify(part.output ?? part.input, null, 2)}`, - ); - break; - case "step-start": - case "step-finish": - // Internal lifecycle events — omit from user-facing output. - // Optionally surface cost/token info from step-finish. - if (part.type === "step-finish" && (part.cost != null || part.tokens)) { - const meta: string[] = []; - if (part.cost != null) meta.push(`cost: $${Number(part.cost).toFixed(4)}`); - if (part.tokens) { - const t = part.tokens; - const tokParts: string[] = []; - if (t.input) tokParts.push(`${t.input} in`); - if (t.output) tokParts.push(`${t.output} out`); - if (t.reasoning) tokParts.push(`${t.reasoning} reasoning`); - if (tokParts.length > 0) meta.push(`tokens: ${tokParts.join(", ")}`); - } - if (meta.length > 0) sections.push(`_${meta.join(" | ")}_`); + const name = part.name ?? "unknown"; + if (state.status === "error") { + const err = state.error; + const detail = typeof err?.message === "string" ? err.message : JSON.stringify(err ?? ""); + sections.push(`[Tool: ${name}] ERROR: ${detail}`); + } else if (state.status === "completed") { + const detail = summarizeToolContent(state.content); + sections.push(`[Tool: ${name}]${detail ? ` ${detail}` : ""}`); + } else { + sections.push(`[Tool: ${name}] ${state.status ?? "running"}`); } break; + } default: - // Skip unknown internal part types to keep output clean - if (part.text || part.content) { - sections.push(part.text ?? part.content); - } - // Only dump JSON for truly unknown parts that have meaningful data - else if (part.type && !["source"].includes(part.type)) { - sections.push( - `[${part.type}] ${JSON.stringify(part, null, 2)}`, - ); - } + if (part?.type && part.text) sections.push(part.text); + break; } } } - + if (typeof r.text === "string" && sections.length === 0) sections.push(r.text); + if (r.error && sections.length === 0) { + const err = r.error; + sections.push(`Error: ${typeof err?.message === "string" ? err.message : JSON.stringify(err)}`); + } return sections.join("\n\n"); } +/** Render tool result content (array of text/file items) as a compact hint. */ +function summarizeToolContent(content: unknown): string { + if (!Array.isArray(content)) return ""; + const texts = content + .filter((item: any) => item?.type === "text" && typeof item.text === "string") + .map((item: any) => item.text); + if (texts.length === 0) return ""; + const joined = texts.join("\n"); + return joined.length > 300 ? joined.slice(0, 297) + "..." : joined; +} + /** - * Format a list of messages, extracting text content from each. - * - * When the assistant message has no text content (common with some providers - * that only emit tool calls), we show a concise summary of tool actions - * instead of blank output. Cost/token metadata from step-finish parts is - * appended when available. + * Format a list of OpenCode 2.x messages (any order), extracting text content + * from each. Idle markers delimit turns; assistant messages carry tool parts. */ export function formatMessageList( messages: unknown[], ): string { if (!messages || messages.length === 0) return "No messages found."; - return messages - .map((raw, i) => { + const ordered = [...messages].sort((a, b) => { + const x = a as any, y = b as any; + return (x?.time?.created ?? 0) - (y?.time?.created ?? 0) || String(x?.id ?? "").localeCompare(String(y?.id ?? "")); + }); + + return ordered + .map((raw) => { const msg = raw as any; - const role = msg?.info?.role ?? "unknown"; - const id = msg?.info?.id ?? "?"; - const parts = Array.isArray(msg?.parts) ? msg.parts : []; + const type = msg?.type ?? "unknown"; + const id = msg?.id ?? "?"; + const time = typeof msg?.time?.created === "number" ? new Date(msg.time.created).toISOString() : ""; + + if (type === "idle") { + const outcome = msg.outcome ?? "unknown"; + return `--- [idle] (${id}) ---\n${msg.error ? `Run ${outcome}: ${JSON.stringify(msg.error)}` : `Run ${outcome}.`}`; + } + if (type === "user") { + const text = typeof msg?.text === "string" ? msg.text : msg?.payload?.text; + return `--- [user] (${id})${time ? ` ${time}` : ""} ---\n${text ?? "(no text)"}`; + } + if (type !== "assistant") { + const text = typeof msg?.text === "string" ? msg.text : JSON.stringify(msg?.payload ?? msg); + return `--- [${type}] (${id}) ---\n${text}`; + } - const textParts = parts - .filter((p: any) => p.type === "text") - .map((p: any) => (p.text ?? p.content ?? "").trim()) + const content = Array.isArray(msg.content) ? msg.content : []; + const textParts = content + .filter((p: any) => p?.type === "text" && p.text) + .map((p: any) => String(p.text).trim()) .filter(Boolean) .join("\n"); + const toolParts = content.filter((p: any) => p?.type === "tool"); + const modelTag = msg?.model?.providerID && msg?.model?.id ? ` model: ${msg.model.providerID}/${msg.model.id}` : ""; - const toolParts = parts.filter( - (p: any) => p.type === "tool" || p.type === "tool-invocation" || p.type === "tool-result", - ); - - // Extract cost/token metadata from step-finish parts - const costMeta = extractCostMeta(parts); - - let summary = `--- Message ${i + 1} [${role}] (${id}) ---\n`; - - if (msg?.info?.structured !== undefined) { - summary += safeStringify(msg.info.structured); - } else if (textParts) { + let summary = `--- [assistant] (${id})${time ? ` ${time}` : ""}${modelTag} ---\n`; + if (textParts) { summary += textParts; - if (toolParts.length > 0) { - summary += `\n[${toolParts.length} tool call(s)]`; - } + if (toolParts.length > 0) summary += `\n[${toolParts.length} tool call(s)]`; } else if (toolParts.length > 0) { - // No text but agent performed actions — show concise tool summaries const toolSummaries = toolParts.slice(0, 10).map((p: any) => { - const name = p.tool ?? p.toolName ?? "unknown"; - // Extract the most useful arg (file path, command, etc.) - const hint = summarizeToolInput(p.state?.input ?? p.input); - const errTag = p.error || p.state?.status === "error" ? " ERROR" : ""; + const name = p.name ?? "unknown"; + const hint = summarizeToolInput(p.state?.input); + const errTag = p.state?.status === "error" ? " ERROR" : ""; return ` ${name}${hint ? `: ${hint}` : ""}${errTag}`; }); summary += `Agent performed ${toolParts.length} action(s):\n${toolSummaries.join("\n")}`; - if (toolParts.length > 10) { - summary += `\n ... and ${toolParts.length - 10} more`; - } + if (toolParts.length > 10) summary += `\n ... and ${toolParts.length - 10} more`; } else { summary += "(no content)"; } + const costMeta = extractCostMeta(msg); if (costMeta) summary += `\n${costMeta}`; - return summary; }) .join("\n\n"); @@ -271,22 +275,18 @@ function summarizeToolInput(input: unknown): string { } /** - * Extract cost/token metadata from step-finish parts and return a - * compact line, or empty string if none found. + * Extract cost/token metadata from an OpenCode 2.x assistant message. */ -function extractCostMeta(parts: any[]): string { - const stepFinish = parts.find( - (p: any) => p.type === "step-finish" && (p.cost != null || p.tokens), - ); - if (!stepFinish) return ""; +function extractCostMeta(msg: any): string { const meta: string[] = []; - if (stepFinish.cost != null) meta.push(`cost: $${Number(stepFinish.cost).toFixed(4)}`); - if (stepFinish.tokens) { - const t = stepFinish.tokens; + if (typeof msg?.cost === "number" && msg.cost > 0) meta.push(`cost: $${Number(msg.cost).toFixed(4)}`); + const t = msg?.tokens; + if (t && typeof t === "object") { const tokParts: string[] = []; if (t.input) tokParts.push(`${t.input} in`); if (t.output) tokParts.push(`${t.output} out`); if (t.reasoning) tokParts.push(`${t.reasoning} reasoning`); + if (t.cache?.read || t.cache?.write) tokParts.push(`${(t.cache.read ?? 0) + (t.cache.write ?? 0)} cache`); if (tokParts.length > 0) meta.push(`tokens: ${tokParts.join(", ")}`); } return meta.length > 0 ? `_${meta.join(" | ")}_` : ""; @@ -332,9 +332,10 @@ export function formatSessionList( const s = raw as any; const id = s?.id ?? "?"; const title = s?.title ?? "(untitled)"; - const createdAt = s?.createdAt ?? ""; const parentID = s?.parentID ? ` (child of ${s.parentID})` : ""; - return `- ${title} [${id}]${parentID}${createdAt ? ` created ${createdAt}` : ""}`; + const created = typeof s?.time?.created === "number" ? ` created ${new Date(s.time.created).toISOString()}` : ""; + const outcome = typeof s?.outcome === "string" ? ` [${s.outcome}]` : ""; + return `- ${title} [${id}]${parentID}${outcome}${created}`; }) .join("\n"); } @@ -370,10 +371,10 @@ export function safeStringify( } /** - * Analyze an AI message response for signs of failure: + * Analyze an OpenCode 2.x assistant message for signs of failure: * - Completely empty (null/undefined) - * - Has parts but no text content (provider returned nothing) - * - Contains error indicators in parts + * - Has content but no text (provider returned nothing) + * - Contains an error field or errored tool parts * * Returns a diagnostic object with `isEmpty`, `hasError`, and `warning` text. */ @@ -395,21 +396,14 @@ export function analyzeMessageResponse(response: unknown): { } const r = response as any; - const parts = Array.isArray(r?.parts) ? r.parts : []; - - // Check for error parts - const errorParts = parts.filter( - (p: any) => - p.error || - (p.type === "tool" && p.state?.status === "error") || - (p.type === "tool-result" && p.error), + const content = Array.isArray(r?.content) ? r.content : []; + + const errorParts = content.filter( + (p: any) => p?.type === "tool" && p?.state?.status === "error", ); - if (r?.info?.error || r?.error || errorParts.length > 0) { + if (r?.error || r?.type === "idle" && r?.outcome === "failed" || errorParts.length > 0) { const firstError = - r?.info?.error ?? r?.error ?? - errorParts[0]?.error ?? - errorParts[0]?.state?.error ?? - JSON.stringify(errorParts[0]); + r?.error ?? errorParts[0]?.state?.error ?? JSON.stringify(errorParts[0]); return { isEmpty: false, hasError: true, @@ -418,15 +412,22 @@ export function analyzeMessageResponse(response: unknown): { }; } - // Check if there's any actual text content - const textContent = parts - .filter((p: any) => p.type === "text") - .map((p: any) => (p.text ?? p.content ?? "").trim()) + const hasToolActivity = content.some((p: any) => p?.type === "tool"); + if (content.length === 0 && typeof r?.text !== "string") { + return { + isEmpty: true, + hasError: false, + warning: + "The AI returned a response with no text content. This usually means " + + "the provider API key is missing or the model is unavailable. " + + "Try a different provider/model, or use `opencode_auth_set` to configure credentials.", + }; + } + const textContent = content + .filter((p: any) => p?.type === "text") + .map((p: any) => String(p.text ?? "").trim()) .join(""); - - const hasToolActivity = parts.some((p: any) => ["tool", "tool-invocation", "tool-result"].includes(p.type)); - if (r?.info?.structured !== undefined) return { isEmpty: false, hasError: false, warning: null }; - if (parts.length === 0 || (textContent === "" && !hasToolActivity)) { + if (textContent === "" && !hasToolActivity && typeof r?.text !== "string") { return { isEmpty: true, hasError: false, @@ -530,25 +531,23 @@ export function redactSecrets(value: unknown): unknown { } /** - * Determine whether a provider object from the OpenCode API is truly configured - * (i.e. has usable credentials), as opposed to being a built-in default. + * Determine whether a provider object from the OpenCode 2.x API is usable. * - * Detection layers: - * - source "env" / "config" / "api" → always configured - * - source "custom" with a non-empty apiKey → configured - * - source "custom" for "anthropic" with extra option keys (OAuth sets headers) → configured - * - Everything else → not configured + * v2 providers carry `integrationID`, `activation`, and `settings`; usable + * credentials live either in the provider settings (apiKey) or as an active + * credential entry for the provider's integration. */ -export function isProviderConfigured(p: Record): boolean { - const source = p.source as string | undefined; - if (source === "env" || source === "config" || source === "api") return true; - if (source === "custom") { - const opts = p.options as Record | undefined; - if (typeof opts?.apiKey === "string" && opts.apiKey !== "") return true; - // Anthropic heuristic: OAuth sets headers but no apiKey - if (p.id === "anthropic" && opts && Object.keys(opts).some((k) => k !== "apiKey")) return true; - } - return false; +export function isProviderConfigured( + p: Record, + credentials?: Array>, +): boolean { + const settings = p.settings as Record | undefined; + if (typeof settings?.apiKey === "string" && settings.apiKey !== "") return true; + const id = p.integrationID ?? p.id; + if (typeof id !== "string") return false; + return (credentials ?? []).some( + (credential) => credential?.active === true && (credential.integrationID === id || credential.integrationID === p.id), + ); } /** diff --git a/src/index.ts b/src/index.ts index 7324b13..b7042db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,12 +5,12 @@ import { OpenCodeClient } from "./client.js"; import { JobService } from "./jobs.js"; import { TaskTransport } from "./task-transport.js"; import { createServer } from "./app.js"; -import { ensureServer, registerShutdownHandlers } from "./server-manager.js"; +import { registerShutdownHandlers } from "./server-manager.js"; import { setModelDefaults } from "./helpers.js"; async function main() { const baseUrl = process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096"; - const username = process.env.OPENCODE_SERVER_USERNAME; + const username = process.env.OPENCODE_SERVER_USERNAME || "opencode"; const password = process.env.OPENCODE_SERVER_PASSWORD; const token = process.env.OPENCODE_SERVER_TOKEN || undefined; const openchamberPassword = process.env.OPENCODE_OPENCHAMBER_PASSWORD || undefined; @@ -22,16 +22,23 @@ async function main() { setModelDefaults(process.env.OPENCODE_DEFAULT_PROVIDER, process.env.OPENCODE_DEFAULT_MODEL); registerShutdownHandlers(); const client = new OpenCodeClient({ baseUrl, username, password, token, openchamberPassword, openchamberUrl, clientId, autoServe }); - const identity: unknown[] = [baseUrl, username ?? "opencode", password ?? ""]; + // Resolve the server (and, for auto-serve, its generated password) before + // serving so the first request is already authenticated. + let effectiveUrl = baseUrl; + try { + const status = await client.connect(); + // Only a server we started moves the URL; otherwise keep OPENCODE_BASE_URL as typed so the job scope is unchanged. + if (status.managedByUs && status.url) effectiveUrl = status.url; + } catch (error) { console.error(`Warning: ${error instanceof Error ? error.message : String(error)}`); } + // Hash the configured credentials, not a generated auto-serve password, so durable jobs stay visible across restarts. + const identity: unknown[] = [effectiveUrl, username, password ?? ""]; if (token || openchamberPassword) identity.push(token ?? "", openchamberPassword ?? "", openchamberUrl ?? ""); const scope = createHash("sha256").update(JSON.stringify(identity)).digest("hex"); const jobs = new JobService(client, { storeRoot: process.env.OPENCODE_TASK_STORE, scope }); - try { await ensureServer({ baseUrl, autoServe, username, password, authorization: await client.getAuthorization() }); } - catch (error) { console.error(`Warning: ${error instanceof Error ? error.message : String(error)}`); } serveStdio(() => createServer(client, jobs, profile), { legacy: "serve", transport: new TaskTransport(new StdioServerTransport(), jobs), onerror: error => console.error("MCP transport error:", error.message), }); - console.error(`opencode-mcp v3.1.0 started (profile: ${profile})`); + console.error(`opencode-mcp v4.0.0 started (profile: ${profile})`); } main().catch(error => { console.error("Fatal error starting opencode-mcp:", error); process.exitCode = 1; }); diff --git a/src/job-contract.ts b/src/job-contract.ts index 09f3c18..3c7d045 100644 --- a/src/job-contract.ts +++ b/src/job-contract.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { directoryParam, outputFormatParam } from "./helpers.js"; +import { directoryParam } from "./helpers.js"; export const dispatchShape = { prompt: z.string().min(1).describe("Task or instruction to send to OpenCode"), @@ -10,7 +10,6 @@ export const dispatchShape = { variant: z.string().optional().describe("Model variant"), agent: z.string().optional().describe("OpenCode agent name"), directory: directoryParam, - format: outputFormatParam, }; export const runShape = { ...dispatchShape, diff --git a/src/jobs.ts b/src/jobs.ts index 4457e4b..c790d37 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -36,7 +36,6 @@ export interface JobStartArgs { model?: { providerID: string; modelID: string }; variant?: string; agent?: string; - format?: unknown; } export interface JobInputResponse { id: string; @@ -68,6 +67,32 @@ const terminal = (status: JobStatus) => ["completed", "failed", "cancelled"].inc const hash = (value: string) => createHash("sha256").update(value).digest("hex"); const segment = (value: string) => encodeURIComponent(value); +/** + * Convert MCP elicitation answers (one array of selected labels per form + * field, in field order) into the OpenCode 2.x Form.Reply answer map. + * Option labels are resolved to option values when they differ. + */ +function formAnswer(form: PendingInput | undefined, answers: string[][] | undefined): Record { + const raw = (form as unknown as { fields?: unknown })?.fields; + const fields: any[] = Array.isArray(raw) ? raw : []; + const answer: Record = {}; + fields.forEach((field: any, index: number) => { + const selected = answers?.[index] ?? []; + const optionValue = (label: string): string => { + const options = Array.isArray(field?.options) ? field.options : []; + const match = options.find((option: any) => option?.value === label || option?.label === label); + return typeof match?.value === "string" ? match.value : label; + }; + if (field?.type === "multiselect") answer[field.key] = selected.map(optionValue); + else if (field?.type === "boolean") answer[field.key] = selected[0] === "true"; + else if (field?.type === "number" || field?.type === "integer") { + const numeric = Number(selected[0]); + answer[field.key] = Number.isFinite(numeric) ? numeric : 0; + } else answer[field.key] = selected[0] === undefined ? "" : optionValue(selected[0]); + }); + return answer; +} + function publicSnapshot(job: StoredJob): JobSnapshot { const { version: _version, responses: _responses, cancelRequested: _cancelRequested, ...snapshot } = job; return structuredClone(snapshot); @@ -92,21 +117,76 @@ export function createMessageId(): string { } async function pendingInputs(client: OpenCodeClient, sessionId: string, directory: string | undefined, options: RequestOptions): Promise { - const values = await Promise.all((["question", "permission"] as const).map(async (kind) => { + // OpenCode 2.x: pending questions are forms and pending permissions are + // requests, listed per session (GET /api/session/{id}/form and + // /api/session/{id}/permission). The location-scoped /api/form and + // /api/permission/request lists only cover the server's current directory + // and would miss inputs of sessions elsewhere. + const [forms, permissions] = await Promise.all(([ + `/api/session/${segment(sessionId)}/form`, + `/api/session/${segment(sessionId)}/permission`, + ] as const).map(async (path) => { try { - const response = await client.get(`/${kind}`, undefined, directory, options); - if (!Array.isArray(response)) throw new Error(`Invalid ${kind} list response`); - return response.filter((input: any) => input?.sessionID === sessionId && typeof input.id === "string") - .map((input: any) => ({ ...input, kind } as PendingInput)); + const response = await client.get(path, undefined, directory, options); + if (!Array.isArray(response)) throw new Error(`Invalid ${path} list response`); + return response as Array>; } catch (error) { if (error instanceof OpenCodeError && error.status === 404) return []; throw error; } })); - return values.flat(); + const inputs: PendingInput[] = []; + for (const form of forms) { + if (form?.sessionID !== undefined && form.sessionID !== sessionId) continue; + if (typeof form.id === "string" && form.id) inputs.push({ ...form, sessionID: sessionId, kind: "question" } as PendingInput); + } + for (const permission of permissions) { + if (permission?.sessionID !== undefined && permission.sessionID !== sessionId) continue; + if (typeof permission.id === "string" && permission.id) inputs.push({ ...permission, sessionID: sessionId, kind: "permission" } as PendingInput); + } + return inputs; +} + +/** + * Read a session's messages in ascending creation order, paging with the + * opaque cursor until the observed turn is complete. Pages arrive newest + * first; once a page contains the target user message (or, without one, the + * newest user message) every older page is irrelevant to that turn. + */ +export async function readSessionMessages( + client: OpenCodeClient, sessionId: string, directory: string | undefined, + request: { signal?: AbortSignal; deadline?: number }, + opts: { targetId?: string; limit?: number; maxPages?: number } = {}, +): Promise { + const limit = String(opts.limit ?? 100); + const maxPages = opts.maxPages ?? 60; + const messages: any[] = []; + let cursor: string | undefined; + for (let page = 0; page < maxPages; page++) { + const response: any = await client.getWithCursor( + `/api/session/${segment(sessionId)}/message`, + cursor ? { limit, cursor } : { limit }, + directory, + request, + ); + // Fixtures may return a bare (already-unwrapped) array; the live API + // returns `{data, cursor}`. Both describe one newest-first page. + const rows = Array.isArray(response) ? response : Array.isArray(response?.data) ? response.data : null; + if (!rows) throw new Error("Invalid session message response"); + messages.push(...rows); + const reached = opts.targetId + ? rows.some((message: any) => message?.id === opts.targetId) + : rows.some((message: any) => message?.type === "user"); + if (reached) break; + const next = Array.isArray(response) ? undefined : response?.cursor?.next; + cursor = typeof next === "string" && next ? next : undefined; + if (!cursor || rows.length === 0) break; + } + return messages.sort((a: any, b: any) => + (a?.time?.created ?? 0) - (b?.time?.created ?? 0) || String(a?.id ?? "").localeCompare(String(b?.id ?? ""))); } -/** Observe a specific dispatched turn, or the latest user turn in a legacy session. */ +/** Observe a specific dispatched turn, or the latest user turn in a session. */ export async function observeSession( client: OpenCodeClient, sessionId: string, directory?: string, messageId?: string, options: RequestOptions = {}, ): Promise { @@ -114,78 +194,53 @@ export async function observeSession( directory = normalizeDirectory(directory); const context = createRequestContext(options); const request = { signal: context.signal, deadline: context.deadline }; - let historyIncomplete = false; - const readMessages = async () => { - const path = `/session/${segment(sessionId)}/message`; - try { - return await client.get(path, { limit: "100" }, directory, request); - } catch (error) { - // OpenCode 1.18.31 cannot encode persisted OutputFormat class instances - // on user messages. A latest-assistant-only read avoids that broken row. - // Require a known parent ID; partial history cannot establish ownership. - if (!messageId || !isOutputFormatEncodingError(error)) throw error; - historyIncomplete = true; - try { - return await client.get(path, { limit: "1" }, directory, request); - } catch (latestError) { - // Before the assistant exists, even the latest row is the broken user. - if (!isOutputFormatEncodingError(latestError)) throw latestError; - return []; - } - } - }; try { - const [statuses, session, response] = await Promise.all([ - client.get>("/session/status", undefined, directory, request), - client.get(`/session/${segment(sessionId)}`, undefined, directory, request), - readMessages(), + const [active, session, messages] = await Promise.all([ + client.get>("/api/session/active", undefined, directory, request).catch(() => ({}) as Record), + client.get(`/api/session/${segment(sessionId)}`, undefined, directory, request), + readSessionMessages(client, sessionId, directory, request, { targetId: messageId }), ]); - if (!Array.isArray(response)) throw new Error("Invalid session message response"); - const messages = [...response].sort((a: any, b: any) => - (a?.info?.time?.created ?? 0) - (b?.info?.time?.created ?? 0) || String(a?.info?.id ?? "").localeCompare(String(b?.info?.id ?? ""))); - const latestUser = messages.filter((message: any) => message?.info?.role === "user").at(-1); - const target = messageId ?? latestUser?.info?.id; - const assistant = messages.filter((message: any) => message?.info?.role === "assistant" && (!target || message.info.parentID === target)).at(-1); - const rawStatus = statuses?.[sessionId]; - const state = typeof rawStatus === "string" ? rawStatus : rawStatus?.type ?? rawStatus?.state ?? "idle"; + const latestUser = messages.filter((message: any) => message?.type === "user").at(-1); + const target = messageId ?? latestUser?.id; const snapshot: JobSnapshot = { sessionId, directory, messageId: target, status: "accepted", session }; + const targetIndex = messages.findIndex((message: any) => message?.id === target); + // The turn is everything after our user message, up to and including the + // idle marker that closes it: user → assistant* → idle. The boundary is + // the idle marker, not the next user message: a steered prompt can join + // the running turn, while queued prompts only start after the marker. + const rest = targetIndex >= 0 ? messages.slice(targetIndex + 1) : []; + const idleIndex = rest.findIndex((message: any) => message?.type === "idle"); + const turn = idleIndex >= 0 ? rest.slice(0, idleIndex + 1) : rest; + const idleMarker = idleIndex >= 0 ? rest[idleIndex] : undefined; + // A session can contain another active turn. Never offer its forms or + // permissions as inputs for an older job, even if its result is unavailable. + if (!idleMarker && target && latestUser && latestUser.id !== target) { + snapshot.status = "unknown"; + snapshot.inputs = []; + return snapshot; + } + const assistants = turn.filter((message: any) => message?.type === "assistant"); + const assistant = assistants.at(-1); if (assistant) { - snapshot.result = assistant.info.structured ?? assistant; + snapshot.result = assistant; snapshot.text = formatMessageResponse(assistant); - if (assistant.info.error) { - snapshot.error = assistant.info.error; - snapshot.status = assistant.info.error.name === "MessageAbortedError" ? "cancelled" : "failed"; + } + if (idleMarker) { + if (idleMarker.outcome === "interrupted") { + snapshot.status = "cancelled"; return snapshot; } - // A tool-call step can complete while the turn continues. Require idle, - // a completed assistant and a final finish reason when supplied. - // Validated structured output itself ends the turn, even though OpenCode - // preserves the StructuredOutput step's "tool-calls" finish reason. - if ((state === "idle" || (target && latestUser && latestUser.info.id !== target)) && typeof assistant.info.time?.completed === "number" && - (assistant.info.structured !== undefined || !["tool-calls", "unknown"].includes(assistant.info.finish))) { - snapshot.status = "completed"; + if (idleMarker.outcome === "failed") { + snapshot.status = "failed"; + snapshot.error = idleMarker.error ?? { name: "SessionError", message: "Session reported an error" }; return snapshot; } - } - if (state === "error" || state === "failed") { - snapshot.status = "failed"; - snapshot.error = rawStatus?.error ?? { name: "SessionError", message: "Session reported an error" }; - return snapshot; - } - if (state === "busy" || state === "retry" || state === "running") snapshot.status = "running"; - if (historyIncomplete) { - snapshot.status = "unknown"; - snapshot.error = { name: "MessageHistoryUnavailable", message: "OpenCode could not serialize message history. No completed result for the requested turn is available yet; observe this job again without resubmitting." }; - snapshot.inputs = []; - return snapshot; - } - // A session can contain another active turn. Never offer its permissions - // as inputs for an older job, even if that job's result is unavailable. - if (target && latestUser && latestUser.info.id !== target) { - snapshot.status = "unknown"; - snapshot.inputs = []; + snapshot.status = "completed"; return snapshot; } + // No idle marker yet: the run is queued, running, or its outcome unknown. + const running = typeof active?.[sessionId]?.type === "string"; + if (running || assistants.length > 0) snapshot.status = "running"; snapshot.inputs = await pendingInputs(client, sessionId, directory, request); if (snapshot.inputs.length) snapshot.status = "input_required"; return snapshot; @@ -194,19 +249,6 @@ export async function observeSession( } } -function isOutputFormatEncodingError(error: unknown): boolean { - if (!(error instanceof OpenCodeError) || error.status !== 400 || error.method !== "GET") return false; - try { - const body = JSON.parse(error.body); - return body?.name === "BadRequest" && body.data?.kind === "Body" && - typeof body.data.message === "string" && - /^Expected OutputFormat(?:JsonSchema|Text), got /.test(body.data.message) && - body.data.message.includes('["info"]["format"]'); - } catch { - return false; - } -} - /** Durable ownership and observation handles. Prompt execution remains owned by OpenCode. */ export class JobService { private readonly store: string | null; @@ -360,16 +402,33 @@ export class JobService { throwIfAborted(context.signal); await this.save(job); // Record ownership before the first remote mutation. try { + const model = args.model + ? { id: args.model.modelID, providerID: args.model.providerID, ...(args.variant ? { variant: args.variant } : {}) } + : undefined; if (!job.sessionId) { - const session = await this.client.post<{ id: string }>("/session", { title: args.title ?? "MCP delegated task" }, request); + // OpenCode 2.x: session creation takes the agent, model and location + // in the body; the x-opencode-directory header does not scope POST /api/session. + const session = await this.client.post<{ id: string }>("/api/session", { + title: args.title ?? "MCP delegated task", + ...(args.agent ? { agent: args.agent } : {}), + ...(model ? { model } : {}), + ...(directory ? { location: { directory } } : {}), + }, request); if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode returned no session ID"); job.sessionId = session.id; await this.save(job); + } else { + // v2 prompts no longer accept an agent or model; switch the + // session before prompting instead. + if (args.agent) await this.client.post(`/api/session/${segment(job.sessionId)}/agent`, { agent: args.agent }, request); + if (model) await this.client.post(`/api/session/${segment(job.sessionId)}/model`, { model }, request); } - await this.client.post(`/session/${segment(job.sessionId)}/prompt_async`, { - messageID: job.messageId, parts: [{ type: "text", text: args.prompt }], - ...(args.model ? { model: args.model } : {}), ...(args.variant ? { variant: args.variant } : {}), - ...(args.agent ? { agent: args.agent } : {}), ...(args.format ? { format: args.format } : {}), + // v2 prompts return immediately with the queued user message; the + // pre-assigned id lets observeSession attribute the turn later. + // delivery "queue" keeps the prompt a separate turn: the default + // "steer" would merge it into a still-running turn. + await this.client.post(`/api/session/${segment(job.sessionId)}/prompt`, { + id: job.messageId, text: args.prompt, delivery: "queue", }, request); job.status = "accepted"; } catch (error) { @@ -418,16 +477,17 @@ export class JobService { job = await this.refresh(job, options); if (terminal(job.status)) return publicSnapshot(job); if (!job.sessionId) throw new Error("No known session to cancel"); - // OpenCode's abort endpoint is session-wide: refuse to abort a newer turn. - const messages = await this.client.get(`/session/${segment(job.sessionId)}/message`, { limit: "100" }, job.directory, options); - const users = messages.filter((message) => message?.info?.role === "user"); - const newest = users.sort((a, b) => (a.info.time?.created ?? 0) - (b.info.time?.created ?? 0) || String(a.info.id).localeCompare(String(b.info.id))).at(-1); - if (newest && newest.info.id !== job.messageId) throw new Error("A newer turn owns this session; refusing to abort unrelated work"); + // OpenCode 2.x's interrupt is session-wide: refuse to abort a newer turn. + const messages = await readSessionMessages(this.client, job.sessionId, job.directory, options); + const newest = messages.filter((message) => message?.type === "user").at(-1); + if (newest && newest.id !== job.messageId) throw new Error("A newer turn owns this session; refusing to abort unrelated work"); if (job.cancelRequested) return publicSnapshot(job); job.cancelRequested = true; await this.save(job); try { - await this.client.post(`/session/${segment(job.sessionId)}/abort`, {}, { ...options, directory: job.directory }); + // resume=false ends the turn; an idle marker with outcome "interrupted" + // is appended so later observations stay consistent. + await this.client.post(`/api/session/${segment(job.sessionId)}/interrupt?resume=false`, undefined, { ...options, directory: job.directory }); job.status = "cancelled"; delete job.error; } catch (error) { @@ -463,9 +523,24 @@ export class JobService { job.responses[key] = { digest, state: "sending" }; await this.save(job); try { - const path = `/${response.kind}/${segment(response.id)}/${response.kind === "question" && response.reject ? "reject" : "reply"}`; - const body = response.kind === "permission" ? { reply: response.reply, ...(response.message ? { message: response.message } : {}) } : response.reject ? undefined : { answers: response.answers }; - await this.client.post(path, body, { ...options, directory: job.directory }); + const input = job.inputs?.find((pending) => pending.id === response.id && pending.kind === response.kind); + const sessionId = (input as { sessionID?: string } | undefined)?.sessionID ?? job.sessionId; + if (!sessionId) throw new Error("No session associated with this input"); + if (response.kind === "permission") { + await this.client.post( + `/api/session/${segment(sessionId)}/permission/${segment(response.id)}/reply`, + { decision: response.reply, ...(response.message ? { message: response.message } : {}) }, + { ...options, directory: job.directory }, + ); + } else if (response.reject) { + await this.client.delete(`/api/session/${segment(sessionId)}/form/${segment(response.id)}`, undefined, job.directory, options); + } else { + await this.client.post( + `/api/session/${segment(sessionId)}/form/${segment(response.id)}/reply`, + { answer: formAnswer(input, response.answers) }, + { ...options, directory: job.directory }, + ); + } job.responses[key].state = "sent"; } catch (error) { if (error instanceof OpenCodeError) delete job.responses[key]; diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 1ef8c08..14c35e8 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -11,10 +11,10 @@ export type ToolProfile = "full" | "essential"; const readers = new Set(`health setup status context conversation sessions_overview check wait review_changes config_get config_providers provider_list provider_models provider_auth_methods - session_list session_get session_children session_status session_todo session_diff session_search permission_list - message_list message_get find_text find_file find_symbol file_list file_read file_status - project_list project_current path_get vcs_info agent_list command_list lsp_status formatter_status - mcp_status tool_ids tool_list events_poll question_list job_get job_list`.split(/\s+/).map(n => `opencode_${n}`)); + session_list session_get session_children session_status session_diff session_search permission_list + message_list message_get find_text find_file file_list file_read file_status + project_list project_current path_get vcs_info agent_list command_list + mcp_status events_poll question_list job_get job_list`.split(/\s+/).map(n => `opencode_${n}`)); const essential = new Set(`setup provider_list provider_models ask reply run fire check wait conversation review_changes context status session_abort session_create session_list permission_list session_permission question_list question_reply question_reject job_get job_list job_cancel job_input`.split(/\s+/).map(n => `opencode_${n}`)); diff --git a/src/prompts.ts b/src/prompts.ts index d48d48d..171777f 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -188,7 +188,7 @@ Steps: ## Writing Useful Requests - State the expected behavior, constraints, and relevant tests. - Use optional variant values supported by the selected model. -- Use format: {type: "json_schema", schema: {...}} when a machine-readable model answer is needed. +- Prompts are plain text; ask for machine-readable formats (e.g. JSON in a code block) in the prompt itself. ## Common Pitfalls - Use an absolute directory on the OpenCode host, including for follow-ups. A session alone is not filesystem isolation; parallel edits should use separate projects or worktrees. diff --git a/src/resources.ts b/src/resources.ts index 7f596e6..c95d07e 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -21,7 +21,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { }, async () => { try { - const project = await client.get("/project/current"); + const project = await client.get("/api/location"); return { contents: [ { @@ -54,7 +54,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const config = await client.get("/config"); + const config = await client.get("/api/config"); return { contents: [ { @@ -77,7 +77,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const providers = await client.get("/provider"); + const providers = await client.get("/api/provider"); return { contents: [ { @@ -99,7 +99,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const agents = await client.get("/agent"); + const agents = await client.get("/api/agent"); return { contents: [ { @@ -121,7 +121,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const commands = await client.get("/command"); + const commands = await client.get("/api/command"); return { contents: [ { @@ -143,7 +143,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const health = await client.get("/global/health"); + const health = await client.get("/api/info"); return { contents: [ { @@ -166,7 +166,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { }, async () => { try { - const vcs = await client.get("/vcs"); + const vcs = await client.get("/api/vcs"); return { contents: [ { @@ -199,7 +199,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const sessions = await client.get("/session"); + const sessions = await client.get("/api/session"); return { contents: [ { @@ -221,7 +221,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const mcp = await client.get("/mcp"); + const mcp = await client.get("/api/mcp"); return { contents: [ { @@ -243,7 +243,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const status = await client.get("/file/status"); + const status = await client.get("/api/vcs/status"); return { contents: [ { @@ -263,8 +263,8 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { return decodeURIComponent(value); }; for (const [name, suffix, endpoint] of [ - ["project-scoped", "current", "/project/current"], - ["sessions-scoped", "sessions", "/session"], + ["project-scoped", "current", "/api/location"], + ["sessions-scoped", "sessions", "/api/session"], ] as const) { server.resource(name, new ResourceTemplate(`opencode://projects/{directory}/${suffix}`, { list: undefined }), @@ -286,7 +286,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { async (uri, variables) => { const directory = normalizeDirectory(scalar(variables.directory, "directory")); const sessionId = scalar(variables.sessionId, "sessionId"); - const data = await client.get(`/session/${encodeURIComponent(sessionId)}${endpointSuffix}`, undefined, directory); + const data = await client.get(`/api/session/${encodeURIComponent(sessionId)}${endpointSuffix}`, undefined, directory); return { contents: [{ uri: uri.href, mimeType: "application/json", text: safeStringify(data) }] }; }, ); diff --git a/src/server-manager.ts b/src/server-manager.ts index f941b65..59439f0 100644 --- a/src/server-manager.ts +++ b/src/server-manager.ts @@ -1,13 +1,14 @@ -import { createOpencodeServer, OpencodeClient } from "@opencode-ai/sdk"; +import { randomBytes } from "node:crypto"; +import { spawn, type ChildProcess } from "node:child_process"; export interface ServerManagerOptions { baseUrl: string; autoServe?: boolean; /** - * HTTP Basic auth credentials forwarded to the `/global/health` probe. - * Required when the OpenCode server is configured with - * `OPENCODE_SERVER_PASSWORD` — without these, the probe would receive 401 - * and `ensureServer` would falsely treat a healthy server as down. + * HTTP Basic auth credentials forwarded to the `/api/info` probe. + * OpenCode 2.x servers enforce authentication; when the server was started + * without `OPENCODE_SERVER_PASSWORD` it generates one and prints it to + * stdout, which is only recoverable by the process that started it. */ username?: string; password?: string; @@ -20,6 +21,12 @@ export interface ServerStatus { version?: string; managedByUs: boolean; url?: string; + /** Password generated for a server started by this process. */ + password?: string; + /** The endpoint exists but rejected the supplied credentials. */ + authFailed?: boolean; + /** A legacy OpenCode 1.x server is running at the URL. */ + legacy?: boolean; } /** @@ -36,28 +43,24 @@ function buildBasicAuthHeader( return "Basic " + Buffer.from(`${user}:${password}`).toString("base64"); } -let managedServer: { url: string; close(): void } | null = null; +let managedServer: { proc: ChildProcess; url: string } | null = null; let shutdownRegistered = false; export function resetShutdownRegisteredForTests(): void { shutdownRegistered = false; + managedServer = null; } /** * In-flight startup promises, keyed by normalized `baseUrl`. Serializes - * concurrent `ensureServer` callers so only one of them invokes - * `createOpencodeServer` per target URL — others awaiting the same key - * receive the same result. Prevents EADDRINUSE / leaked server handles - * when two requests hit the MCP simultaneously and both observe the - * initial health probe as unhealthy. - * - * Keying by `baseUrl` matters because two callers targeting different - * URLs must NOT share each other's result (the second caller would - * receive the first server's URL and bind to the wrong endpoint). + * concurrent `ensureServer` callers so only one of them spawns a server per + * target URL — others awaiting the same key receive the same result. + * Prevents EADDRINUSE / leaked server handles when two requests hit the MCP + * simultaneously and both observe the initial health probe as unhealthy. */ const startServerInFlight = new Map< string, - Promise<{ url: string; version?: string }> + Promise<{ url: string; version?: string; password?: string }> >(); export function registerShutdownHandlers(): void { @@ -66,8 +69,7 @@ export function registerShutdownHandlers(): void { const cleanup = () => { if (managedServer) { - managedServer.close(); - managedServer = null; + killManagedServer(); } }; @@ -87,6 +89,13 @@ export function registerShutdownHandlers(): void { } } +function killManagedServer(): void { + if (!managedServer) return; + const { proc } = managedServer; + managedServer = null; + try { proc.kill(); } catch { /* already gone */ } +} + function parseBaseUrl(baseUrl: string): { hostname: string; port: number } { const url = new URL(baseUrl); return { @@ -95,75 +104,163 @@ function parseBaseUrl(baseUrl: string): { hostname: string; port: number } { }; } +/** + * Probe an OpenCode 2.x server via `GET /api/info`. + * + * The response also identifies legacy 1.x servers (which serve `/global/health` + * but not `/api/info`) and authentication failures, so callers can produce a + * precise error instead of a generic "not running". + */ export async function isServerRunning( baseUrl: string, username?: string, password?: string, authorization?: string, -): Promise<{ healthy: boolean; version?: string }> { +): Promise<{ healthy: boolean; version?: string; authFailed?: boolean; legacy?: boolean }> { + const base = baseUrl.replace(/\/$/, ""); try { - const headers: Record = {}; + const headers: Record = { Accept: "application/json" }; const authHeader = authorization ?? buildBasicAuthHeader(username, password); if (authHeader) { headers["Authorization"] = authHeader; } - const response = await fetch(`${baseUrl.replace(/\/$/, "")}/global/health`, { + const response = await fetch(`${base}/api/info`, { method: "GET", headers, signal: AbortSignal.timeout(3000), }); - if (!response.ok) return { healthy: false }; - const res = await response.json() as any; - if (res && typeof res === 'object' && 'healthy' in res) { - return { - healthy: res.healthy === true, - version: typeof res.version === "string" ? res.version : undefined, - }; + if (response.status === 401) { + // The server is alive but rejected our (possibly absent) password. + return { healthy: false, authFailed: true }; + } + if (response.ok) { + const info = await response.json() as any; + return { + healthy: true, + version: typeof info?.version === "string" ? info.version : undefined, + }; + } + if (response.status === 404) { + return await probeLegacy(base); } return { healthy: false }; } catch { - return { healthy: false }; + // Connection-level failure: fall back to the legacy probe before declaring + // the endpoint dead, in case a 1.x server is running. + return await probeLegacy(base); } } +/** Detect an OpenCode 1.x server, which has no `/api/info` route. */ +async function probeLegacy(base: string): Promise<{ healthy: boolean; legacy?: boolean }> { + try { + const response = await fetch(`${base}/global/health`, { + method: "GET", + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(2000), + }); + const type = response.headers.get("content-type") ?? ""; + if (response.ok && type.includes("application/json")) { + const body = await response.json() as any; + if (body && typeof body === "object" && "healthy" in body) return { healthy: false, legacy: true }; + } + } catch { /* unreachable or SPA fallback */ } + return { healthy: false }; +} + export async function startServer( baseUrl: string, timeoutMs: number = 30000, -): Promise<{ url: string; version?: string }> { + username: string = "opencode", +): Promise<{ url: string; version?: string; password?: string }> { const target = new URL(baseUrl); if (target.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || target.pathname !== "/") { throw new Error("Auto-start requires a local loopback HTTP URL without a path prefix. Start remote servers manually and set OPENCODE_BASE_URL."); } const { hostname, port } = parseBaseUrl(baseUrl); - console.error(`Starting OpenCode SDK server on ${hostname}:${port}`); - - // Capture into a local so concurrent `startServer` calls (against - // different baseUrls) don't clobber each other's return values via the - // module-level `managedServer` singleton. The singleton is still - // updated (for shutdown handler reach) but the URL we return is the - // one this specific call produced. - const created = await createOpencodeServer({ - hostname, - port, - timeout: timeoutMs, + console.error(`Starting OpenCode server on ${hostname}:${port}`); + + // OpenCode 2.x enforces HTTP Basic auth. Generate a private password for the + // child process so this MCP can authenticate without depending on stdout + // parsing, then probe the server with the same credentials. + const password = randomBytes(24).toString("base64url"); + const proc = spawn("opencode", ["serve", `--hostname=${hostname}`, `--port=${port}`], { + // Pin the username too: the child would otherwise inherit OPENCODE_SERVER_USERNAME. + env: { ...process.env, OPENCODE_SERVER_USERNAME: username, OPENCODE_SERVER_PASSWORD: password }, + stdio: ["ignore", "pipe", "pipe"], }); - managedServer = created; + let settled = false; + const url = await new Promise((resolve, reject) => { + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + killSpawned(); + reject(error); + }; + const timer = setTimeout(() => { + fail(new Error(`Timeout waiting for the OpenCode server to start after ${timeoutMs}ms`)); + }, timeoutMs); + const killSpawned = () => { + try { proc.kill(); } catch { /* already gone */ } + }; + let output = ""; + const onLine = (line: string) => { + if (settled) return; + output += line + "\n"; + // OpenCode 2.x prints `server listening on http://host:port`. + const match = line.match(/server listening on (https?:\/\/\S+)/); + if (match) { + settled = true; + clearTimeout(timer); + resolve(match[1]); + return; + } + if (/error|EADDRINUSE/i.test(line) && /listen|bind|server/i.test(line)) { + fail(new Error(`OpenCode server failed to start: ${line.trim()}`)); + } + }; + // Buffer per stream so a line split across two reads is still matched. + const lineReader = () => { + let pending = ""; + return (chunk: Buffer) => { + pending += chunk.toString("utf8"); + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ""; + lines.forEach(onLine); + }; + }; + proc.stdout?.on("data", lineReader()); + proc.stderr?.on("data", lineReader()); + proc.on("exit", (code) => { + if (!settled) fail(new Error(`OpenCode server exited before listening (code ${code})${output ? `\n${output.trim()}` : ""}`)); + }); + proc.on("error", (error) => { + if (!settled) fail(error instanceof Error ? error : new Error(String(error))); + }); + }); + + managedServer = { proc, url }; registerShutdownHandlers(); - // Note: managed (in-process) SDK servers do not enforce auth, so this probe - // is unauthenticated by design. External servers, when present, are probed - // with credentials from `ensureServer`. - const status = await isServerRunning(created.url); - return { url: created.url, version: status.version }; + // Wait until the server answers /api/info with our credentials. + const deadline = Date.now() + Math.min(timeoutMs, 15000); + let status = await isServerRunning(url, username, password); + while (!status.healthy && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + status = await isServerRunning(url, username, password); + } + if (!status.healthy) { + killManagedServer(); + throw new Error(`OpenCode server started at ${url} but did not become healthy`); + } + return { url, version: status.version, password }; } export function stopServer(): void { - if (managedServer) { - managedServer.close(); - managedServer = null; - } + killManagedServer(); } export async function ensureServer( @@ -184,12 +281,26 @@ export async function ensureServer( url: baseUrl, }; } + if (existing.legacy) { + throw new Error( + `OpenCode 1.x server detected at ${baseUrl}; opencode-mcp 4.x requires OpenCode 2.x or newer.\n` + + `Upgrade OpenCode (https://opencode.ai/docs/) and restart its server.`, + ); + } + if (existing.authFailed) { + throw new Error( + `OpenCode server at ${baseUrl} rejected the configured credentials (HTTP 401).\n` + + `Set OPENCODE_SERVER_PASSWORD to the server's password. OpenCode 2.x prints a generated\n` + + `password at startup when none is configured.`, + ); + } if (!autoServe) { throw new Error( `OpenCode server is not running at ${baseUrl} and OPENCODE_AUTO_SERVE=false.\n` + `Start it manually: opencode serve --hostname 127.0.0.1 --port 4096\n` + - `Or reuse a TUI started with opencode --port 4096. Set OPENCODE_BASE_URL to its URL.\n` + + `Or reuse a TUI started with opencode --port 4096. Set OPENCODE_BASE_URL to its URL and\n` + + `OPENCODE_SERVER_PASSWORD to its password (printed on startup).\n` + `Set OPENCODE_AUTO_SERVE=true only to explicitly allow a separate local server.`, ); } @@ -200,7 +311,7 @@ export async function ensureServer( const startupKey = baseUrl.replace(/\/$/, ""); let inFlight = startServerInFlight.get(startupKey); if (!inFlight) { - inFlight = startServer(startupKey).finally(() => { + inFlight = startServer(startupKey, undefined, opts.username).finally(() => { startServerInFlight.delete(startupKey); }); startServerInFlight.set(startupKey, inFlight); @@ -213,5 +324,6 @@ export async function ensureServer( version: result.version, managedByUs: true, url: result.url, + password: result.password, }; } diff --git a/src/task-input.ts b/src/task-input.ts index 85158ee..8a7f33a 100644 --- a/src/task-input.ts +++ b/src/task-input.ts @@ -25,22 +25,41 @@ export const inputAnswerSchema = z.object({ ctx.addIssue({ code: "custom", message: "Question input requires answers or reject=true, exclusively" }); }); -function questions(item: PendingRequest): Array<{ question?: string; options?: Array<{ label: string }>; multiple?: boolean }> { - return Array.isArray(item.questions) ? item.questions : []; +/** A single OpenCode 2.x form field (Form.Field union). */ +interface FormField { + key?: string; + title?: string; + description?: string; + type?: string; + options?: Array<{ value?: string; label?: string }>; +} + +function formFields(item: PendingRequest): FormField[] { + return Array.isArray(item.fields) ? (item.fields as FormField[]) : []; +} +function isMultiselect(field: FormField): boolean { + return field?.type === "multiselect"; } export function inputRequests(items: PendingRequest[]): InputRequests { return Object.fromEntries(items.map(item => { const key = `${item.kind}:${item.id}`; if (item.kind === "permission") { - return [key, inputRequired.elicit({ message: `OpenCode requests ${String(item.permission ?? "permission")} for ${JSON.stringify(item.patterns ?? [])}`, + // OpenCode 2.x Permission.Request: { action, resources, message? }. + const action = typeof item.action === "string" ? item.action : "permission"; + const resources = Array.isArray(item.resources) ? item.resources.map(String) : []; + const note = typeof item.message === "string" && item.message ? `\n${item.message}` : ""; + return [key, inputRequired.elicit({ message: `OpenCode requests ${action} for ${JSON.stringify(resources)}${note}`, requestedSchema: { type: "object", properties: { decision: { type: "string", enum: ["once", "always", "reject"], description: "Choose explicitly; no approval is selected automatically" } }, required: ["decision"] } })]; } - const qs = questions(item); - const properties = Object.fromEntries(qs.map((q, index) => [`answer_${index}`, { - type: "string" as const, - description: `${q.question ?? "Answer"}${q.options?.length ? ` Options: ${q.options.map(o => o.label).join(", ")}.` : ""}${q.multiple ? " For multiple choices, enter a JSON array of labels." : ""}`, - }])); - return [key, inputRequired.elicit({ message: qs.map(q => q.question).join("\n") || "OpenCode needs your input", + const fields = formFields(item); + const properties = Object.fromEntries(fields.map((field, index) => { + const labels = (field.options ?? []).map(option => option.label ?? option.value ?? "").filter(Boolean); + return [`answer_${index}`, { + type: "string" as const, + description: `${field.description ?? field.title ?? "Answer"}${labels.length ? ` Options: ${labels.join(", ")}.` : ""}${isMultiselect(field) ? " For multiple choices, enter a JSON array of labels." : ""}`, + }]; + })); + return [key, inputRequired.elicit({ message: fields.map(field => field.title ?? field.description).filter(Boolean).join("\n") || (typeof item.title === "string" && item.title ? item.title : "OpenCode needs your input"), requestedSchema: { type: "object", properties, required: Object.keys(properties) } })]; })); } @@ -56,9 +75,9 @@ export function decodeInputResponses(items: PendingRequest[], responses: Record< ...(item.kind === "permission" ? { reply: "reject" as const } : { reject: true }) }]; if (item.kind === "permission") return [{ id: item.id, kind: item.kind, reply: z.enum(["once", "always", "reject"]).parse(response.content?.decision) }]; - const answers = questions(item).map((q, index) => { + const answers = formFields(item).map((field, index) => { const value = z.string().parse(response.content?.[`answer_${index}`]); - return q.multiple ? z.array(z.string()).parse(JSON.parse(value)) : [value]; + return isMultiselect(field) ? z.array(z.string()).parse(JSON.parse(value)) : [value]; }); return [{ id: item.id, kind: item.kind, answers }]; }); diff --git a/src/tools/config.ts b/src/tools/config.ts index 16c9651..0e65626 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; -import { toolJson, toolError, toolResult, redactSecrets, safeStringify, directoryParam } from "../helpers.js"; +import { toolJson, toolError, toolResult, redactSecrets, directoryParam } from "../helpers.js"; export function registerConfigTools(server: McpServer, client: OpenCodeClient) { server.tool( @@ -12,40 +12,37 @@ export function registerConfigTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const config = await client.get("/config", undefined, directory); - const c = redactSecrets(config) as Record; + const config = await client.get("/api/config", undefined, directory); + const redacted = redactSecrets(config); - // Build a compact summary instead of dumping entire config + // OpenCode 2.x returns a list of layered config documents: + // [{ type: "document", path, info }, ...]. Summarize each layer. const lines: string[] = []; + if (Array.isArray(redacted)) { + for (const doc of redacted as Array>) { + const path = typeof doc?.path === "string" ? doc.path : "(unknown source)"; + const info = (doc?.info ?? {}) as Record; + const keys = Object.keys(info); + lines.push(`- ${path}: {${keys.length} entries${keys.length <= 8 ? ` ${keys.join(", ")}` : ""}}`); + } + if (lines.length === 0) lines.push("(no configuration documents)"); + return toolResult( + `## OpenCode Config (${lines.length} layer(s))\n${lines.join("\n")}\n\nUse \`opencode_config_update\` to modify supported settings.`, + ); + } + + // Fallback for object-shaped configuration. + const c = redacted as Record; for (const [k, v] of Object.entries(c)) { if (v && typeof v === "object" && !Array.isArray(v)) { - const keys = Object.keys(v as Record); - if (keys.length <= 5) { - // Small objects: show inline - const inner = Object.entries(v as Record) - .map(([sk, sv]) => { - if (sv && typeof sv === "object") return `${sk}: {..}`; - return `${sk}: ${sv}`; - }) - .join(", "); - lines.push(`${k}: { ${inner} }`); - } else { - lines.push(`${k}: {${keys.length} entries}`); - } + lines.push(`${k}: {${Object.keys(v as Record).length} entries}`); } else if (Array.isArray(v)) { - if (v.length <= 3) { - lines.push(`${k}: ${JSON.stringify(v)}`); - } else { - lines.push(`${k}: [${v.length} items]`); - } + lines.push(`${k}: [${v.length} items]`); } else { lines.push(`${k}: ${v}`); } } - - return toolResult( - `## OpenCode Config\n${lines.join("\n")}\n\nUse \`opencode_config_update\` to modify specific settings.`, - ); + return toolResult(`## OpenCode Config\n${lines.join("\n")}`); } catch (e) { return toolError(e); } @@ -54,16 +51,16 @@ export function registerConfigTools(server: McpServer, client: OpenCodeClient) { server.tool( "opencode_config_update", - "Update the opencode configuration. Pass a partial config object with fields to update.", + "Update opencode configuration. OpenCode 2.x supports patching the default shell (`shell` field); pass a partial object with fields to update.", { config: z .record(z.string(), z.unknown()) - .describe("Partial config object with fields to update"), + .describe("Partial config object with fields to update (2.x servers support `shell`)"), directory: directoryParam, }, async ({ config, directory }) => { try { - return toolJson(redactSecrets(await client.patch("/config", config, directory))); + return toolJson(redactSecrets(await client.patch("/api/experimental/config", config, directory))); } catch (e) { return toolError(e); } @@ -72,55 +69,45 @@ export function registerConfigTools(server: McpServer, client: OpenCodeClient) { server.tool( "opencode_config_providers", - "List all configured providers and their default models", + "List all configured providers and the default model", { directory: directoryParam, }, async ({ directory }) => { try { - const raw = await client.get("/config/providers", undefined, directory); - const wrapper = raw as Record; - - // API returns { providers: [...], default: { providerId: modelId, ... } } - const providerList = Array.isArray(wrapper?.providers) - ? wrapper.providers as Array> - : Array.isArray(raw) - ? raw as Array> - : []; - const defaults = (wrapper?.default ?? {}) as Record; + const [providersRaw, modelsRaw, defaultsRaw] = await Promise.all([ + client.get("/api/provider", undefined, directory).catch(() => null), + client.get("/api/model", undefined, directory).catch(() => null), + client.get("/api/model/default", undefined, directory).catch(() => null), + ]); + const providerList = (Array.isArray(providersRaw) ? providersRaw : []) as Array>; + const models = (Array.isArray(modelsRaw) ? modelsRaw : []) as Array>; if (providerList.length === 0) { return toolResult("No configured providers found."); } + const byProvider = new Map(); + for (const model of models) { + const providerID = typeof model?.providerID === "string" ? model.providerID : "?"; + byProvider.set(providerID, (byProvider.get(providerID) ?? 0) + 1); + } + const defaults = (defaultsRaw ?? null) as Record | null; + const defaultLine = defaults && typeof defaults === "object" + ? `\n\nDefault model: ${String((defaults as any).providerID ?? "?")}/${String((defaults as any).id ?? "?")}` + : ""; + const lines = providerList.map((p) => { const id = (p.id ?? "?") as string; const name = (p.name ?? id) as string; - const models = p.models; - const modelCount = - Array.isArray(models) - ? models.length - : models && typeof models === "object" - ? Object.keys(models).length - : 0; - const defaultModel = defaults[id] ?? ""; - const defaultStr = defaultModel ? ` (default: ${defaultModel})` : ""; - return `- ${id} (${name}): ${modelCount} model${modelCount !== 1 ? "s" : ""}${defaultStr}`; + const modelCount = byProvider.get(id) ?? 0; + const activation = typeof p.activation === "string" ? ` [${p.activation}]` : ""; + return `- ${id} (${name}${activation}): ${modelCount} model${modelCount !== 1 ? "s" : ""}`; }); - // Show defaults summary if there are entries not in the provider list - const extraDefaults = Object.entries(defaults).filter( - ([id]) => !providerList.some((p) => p.id === id), + return toolResult( + `## Configured Providers (${providerList.length})\n${lines.join("\n")}${defaultLine}\n\nUse \`opencode_provider_models\` with a provider ID to see full model details.`, ); - const extraLines = extraDefaults.map(([id, model]) => `- ${id}: ${model}`); - - let output = `## Configured Providers (${providerList.length})\n${lines.join("\n")}`; - if (extraLines.length > 0) { - output += `\n\n## Additional Default Models\n${extraLines.join("\n")}`; - } - output += `\n\nUse \`opencode_provider_models\` with a provider ID to see full model details.`; - - return toolResult(output); } catch (e) { return toolError(e); } diff --git a/src/tools/events.ts b/src/tools/events.ts index 1250df5..efa0724 100644 --- a/src/tools/events.ts +++ b/src/tools/events.ts @@ -37,8 +37,10 @@ export function registerEventTools( const context = createRequestContext({ signal: extra?.signal, timeout: durationMs ?? 3000 }); let streamError: unknown; try { - for await (const evt of client.subscribeSSE(scope === "global" ? "/global/event" : "/event", { - signal: context.signal, deadline: context.deadline, directory, + // OpenCode 2.x exposes a single /api/event stream; project scope is + // expressed with the directory parameter, global scope without one. + for await (const evt of client.subscribeSSE("/api/event", { + signal: context.signal, deadline: context.deadline, ...(scope === "global" ? {} : { directory }), })) { events.push(evt); if (events.length >= (maxEvents ?? 50)) break; diff --git a/src/tools/file.ts b/src/tools/file.ts index 004fb12..3258345 100644 --- a/src/tools/file.ts +++ b/src/tools/file.ts @@ -6,36 +6,22 @@ import { toolJson, toolError, toolResult, directoryParam, readOnly } from "../he export function registerFileTools(server: McpServer, client: OpenCodeClient) { server.tool( "opencode_find_text", - "Search for text patterns in project files (regex supported). Returns file paths, line numbers, and matching lines.", + "Search for files and directories whose path matches a pattern. OpenCode 2.x matches names and paths (no line-level text search).", { pattern: z .string() - .describe("Text or regex pattern to search for in files"), + .describe("Text pattern matched against file and directory paths"), directory: directoryParam, }, readOnly, async ({ pattern, directory }) => { try { - const raw = await client.get("/find", { pattern }, directory); + const raw = await client.get("/api/fs/find", { query: pattern }, directory); const results = Array.isArray(raw) ? raw as Array> : []; if (results.length === 0) { return toolResult(`No matches found for pattern: ${pattern}`, false, { data: [] }); } - const formatted = results.map((r) => { - // The API returns path as {text: "file.ts"} and lines as {text: "content\n"} - const rawPath = r.path ?? r.file ?? r.name ?? ""; - const filePath = typeof rawPath === "string" - ? rawPath - : (rawPath as Record)?.text ?? (rawPath as Record)?.path ?? (rawPath as Record)?.name ?? String(rawPath); - const lineNum = r.line_number ?? r.lineNumber ?? r.line ?? "?"; - const rawLines = r.lines ?? r.text ?? r.content ?? ""; - const lineText = typeof rawLines === "string" - ? rawLines.trim() - : (rawLines as Record)?.text != null - ? String((rawLines as Record).text).trim() - : JSON.stringify(rawLines); - return `${filePath}:${lineNum} ${lineText}`; - }).join("\n"); + const formatted = results.map((r) => `${r.type === "directory" ? "[DIR] " : ""}${r.path ?? "?"}`).join("\n"); return toolResult(`${results.length} match(es):\n\n${formatted}`, false, { data: results }); } catch (e) { return toolError(e); @@ -45,17 +31,13 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { server.tool( "opencode_find_file", - "Find files and directories by name (fuzzy match)", + "Find files and directories by name", { query: z.string().describe("Search string for file/directory names"), type: z .enum(["file", "directory"]) .optional() .describe("Limit results to 'file' or 'directory'"), - searchDirectory: z - .string() - .optional() - .describe("Override the project root for the search"), limit: z .number() .optional() @@ -63,51 +45,17 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { directory: directoryParam, }, readOnly, - async ({ query, type, searchDirectory, limit, directory }) => { + async ({ query, type, limit, directory }) => { try { const q: Record = { query }; if (type) q.type = type; - if (searchDirectory) q.directory = searchDirectory; if (limit !== undefined) q.limit = String(limit); - const files = (await client.get("/find/file", q, directory)) as string[]; + const files = (await client.get("/api/fs/find", q, directory)) as Array>; if (!files || files.length === 0) { return toolResult(`No files found matching: ${query}`, false, { data: [] }); } - return toolResult(files.join("\n"), false, { data: files }); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_find_symbol", - "Find workspace symbols by name (functions, classes, variables, etc.)", - { - query: z.string().describe("Symbol name to search for"), - directory: directoryParam, - }, - readOnly, - async ({ query, directory }) => { - try { - const raw = await client.get("/find/symbol", { query }, directory); - const symbols = Array.isArray(raw) ? raw as Array> : []; - if (symbols.length === 0) { - return toolResult(`No symbols found matching: ${query}`, false, { data: [] }); - } - const lines = symbols.map((s) => { - const name = s.name ?? s.symbol ?? "?"; - const kind = s.kind ? ` (${s.kind})` : ""; - const location = s.location && typeof s.location === "object" - ? s.location as { uri?: string; range?: { start?: { line?: number } } } - : undefined; - const loc = location?.uri ?? (typeof s.location === "string" ? s.location : s.path ?? s.file ?? ""); - const line = typeof location?.range?.start?.line === "number" - ? location.range.start.line + 1 : s.line ?? s.lineNumber ?? ""; - const locStr = loc ? ` — ${loc}${line ? `:${line}` : ""}` : ""; - return `- ${name}${kind}${locStr}`; - }); - return toolResult(`${symbols.length} symbol(s) matching "${query}":\n\n${lines.join("\n")}`, false, { data: symbols }); + const formatted = files.map((f) => `${f.type === "directory" ? "[DIR] " : ""}${f.path ?? "?"}`).join("\n"); + return toolResult(formatted, false, { data: files }); } catch (e) { return toolError(e); } @@ -121,20 +69,21 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { path: z .string() .optional() - .describe("Path to list (defaults to project root)"), + .describe("Relative path to list (defaults to project root)"), directory: directoryParam, }, readOnly, async ({ path, directory }) => { try { - const q: Record = { path: path || "." }; - const nodes = (await client.get("/file", q, directory)) as Array>; + const q: Record = {}; + if (path) q.path = path; + const nodes = (await client.get("/api/fs/list", q, directory)) as Array>; if (!nodes || nodes.length === 0) { return toolResult("Empty directory."); } const formatted = nodes.map((n) => { const type = n.type === "directory" ? "[DIR]" : " "; - return `${type} ${n.name ?? n.path ?? "?"}`; + return `${type} ${n.path ?? "?"}`; }).join("\n"); return toolResult(formatted); } catch (e) { @@ -145,17 +94,17 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { server.tool( "opencode_file_read", - "Read the content of a file", + "Read the content of a file relative to the project root", { - path: z.string().describe("File path to read"), + path: z.string().describe("File path to read, relative to the project root"), directory: directoryParam, }, readOnly, async ({ path, directory }) => { try { - const result = (await client.get("/file/content", { path }, directory)) as Record; - if (typeof result.content === "string") { - return toolResult(`File: ${path}\n\n${result.content}`, false, { data: result }); + const result = await client.get(`/api/fs/read/${path.split("/").map(encodeURIComponent).join("/")}`, undefined, directory); + if (typeof result === "string") { + return toolResult(`File: ${path}\n\n${result}`, false, { data: result }); } return toolJson(result); } catch (e) { @@ -166,21 +115,23 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { server.tool( "opencode_file_status", - "Get status for tracked files (VCS changes: modified, added, deleted, etc.)", + "Get status for tracked files (VCS changes: modified, added, deleted)", { directory: directoryParam, }, readOnly, async ({ directory }) => { try { - const files = (await client.get("/file/status", undefined, directory)) as Array>; + const files = (await client.get("/api/vcs/status", undefined, directory)) as Array>; if (!files || files.length === 0) { return toolResult("No tracked file changes."); } const formatted = files.map((f) => { - const status = f.status ?? f.type ?? "?"; - const path = f.path ?? f.file ?? "?"; - return `[${status}] ${path}`; + const status = f.status ?? "?"; + const path = f.file ?? f.path ?? "?"; + const additions = typeof f.additions === "number" ? ` (+${f.additions}` : ""; + const deletions = typeof f.deletions === "number" ? ` -${f.deletions})` : ""; + return `[${status}] ${path}${additions || deletions ? `${additions}${deletions}` : ""}`; }).join("\n"); return toolResult(formatted); } catch (e) { diff --git a/src/tools/global.ts b/src/tools/global.ts index 0d8eaf8..0fa1b32 100644 --- a/src/tools/global.ts +++ b/src/tools/global.ts @@ -5,17 +5,17 @@ import { toolResult, toolError, directoryParam, readOnly } from "../helpers.js"; export function registerGlobalTools(server: McpServer, client: OpenCodeClient) { server.tool( "opencode_health", - "Check server health and version", + "Check OpenCode 2.x server health and version", { directory: directoryParam, }, readOnly, async ({ directory }) => { try { - const raw = await client.get("/global/health", undefined, directory) as Record; - const status = raw.healthy ? "healthy" : "unhealthy"; - const version = raw.version ?? "unknown"; - return toolResult(`Status: ${status}\nVersion: ${version}`); + const info = await client.get("/api/info", undefined, directory) as Record; + const version = info?.version ?? "unknown"; + const urls = Array.isArray(info?.urls) ? (info.urls as string[]).join(", ") : "unknown"; + return toolResult(`Status: healthy\nVersion: ${version}\nURLs: ${urls}`); } catch (e) { return toolError(e); } diff --git a/src/tools/input.ts b/src/tools/input.ts index 9e81f90..0b759bc 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -8,26 +8,50 @@ import { decodeInputResponses, inputAnswerSchema, inputRequests, supportsForm } const errorResult = (error: unknown) => toolResult(error instanceof Error ? error.message : String(error), true); +/** + * Resolve the session that owns a form. OpenCode 2.x forms are + * session-scoped: replies and dismissals address + * /api/session/{sessionID}/form/{formID}. + */ +async function formSession(client: OpenCodeClient, formId: string, directory: string | undefined, sessionId?: string): Promise { + if (sessionId) return sessionId; + const forms = await client.get("/api/form", undefined, directory); + const form = (Array.isArray(forms) ? forms : []).find((f) => f?.id === formId); + if (typeof form?.sessionID !== "string" || !form.sessionID) throw new Error("Form not found; supply the sessionId that owns it"); + return form.sessionID; +} + export function registerInputTools(server: McpServer, client: OpenCodeClient, jobs: JobService) { - server.tool("opencode_question_list", "List pending OpenCode questions, optionally filtered to a session.", { + server.tool("opencode_question_list", "List pending OpenCode forms, optionally filtered to a session.", { sessionId: z.string().optional(), directory: directoryParam, }, async ({ sessionId, directory }) => { try { - const questions = await client.get("/question", undefined, normalizeDirectory(directory)); - return toolJson(sessionId ? questions.filter(q => q.sessionID === sessionId) : questions); + const forms = await client.get("/api/form", undefined, normalizeDirectory(directory)); + return toolJson(sessionId ? forms.filter((f) => f.sessionID === sessionId) : forms); } catch (error) { return errorResult(error); } }); - server.tool("opencode_question_reply", "Answer an OpenCode question request. Supply one array of selected labels or free text per question.", { - requestId: z.string().min(1), answers: z.array(z.array(z.string())), directory: directoryParam, - }, async ({ requestId, answers, directory }) => { - try { return toolJson(await client.post(`/question/${encodeURIComponent(requestId)}/reply`, { answers }, { directory: normalizeDirectory(directory) })); } - catch (error) { return errorResult(error); } + server.tool("opencode_question_reply", "Answer a pending OpenCode form. Supply one value per field, keyed by field key.", { + requestId: z.string().min(1), + sessionId: z.string().optional().describe("Session that owns the form; auto-detected from the pending form list when omitted"), + answer: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])), + directory: directoryParam, + }, async ({ requestId, sessionId, answer, directory }) => { + try { + directory = normalizeDirectory(directory); + const sid = await formSession(client, requestId, directory, sessionId); + return toolJson(await client.post(`/api/session/${encodeURIComponent(sid)}/form/${encodeURIComponent(requestId)}/reply`, { answer }, { directory })); + } catch (error) { return errorResult(error); } }); - server.tool("opencode_question_reject", "Reject a pending OpenCode question explicitly.", { - requestId: z.string().min(1), directory: directoryParam, - }, async ({ requestId, directory }) => { - try { return toolJson(await client.post(`/question/${encodeURIComponent(requestId)}/reject`, undefined, { directory: normalizeDirectory(directory) })); } - catch (error) { return errorResult(error); } + server.tool("opencode_question_reject", "Dismiss a pending OpenCode form explicitly.", { + requestId: z.string().min(1), + sessionId: z.string().optional().describe("Session that owns the form; auto-detected from the pending form list when omitted"), + directory: directoryParam, + }, async ({ requestId, sessionId, directory }) => { + try { + directory = normalizeDirectory(directory); + const sid = await formSession(client, requestId, directory, sessionId); + return toolJson(await client.delete(`/api/session/${encodeURIComponent(sid)}/form/${encodeURIComponent(requestId)}`, undefined, directory)); + } catch (error) { return errorResult(error); } }); for (const action of ["get", "cancel"] as const) { server.tool(`opencode_job_${action}`, action === "get" ? "Observe a durable job by ID, including pending inputs and final results." : "Explicitly abort the OpenCode session owned by a job. This also stops other work in that session.", { diff --git a/src/tools/message.ts b/src/tools/message.ts index d15e517..9da2d60 100644 --- a/src/tools/message.ts +++ b/src/tools/message.ts @@ -1,7 +1,8 @@ import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; -import { createMessageId } from "../jobs.js"; +import { createRequestContext } from "../async.js"; +import { createMessageId, readSessionMessages } from "../jobs.js"; import { toolResult, toolError, @@ -10,16 +11,31 @@ import { formatMessageList, applyModelDefaults, directoryParam, - outputFormatParam, } from "../helpers.js"; +/** Switch the model a session uses (OpenCode 2.x selects models per session). */ +export async function switchSessionModel( + client: OpenCodeClient, + sessionId: string, + providerID: string, + modelID: string, + variant?: string, + directory?: string, +): Promise { + await client.post( + `/api/session/${encodeURIComponent(sessionId)}/model`, + { model: { id: modelID, providerID, ...(variant ? { variant } : {}) } }, + { directory }, + ); +} + export function registerMessageTools( server: McpServer, client: OpenCodeClient, ) { server.tool( "opencode_message_list", - "List all messages in a session with formatted output showing roles and content", + "List all messages in a session (newest first) with formatted output showing roles and content", { sessionId: z.string().describe("Session ID"), limit: z @@ -33,7 +49,7 @@ export function registerMessageTools( const query: Record = {}; if (limit !== undefined) query.limit = String(limit); const messages = await client.get( - `/session/${sessionId}/message`, + `/api/session/${encodeURIComponent(sessionId)}/message`, query, directory, ); @@ -55,7 +71,7 @@ export function registerMessageTools( async ({ sessionId, messageId, directory }) => { try { const msg = await client.get( - `/session/${sessionId}/message/${messageId}`, + `/api/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`, undefined, directory, ); @@ -68,59 +84,73 @@ export function registerMessageTools( server.tool( "opencode_message_send", - "Send a prompt message to a session and wait for the AI response. Use parts to send text, and optionally specify a model.", + "Send a prompt to a session and wait for the turn to finish. OpenCode 2.x prompts are queued in the session inbox; this tool waits for the run to complete and returns the assistant's answer.", { sessionId: z.string().describe("Session ID"), text: z.string().describe("The text message to send"), providerID: z .string() .optional() - .describe("Provider ID (e.g. 'anthropic')"), + .describe("Provider ID (e.g. 'anthropic'); switches the session model when combined with modelID"), modelID: z .string() .optional() - .describe("Model ID (e.g. 'claude-3-5-sonnet-20241022')"), + .describe("Model ID (e.g. 'claude-sonnet-4'); switches the session model when combined with providerID"), variant: z.string().optional().describe("Model variant (e.g. 'fast', 'smart')"), agent: z.string().optional().describe("Agent to use"), - noReply: z - .boolean() + timeoutMs: z + .number() + .int() + .min(1000) + .max(600000) .optional() - .describe( - "If true, inject context without triggering AI response (useful for plugins)", - ), - system: z.string().optional().describe("System prompt override"), - format: outputFormatParam, + .describe("How long to wait for the run to finish (default 300000 ms)"), directory: directoryParam, }, - async ({ - sessionId, - text, - providerID, - modelID, - variant, - agent, - noReply, - system, - format, - directory, - }) => { + async ({ sessionId, text, providerID, modelID, variant, agent, timeoutMs, directory }) => { try { - const body: Record = { - parts: [{ type: "text", text }], - }; const model = applyModelDefaults(providerID, modelID); - if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; - if (noReply !== undefined) body.noReply = noReply; - if (system) body.system = system; - if (format) body.format = format; - const response = await client.post( - `/session/${sessionId}/message`, - body, - { directory }, - ); + if (model) { + await switchSessionModel(client, sessionId, model.providerID, model.modelID, variant, directory); + } else if (variant) { + // No explicit model: read the session's current model and switch variant. + const session = await client.get(`/api/session/${encodeURIComponent(sessionId)}`, undefined, directory).catch(() => null); + const current = (session as Record | null)?.model as Record | undefined; + if (current?.id && current?.providerID) { + await switchSessionModel(client, sessionId, current.providerID, current.id, variant, directory); + } + } + // 2.x prompts accept neither an agent nor a model; switch the session first. + if (agent) { + await client.post(`/api/session/${encodeURIComponent(sessionId)}/agent`, { agent }, { directory }); + } + // delivery "queue" keeps this prompt a separate turn: the default + // "steer" would merge it into a still-running turn. + const submitted = await client.post(`/api/session/${encodeURIComponent(sessionId)}/prompt`, { text, delivery: "queue" }, { directory }) as Record; + const messageId = typeof submitted?.id === "string" ? submitted.id : undefined; + + // Wait for the session to become idle, then read the answer. + const context = createRequestContext({ timeout: timeoutMs ?? 300000 }); + try { + await client.post(`/api/experimental/session/${encodeURIComponent(sessionId)}/wait`, undefined, { + signal: context.signal, deadline: context.deadline, directory, + }); + } finally { + context.dispose(); + } + const messages = await readSessionMessages(client, sessionId, directory, {}, { targetId: messageId }); + const targetIndex = messageId ? messages.findIndex((m) => m?.id === messageId) : -1; + // The turn is everything after our user message, up to and including + // the idle marker that closes it; a newer queued turn starts only + // after that marker. + const rest = targetIndex >= 0 ? messages.slice(targetIndex + 1) : messages; + const idleIndex = rest.findIndex((m) => m?.type === "idle"); + const turn = idleIndex >= 0 ? rest.slice(0, idleIndex + 1) : rest; + const assistant = [...turn].reverse().find((m) => m?.type === "assistant"); + const idle = idleIndex >= 0 ? rest[idleIndex] : undefined; + + const response = assistant ?? submitted; const analysis = analyzeMessageResponse(response); const formatted = formatMessageResponse(response); const parts: string[] = []; @@ -128,9 +158,12 @@ export function registerMessageTools( if (analysis.warning) { parts.push(`\n--- WARNING ---\n${analysis.warning}`); } + if (idle?.outcome === "failed") { + parts.push(`\n--- WARNING ---\nThe run failed${idle.error ? `: ${JSON.stringify(idle.error)}` : ""}.`); + } return toolResult( parts.join("\n\n") || "Empty response.", - analysis.hasError, + analysis.hasError || idle?.outcome === "failed", { data: response }, ); } catch (e) { @@ -148,29 +181,35 @@ export function registerMessageTools( providerID: z .string() .optional() - .describe("Provider ID (e.g. 'anthropic')"), + .describe("Provider ID (e.g. 'anthropic'); switches the session model when combined with modelID"), modelID: z .string() .optional() - .describe("Model ID (e.g. 'claude-3-5-sonnet-20241022')"), + .describe("Model ID (e.g. 'claude-sonnet-4'); switches the session model when combined with providerID"), variant: z.string().optional().describe("Model variant (e.g. 'fast', 'smart')"), agent: z.string().optional().describe("Agent to use"), - format: outputFormatParam, directory: directoryParam, }, - async ({ sessionId, text, providerID, modelID, variant, agent, format, directory }) => { + async ({ sessionId, text, providerID, modelID, variant, agent, directory }) => { const messageId = createMessageId(); try { - const body: Record = { - messageID: messageId, - parts: [{ type: "text", text }], - }; const model = applyModelDefaults(providerID, modelID); - if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; - if (format) body.format = format; - await client.post(`/session/${sessionId}/prompt_async`, body, { directory }); + if (model) { + await switchSessionModel(client, sessionId, model.providerID, model.modelID, variant, directory); + } else if (variant) { + const session = await client.get(`/api/session/${encodeURIComponent(sessionId)}`, undefined, directory).catch(() => null); + const current = (session as Record | null)?.model as Record | undefined; + if (current?.id && current?.providerID) { + await switchSessionModel(client, sessionId, current.providerID, current.id, variant, directory); + } + } + // 2.x prompts accept neither an agent nor a model; switch the session first. + if (agent) { + await client.post(`/api/session/${encodeURIComponent(sessionId)}/agent`, { agent }, { directory }); + } + // delivery "queue" keeps this prompt a separate turn: the default + // "steer" would merge it into a still-running turn. + await client.post(`/api/session/${encodeURIComponent(sessionId)}/prompt`, { id: messageId, text, delivery: "queue" }, { directory }); return toolResult( `Message sent asynchronously. Use opencode_wait({sessionId: "${sessionId}", messageId: "${messageId}"}) to observe this exact turn.`, false, @@ -184,43 +223,23 @@ export function registerMessageTools( server.tool( "opencode_command_execute", - "Execute a slash command in a session (e.g. /init, /undo, /redo)", + "Execute a slash command in a session (e.g. /init, /review)", { sessionId: z.string().describe("Session ID"), command: z .string() - .describe("The slash command to execute (e.g. 'init', 'undo')"), + .describe("The slash command to execute (e.g. 'init', 'review')"), arguments: z .string() .optional() - .describe("Arguments for the command"), - agent: z.string().optional().describe("Agent to use"), - providerID: z.string().optional().describe("Provider ID"), - modelID: z.string().optional().describe("Model ID"), - variant: z.string().optional().describe("Model variant"), + .describe("Arguments for the command (sent as the command text)"), directory: directoryParam, }, - async ({ - sessionId, - command, - arguments: args, - agent, - providerID, - modelID, - variant, - directory, - }) => { + async ({ sessionId, command, arguments: args, directory }) => { try { - const body: Record = { - command, - arguments: args ?? "", - }; - if (agent) body.agent = agent; - const cmdModel = applyModelDefaults(providerID, modelID); - if (cmdModel) body.model = `${cmdModel.providerID}/${cmdModel.modelID}`; - if (variant) body.variant = variant; + const body: Record = { name: command, text: args ?? "" }; const result = await client.post( - `/session/${sessionId}/command`, + `/api/session/${encodeURIComponent(sessionId)}/command`, body, { directory }, ); @@ -237,19 +256,13 @@ export function registerMessageTools( { sessionId: z.string().describe("Session ID"), command: z.string().describe("Shell command to execute"), - agent: z.string().describe("Agent to use for the shell command"), - providerID: z.string().optional().describe("Provider ID"), - modelID: z.string().optional().describe("Model ID"), directory: directoryParam, }, - async ({ sessionId, command, agent, providerID, modelID, directory }) => { + async ({ sessionId, command, directory }) => { try { - const body: Record = { command, agent }; - const shellModel = applyModelDefaults(providerID, modelID); - if (shellModel) body.model = shellModel; const result = await client.post( - `/session/${sessionId}/shell`, - body, + `/api/session/${encodeURIComponent(sessionId)}/shell`, + { command }, { directory }, ); return toolResult(formatMessageResponse(result), false, { data: result }); diff --git a/src/tools/misc.ts b/src/tools/misc.ts index 557b759..0ffce54 100644 --- a/src/tools/misc.ts +++ b/src/tools/misc.ts @@ -1,40 +1,30 @@ import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; -import { toolJson, toolError, toolResult, directoryParam, destructive, readOnly } from "../helpers.js"; +import { toolJson, toolError, toolResult, directoryParam, readOnly } from "../helpers.js"; export function registerMiscTools(server: McpServer, client: OpenCodeClient) { // --- Path & VCS --- server.tool( "opencode_path_get", - "Get the current working path of the opencode server", + "Get the active directory and project of the OpenCode server", { directory: directoryParam, }, + readOnly, async ({ directory }) => { try { - const raw = await client.get("/path", undefined, directory); - const p = raw as Record; - if (p && typeof p === "object" && !Array.isArray(p)) { - const labels: Record = { - worktree: "Working directory", - directory: "Project directory", - config: "Config", - state: "State", - home: "Home", - }; - const lines: string[] = []; - for (const [key, label] of Object.entries(labels)) { - if (p[key]) lines.push(`${label}: ${p[key]}`); - } - // Any extra keys not in our map - for (const [k, v] of Object.entries(p)) { - if (!labels[k]) lines.push(`${k}: ${v}`); - } - return toolResult(lines.length > 0 ? lines.join("\n") : JSON.stringify(p, null, 2)); + const raw = await client.get("/api/location", undefined, directory); + const location = raw as Record; + const lines: string[] = []; + if (typeof location?.directory === "string") lines.push(`Directory: ${location.directory}`); + const project = location?.project as Record | undefined; + if (project) { + if (typeof project.id === "string") lines.push(`Project ID: ${project.id}`); + if (typeof project.directory === "string") lines.push(`Project directory: ${project.directory}`); } - return toolJson(raw); + return toolResult(lines.length > 0 ? lines.join("\n") : JSON.stringify(location, null, 2), false, { data: location }); } catch (e) { return toolError(e); } @@ -43,27 +33,26 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { server.tool( "opencode_vcs_info", - "Get VCS (version control) info for the current project (branch, remote, status)", + "Get VCS (version control) info for the current project (provider, branches)", { directory: directoryParam, }, + readOnly, async ({ directory }) => { try { - const raw = await client.get("/vcs", undefined, directory); + const raw = await client.get("/api/vcs", undefined, directory); const v = raw as Record; if (!v || typeof v !== "object") { return toolResult("No VCS info available."); } const lines: string[] = []; - if (v.branch) lines.push(`Branch: ${v.branch}`); - if (v.remote) lines.push(`Remote: ${v.remote}`); - if (v.sha) lines.push(`HEAD: ${v.sha}`); - if (v.dirty !== undefined) lines.push(`Dirty: ${v.dirty}`); - if (v.ahead !== undefined) lines.push(`Ahead: ${v.ahead}`); - if (v.behind !== undefined) lines.push(`Behind: ${v.behind}`); - if (v.type) lines.push(`Type: ${v.type}`); + if (v.provider) lines.push(`Provider: ${v.provider}`); + const branch = v.branch as Record | undefined; + if (branch) { + if (branch.current) lines.push(`Branch: ${branch.current}`); + if (branch.default) lines.push(`Default branch: ${branch.default}`); + } if (lines.length === 0) { - // Fallback: show all keys for (const [k, val] of Object.entries(v)) { lines.push(`${k}: ${typeof val === "object" ? JSON.stringify(val) : val}`); } @@ -75,25 +64,6 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, ); - // --- Instance --- - - server.tool( - "opencode_instance_dispose", - "Dispose the current opencode instance (shuts it down). WARNING: This is destructive and will terminate the server.", - { - directory: directoryParam, - }, - destructive, - async ({ directory }) => { - try { - await client.post("/instance/dispose", undefined, { directory }); - return toolResult("Instance disposed."); - } catch (e) { - return toolError(e); - } - }, - ); - // --- Agents --- server.tool( @@ -102,9 +72,10 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { { directory: directoryParam, }, + readOnly, async ({ directory }) => { try { - const agents = (await client.get("/agent", undefined, directory)) as Array>; + const agents = (await client.get("/api/agent", undefined, directory)) as Array>; if (!agents || agents.length === 0) { return toolResult("No agents found."); } @@ -112,7 +83,8 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { const name = a.name ?? a.id ?? "?"; const mode = a.mode ?? "?"; const desc = a.description ?? "(no description)"; - return `- ${name} [${mode}]: ${desc}`; + const hidden = a.hidden === true ? " (hidden)" : ""; + return `- ${name} [${mode}]${hidden}: ${desc}`; }).join("\n"); return toolResult(formatted); } catch (e) { @@ -129,9 +101,10 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { { directory: directoryParam, }, + readOnly, async ({ directory }) => { try { - const raw = await client.get("/command", undefined, directory); + const raw = await client.get("/api/command", undefined, directory); const commands = Array.isArray(raw) ? raw as Array> : []; if (commands.length === 0) { return toolResult("No commands available."); @@ -139,7 +112,6 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { const lines = commands.map((c) => { const name = c.name ?? c.id ?? "?"; const rawDesc = (c.description ?? c.desc ?? "") as string; - // Truncate to first line, cap at 120 chars to avoid multi-line descriptions leaking const desc = rawDesc.split("\n")[0].slice(0, 120); return `- /${name}${desc ? `: ${desc}` : ""}`; }); @@ -150,73 +122,6 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, ); - // --- LSP --- - - server.tool( - "opencode_lsp_status", - "Get the status of LSP (Language Server Protocol) servers", - { - directory: directoryParam, - }, - async ({ directory }) => { - try { - const raw = await client.get("/lsp", undefined, directory); - const servers = Array.isArray(raw) ? raw as Array> : []; - if (servers.length === 0) { - return toolResult("No LSP servers running."); - } - const lines = servers.map((s) => { - const name = s.name ?? s.id ?? s.language ?? "?"; - const status = s.status ?? s.state ?? "unknown"; - const lang = s.language ? ` (${s.language})` : ""; - return `- ${name}${lang}: ${status}`; - }); - return toolResult(`## LSP Servers (${servers.length})\n${lines.join("\n")}`); - } catch (e) { - return toolError(e); - } - }, - ); - - // --- Formatter --- - - server.tool( - "opencode_formatter_status", - "Get the status of configured formatters", - { - directory: directoryParam, - }, - async ({ directory }) => { - try { - const raw = await client.get("/formatter", undefined, directory); - const formatters = Array.isArray(raw) ? raw as Array> : []; - if (formatters.length === 0) { - return toolResult("No formatters configured."); - } - const enabled = formatters.filter((f) => f.enabled || f.status === "enabled" || f.active); - const disabled = formatters.filter((f) => !enabled.includes(f)); - const lines: string[] = []; - if (enabled.length > 0) { - lines.push("**Enabled:**"); - for (const f of enabled) { - const name = f.name ?? f.id ?? "?"; - const globs = f.globs ?? f.patterns ?? ""; - const globStr = Array.isArray(globs) ? ` (${globs.join(", ")})` : globs ? ` (${globs})` : ""; - lines.push(`- ${name}${globStr}`); - } - } - if (disabled.length > 0) { - lines.push(`\n+${disabled.length} more formatters available but not enabled.`); - } - return toolResult( - `## Formatters (${enabled.length} enabled / ${formatters.length} total)\n${lines.join("\n")}`, - ); - } catch (e) { - return toolError(e); - } - }, - ); - // --- MCP servers --- server.tool( @@ -225,32 +130,17 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { { directory: directoryParam, }, + readOnly, async ({ directory }) => { try { - const raw = await client.get("/mcp", undefined, directory); - // MCP status may be an object keyed by server name or an array - if (raw && typeof raw === "object" && !Array.isArray(raw)) { - const entries = Object.entries(raw as Record); - if (entries.length === 0) { - return toolResult("No MCP servers configured."); - } - const lines = entries.map(([name, value]) => { - const s = value as Record; - const status = s.status ?? s.state ?? "unknown"; - const tools = s.tools; - const toolCount = Array.isArray(tools) ? tools.length : typeof tools === "number" ? tools : ""; - const toolStr = toolCount !== "" ? ` | ${toolCount} tools` : ""; - return `- ${name}: ${status}${toolStr}`; - }); - return toolResult(`## MCP Servers (${entries.length})\n${lines.join("\n")}`); - } + const raw = await client.get("/api/mcp", undefined, directory); const servers = Array.isArray(raw) ? raw as Array> : []; if (servers.length === 0) { return toolResult("No MCP servers configured."); } const lines = servers.map((s) => { const name = s.name ?? s.id ?? "?"; - const status = s.status ?? s.state ?? "unknown"; + const status = (s.status as Record | undefined)?.status ?? s.status ?? "unknown"; return `- ${name}: ${status}`; }); return toolResult(`## MCP Servers (${servers.length})\n${lines.join("\n")}`); @@ -262,99 +152,17 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { server.tool( "opencode_mcp_add", - "Add an MCP server dynamically to opencode", + "Add or update an MCP server configuration in opencode", { name: z.string().describe("Name for the MCP server"), config: z .record(z.string(), z.unknown()) - .describe("MCP server configuration object"), + .describe("MCP server configuration object (e.g. {type: 'local', command: [...]} or {type: 'remote', url: '...'})"), directory: directoryParam, }, async ({ name, config, directory }) => { try { - return toolJson(await client.post("/mcp", { name, config }, { directory })); - } catch (e) { - return toolError(e); - } - }, - ); - - // --- Tools (Experimental) --- - - server.tool( - "opencode_tool_ids", - "List all available tool IDs that the LLM can use (experimental)", - { - directory: directoryParam, - }, - async ({ directory }) => { - try { - const raw = await client.get("/experimental/tool/ids", undefined, directory); - const ids = Array.isArray(raw) ? raw as string[] : []; - if (ids.length === 0) { - return toolResult("No tools available."); - } - return toolResult(`## Tools (${ids.length})\n${ids.map((id) => `- ${id}`).join("\n")}`); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_tool_list", - "List tools with JSON schemas for a given provider and model (experimental)", - { - provider: z.string().describe("Provider ID"), - model: z.string().describe("Model ID"), - directory: directoryParam, - }, - async ({ provider, model, directory }) => { - try { - const raw = await client.get("/experimental/tool", { provider, model }, directory); - const tools = Array.isArray(raw) ? raw as Array> : []; - if (tools.length === 0) { - return toolResult("No tools available for this provider/model."); - } - const lines = tools.map((t) => { - const id = t.id ?? t.name ?? "?"; - const desc = t.description as string | undefined; - // Show first line of description only (many are multi-paragraph) - const shortDesc = desc ? desc.split("\n")[0].slice(0, 120) : ""; - return `- ${id}${shortDesc ? `: ${shortDesc}` : ""}`; - }); - return toolResult( - `## Tools for ${provider}/${model} (${tools.length})\n${lines.join("\n")}`, - ); - } catch (e) { - return toolError(e); - } - }, - ); - - // --- Logging --- - - server.tool( - "opencode_log", - "Write a log entry to the opencode server", - { - service: z.string().describe("Service name for the log entry"), - level: z - .enum(["debug", "info", "warn", "error"]) - .describe("Log level"), - message: z.string().describe("Log message"), - extra: z - .record(z.string(), z.unknown()) - .optional() - .describe("Extra data to include in the log entry"), - directory: directoryParam, - }, - async ({ service, level, message, extra, directory }) => { - try { - const body: Record = { service, level, message }; - if (extra) body.extra = extra; - await client.post("/log", body, { directory }); - return toolResult(`Log entry written [${level}] ${service}: ${message}`); + return toolJson(await client.put(`/api/experimental/mcp/${encodeURIComponent(name)}`, { config }, directory)); } catch (e) { return toolError(e); } diff --git a/src/tools/project.ts b/src/tools/project.ts index 035ebde..0e14639 100644 --- a/src/tools/project.ts +++ b/src/tools/project.ts @@ -95,13 +95,13 @@ export function registerProjectTools( }, async ({ directory }) => { try { - const raw = await client.get("/project", undefined, directory); + const raw = await client.get("/api/project", undefined, directory); const projects = Array.isArray(raw) ? raw as Array> : []; if (projects.length === 0) { return toolResult("No projects found."); } const lines = projects.map((p) => { - const worktree = (p.worktree ?? "?") as string; + const worktree = (p.canonical ?? p.worktree ?? "?") as string; const name = (worktree !== "?" ? worktree.split("/").filter(Boolean).pop() : undefined) ?? p.name ?? p.id ?? "(root)"; const vcs = p.vcs ? ` [${p.vcs}]` : ""; @@ -183,7 +183,7 @@ export function registerProjectTools( // ── Register with OpenCode server (best-effort) ───────────── try { - await client.get("/project/current", undefined, realPath); + await client.get("/api/location", undefined, realPath); } catch (e) { console.error( `opencode_project_init: project ping failed for ${realPath}: ${e instanceof Error ? e.message : String(e)}`, @@ -207,10 +207,19 @@ export function registerProjectTools( }, async ({ directory }) => { try { - const raw = await client.get("/project/current", undefined, directory); - const p = raw as Record; - if (p && typeof p === "object") { - return toolResult(formatProject(p)); + // OpenCode 2.x exposes the active location (directory + project) via + // GET /api/location. `directory` scopes which project resolves. + const raw = await client.get("/api/location", undefined, directory); + const location = raw as Record; + if (location && typeof location === "object") { + const project = (location.project ?? null) as Record | null; + const dir = typeof location.directory === "string" ? location.directory : "?"; + const lines = [`Directory: ${dir}`]; + if (project) { + lines.push(`Project ID: ${project.id ?? "?"}`); + if (typeof project.directory === "string") lines.push(`Project directory: ${project.directory}`); + } + return toolResult(lines.join("\n"), false, { data: location }); } return toolJson(raw); } catch (e) { diff --git a/src/tools/provider.ts b/src/tools/provider.ts index 10f5ad3..39d2c99 100644 --- a/src/tools/provider.ts +++ b/src/tools/provider.ts @@ -1,7 +1,21 @@ import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; -import { toolJson, toolError, toolResult, directoryParam, isProviderConfigured } from "../helpers.js"; +import { toolJson, toolError, toolResult, directoryParam } from "../helpers.js"; + +/** + * A v2 provider is usable when an active credential exists for its integration + * or its settings carry an API key. + */ +function providerHasCredential( + provider: Record, + credentials: Array>, +): boolean { + const integrationID = typeof provider.integrationID === "string" ? provider.integrationID : provider.id; + if (credentials.some((c) => c.active === true && (c.integrationID === integrationID || c.integrationID === provider.id))) return true; + const settings = provider.settings as Record | undefined; + return typeof settings?.apiKey === "string" && settings.apiKey.length > 0; +} export function registerProviderTools( server: McpServer, @@ -9,59 +23,44 @@ export function registerProviderTools( ) { server.tool( "opencode_provider_list", - "List all configured providers with their connection status. Returns a compact summary — use opencode_provider_models to see models for a specific provider.", + "List all providers with their configuration status. Returns a compact summary — use opencode_provider_models to see models for a specific provider.", { directory: directoryParam, }, async ({ directory }) => { try { - const raw = await client.get("/provider", undefined, directory); - const providers = ( - raw && typeof raw === "object" && "all" in (raw as Record) - ? (raw as Record).all - : raw - ) as Array>; - - if (!Array.isArray(providers) || providers.length === 0) { + const [providersRaw, credentialsRaw] = await Promise.all([ + client.get("/api/provider", undefined, directory).catch(() => null), + client.get("/api/credential", undefined, directory).catch(() => null), + ]); + const providers = (Array.isArray(providersRaw) ? providersRaw : []) as Array>; + const credentials = (Array.isArray(credentialsRaw) ? credentialsRaw : []) as Array>; + + if (providers.length === 0) { return toolResult("No providers configured."); } - const countModels = (p: Record) => { - const m = p.models; - return Array.isArray(m) ? m.length : m && typeof m === "object" ? Object.keys(m).length : 0; - }; - - const configured = providers.filter(isProviderConfigured); - const unconfigured = providers.filter((p) => !isProviderConfigured(p)); + const configured = providers.filter((p) => providerHasCredential(p, credentials)); + const unconfigured = providers.filter((p) => !providerHasCredential(p, credentials)); const lines: string[] = []; - if (configured.length > 0) { lines.push("**Configured:**"); for (const p of configured) { - const id = p.id ?? p.name ?? "?"; - const mc = countModels(p); - const envVars = Array.isArray(p.env) ? ` (via ${(p.env as string[]).join(", ")})` : ""; - lines.push(`- ${id}: ${mc} model${mc !== 1 ? "s" : ""}${envVars}`); + const activation = typeof p.activation === "string" ? ` [${p.activation}]` : ""; + lines.push(`- ${p.id}${activation}`); } } else { - lines.push("**No providers configured.** Use `opencode_auth_set` or set environment variables."); + lines.push("**No providers configured.** Use `opencode_auth_set` or OAuth via `opencode_provider_oauth_authorize`."); } if (unconfigured.length > 0) { lines.push(`\n**Not configured:** ${unconfigured.length} more providers available`); - // Show up to 5 popular ones - const popularIds = new Set(["anthropic", "openai", "google", "openrouter", "groq", "deepseek", "mistral", "huggingface"]); + const popularIds = new Set(["anthropic", "openai", "google", "openrouter", "groq", "deepseek", "mistral"]); const popular = unconfigured.filter((p) => popularIds.has(p.id as string)).slice(0, 5); - if (popular.length > 0) { - for (const p of popular) { - const id = p.id ?? "?"; - const mc = countModels(p); - lines.push(`- ${id}: ${mc} model${mc !== 1 ? "s" : ""}`); - } - const rest = unconfigured.length - popular.length; - if (rest > 0) lines.push(`- ... and ${rest} more`); - } + for (const p of popular) lines.push(`- ${p.id}`); + const rest = unconfigured.length - popular.length; + if (rest > 0) lines.push(`- ... and ${rest} more`); } return toolResult( @@ -88,23 +87,13 @@ export function registerProviderTools( }, async ({ providerId, limit, directory }) => { try { - const raw = await client.get("/provider", undefined, directory); - const providers = ( - raw && typeof raw === "object" && "all" in (raw as Record) - ? (raw as Record).all - : raw - ) as Array>; - - if (!Array.isArray(providers)) { - return toolError("Unexpected provider response format."); - } - - const provider = providers.find( - (p) => (p.id ?? p.name) === providerId, - ); - - if (!provider) { - const allIds = providers.map((p) => p.id ?? p.name); + const [modelsRaw, providersRaw] = await Promise.all([ + client.get("/api/model", undefined, directory), + client.get("/api/provider", undefined, directory).catch(() => null), + ]); + const providers = (Array.isArray(providersRaw) ? providersRaw : []) as Array>; + if (!providers.some((p) => p.id === providerId)) { + const allIds = providers.map((p) => p.id); const shown = allIds.slice(0, 10); const rest = allIds.length - shown.length; const available = shown.join(", ") + (rest > 0 ? ` ... and ${rest} more` : ""); @@ -114,34 +103,25 @@ export function registerProviderTools( ); } - const configured = isProviderConfigured(provider); - const rawModels = provider.models; - const modelList: Array> = Array.isArray(rawModels) - ? rawModels - : rawModels && typeof rawModels === "object" - ? Object.values(rawModels as Record) - : []; - + const models = (Array.isArray(modelsRaw) ? modelsRaw : []) as Array>; + const modelList = models.filter((m) => m.providerID === providerId); if (modelList.length === 0) { - return toolResult( - `Provider "${providerId}" (${configured ? "configured" : "NOT CONFIGURED"}) has no models available.`, - ); + return toolResult(`Provider "${providerId}" has no models available.`); } const maxItems = limit === 0 ? modelList.length : (limit ?? 30); const shown = modelList.slice(0, maxItems); - const lines = shown.map((m: Record) => { - const id = m.id ?? m.name ?? "?"; + const lines = shown.map((m) => { + const id = m.id ?? m.modelID ?? "?"; const name = m.name && m.name !== m.id ? ` — ${m.name}` : ""; return `- ${id}${name}`; }); - const truncNote = modelList.length > maxItems ? `\n\n... and ${modelList.length - maxItems} more. Use \`limit: 0\` to see all.` : ""; return toolResult( - `## ${providerId} (${configured ? "configured" : "NOT CONFIGURED"}) — ${modelList.length} model${modelList.length !== 1 ? "s" : ""}\n${lines.join("\n")}${truncNote}`, + `## ${providerId} — ${modelList.length} model${modelList.length !== 1 ? "s" : ""}\n${lines.join("\n")}${truncNote}`, ); } catch (e) { return toolError(e); @@ -151,51 +131,55 @@ export function registerProviderTools( server.tool( "opencode_provider_auth_methods", - "Get available authentication methods for all providers", + "List integrations and the connection methods they support (API key, environment variable, OAuth, command). Provider IDs map to integration IDs.", { directory: directoryParam, }, async ({ directory }) => { try { - const raw = await client.get("/provider/auth", undefined, directory); - if (raw && typeof raw === "object" && !Array.isArray(raw)) { - const entries = Object.entries(raw as Record); - if (entries.length === 0) { - return toolResult("No authentication methods available."); - } - const lines = entries.map(([providerId, methods]) => { - const arr = Array.isArray(methods) ? methods as Array> : []; - const methodDescs = arr.map((m, index) => { - const type = m.type ?? "?"; - const label = m.label ?? ""; - return `[${index}] ${label ? `${type} (${label})` : String(type)}`; - }); - return `- ${providerId}: ${methodDescs.join(", ") || "none"}`; - }); - return toolResult(`## Auth Methods (${entries.length} providers)\n${lines.join("\n")}`, false, { data: raw }); + const raw = await client.get("/api/integration", undefined, directory); + const integrations = (Array.isArray(raw) ? raw : []) as Array>; + if (integrations.length === 0) { + return toolResult("No integrations available."); } - return toolJson(raw); + const lines = integrations.map((integration) => { + const methods = (Array.isArray(integration.methods) ? integration.methods : []) as Array>; + const connections = (Array.isArray(integration.connections) ? integration.connections : []) as Array>; + const methodDescs = methods.map((m) => { + if (m.type === "env") return `env(${(m.names as string[] | undefined)?.join(", ") ?? ""})`; + return String(m.type ?? "?"); + }); + return `- ${integration.id}: ${methodDescs.join(", ") || "none"}${connections.length ? ` (${connections.length} connection(s))` : ""}`; + }); + return toolResult(`## Integrations (${integrations.length})\n${lines.join("\n")}`, false, { data: raw }); } catch (e) { return toolError(e); } }, ); - // Auth routes run OUTSIDE the directory middleware in the OpenCode server, - // so these tools do not accept a directory parameter. + // Integration routes are addressed by integration ID (provider IDs map to the + // same value in most cases), so these tools do not take a directory. server.tool( "opencode_provider_oauth_authorize", - "Start OAuth authorization for a provider", + "Start OAuth authorization for a provider's integration", { - providerId: z.string().describe("Provider ID to authorize"), - method: z.number().int().nonnegative().default(0).describe("Auth method index from opencode_provider_auth_methods (default 0)"), - inputs: z.record(z.string(), z.string()).optional().describe("Additional inputs requested by the selected authentication method"), + providerId: z.string().describe("Provider/integration ID to authorize"), + methodID: z.string().optional().describe("Specific method ID to use (defaults to the first OAuth method)"), }, - async ({ providerId, method, inputs }) => { + async ({ providerId, methodID }) => { try { + let method = methodID; + if (!method) { + // The server requires a methodID; default to the integration's first OAuth method. + const integrations = await client.get }>>("/api/integration"); + const entry = Array.isArray(integrations) ? integrations.find((item) => item?.id === providerId) : undefined; + method = entry?.methods?.find((item) => item?.type === "oauth" && item.id)?.id; + if (!method) throw new Error(`No OAuth method is available for ${providerId}; pass methodID or see opencode_provider_auth_methods.`); + } return toolJson( - await client.post(`/provider/${encodeURIComponent(providerId)}/oauth/authorize`, { method: method ?? 0, ...(inputs ? { inputs } : {}) }), + await client.post(`/api/integration/${encodeURIComponent(providerId)}/connect/oauth`, { methodID: method }), ); } catch (e) { return toolError(e); @@ -205,19 +189,17 @@ export function registerProviderTools( server.tool( "opencode_provider_oauth_callback", - "Handle OAuth callback for a provider", + "Complete OAuth authorization for a provider's integration with the callback code", { - providerId: z.string().describe("Provider ID"), - callbackData: z.object({ - method: z.number().int().nonnegative().describe("The method index used for authorization"), - code: z.string().optional().describe("Authorization code for methods that request one"), - }).describe("OAuth callback data"), + providerId: z.string().describe("Provider/integration ID"), + attemptID: z.string().describe("Authorization attempt ID returned by opencode_provider_oauth_authorize"), + code: z.string().optional().describe("Authorization code (when the redirect supplies one)"), }, - async ({ providerId, callbackData }) => { + async ({ providerId, attemptID, code }) => { try { await client.post( - `/provider/${encodeURIComponent(providerId)}/oauth/callback`, - callbackData, + `/api/integration/${encodeURIComponent(providerId)}/connect/oauth/${encodeURIComponent(attemptID)}/complete`, + code !== undefined ? { code } : {}, ); return toolResult("OAuth callback processed."); } catch (e) { @@ -228,16 +210,21 @@ export function registerProviderTools( server.tool( "opencode_auth_set", - "Set authentication credentials for a provider (e.g. API key). Credentials are stored globally and shared across all projects.", + "Set an API key credential for a provider's integration. Credentials are stored globally and shared across all projects.", { - providerId: z.string().describe("Provider ID (e.g. 'anthropic')"), - type: z.string().describe("Auth type (e.g. 'api')"), - key: z.string().describe("API key or credential value"), + providerId: z.string().describe("Provider/integration ID (e.g. 'anthropic')"), + key: z.string().describe("API key value"), + label: z.string().optional().describe("Optional label for the stored credential"), }, - async ({ providerId, type, key }) => { + async ({ providerId, key, label }) => { try { - await client.put(`/auth/${providerId}`, { type, key }); - return toolResult(`Auth credentials set for ${providerId}.`); + await client.post("/api/credential", { + integrationID: providerId, + value: { type: "key", key }, + activate: true, + ...(label ? { label } : {}), + }); + return toolResult(`Credentials set for ${providerId}.`); } catch (e) { return toolError(e); } diff --git a/src/tools/session.ts b/src/tools/session.ts index 2c69d21..e08f9f7 100644 --- a/src/tools/session.ts +++ b/src/tools/session.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; -import { toolError, formatSessionList, formatDiffResponse, resolveSessionStatus, toolResult, directoryParam, destructive, readOnly } from "../helpers.js"; +import { toolError, formatSessionList, formatDiffResponse, toolResult, directoryParam, destructive, readOnly } from "../helpers.js"; /** Format a single session object into a compact human-readable summary. */ function formatSession(raw: unknown): string { @@ -10,30 +10,17 @@ function formatSession(raw: unknown): string { const lines: string[] = []; if (s.id) lines.push(`ID: ${s.id}`); if (s.title) lines.push(`Title: ${s.title}`); - if (s.slug) lines.push(`Slug: ${s.slug}`); if (s.parentID) lines.push(`Parent: ${s.parentID}`); - // Time field may be {created, updated} timestamps (ms since epoch) const time = s.time as Record | undefined; if (time?.created) { lines.push(`Created: ${new Date(time.created as number).toISOString()}`); - } else if (s.createdAt) { - lines.push(`Created: ${s.createdAt}`); } if (time?.updated) { lines.push(`Updated: ${new Date(time.updated as number).toISOString()}`); - } else if (s.updatedAt) { - lines.push(`Updated: ${s.updatedAt}`); - } - if (s.status) lines.push(`Status: ${s.status}`); - if (s.version) lines.push(`Version: ${s.version}`); - if (s.directory) lines.push(`Directory: ${s.directory}`); - if (s.shareUrl) lines.push(`Share URL: ${s.shareUrl}`); - // Show summary if present - const summary = s.summary as Record | string | undefined; - if (summary) { - const text = typeof summary === "string" ? summary : (summary as Record)?.text; - if (text) lines.push(`Summary: ${String(text).slice(0, 200)}`); } + if (s.outcome) lines.push(`Last outcome: ${s.outcome}`); + const location = s.location as Record | undefined; + if (location?.directory) lines.push(`Directory: ${location.directory}`); return lines.length > 0 ? lines.join("\n") : JSON.stringify(raw); } @@ -50,7 +37,7 @@ export function registerSessionTools( readOnly, async ({ directory }) => { try { - const sessions = (await client.get("/session", undefined, directory)) as Array>; + const sessions = (await client.get("/api/session", undefined, directory)) as Array>; return toolResult(formatSessionList(sessions), false, { data: sessions }); } catch (e) { return toolError(e); @@ -60,18 +47,26 @@ export function registerSessionTools( server.tool( "opencode_session_create", - "Create a new session. Optionally provide a parentID to create a child session, and a title.", + "Create a new session. Optionally provide a parentID, agent, model, and title.", { parentID: z.string().optional().describe("Parent session ID"), title: z.string().optional().describe("Session title"), + agent: z.string().optional().describe("Agent ID to start the session with"), + providerID: z.string().optional().describe("Provider ID for the session model"), + modelID: z.string().optional().describe("Model ID for the session model"), directory: directoryParam, }, - async ({ parentID, title, directory }) => { + async ({ parentID, title, agent, providerID, modelID, directory }) => { try { - const body: Record = {}; + // OpenCode 2.x binds a session to a location at creation time via the + // request body; the x-opencode-directory header does not scope creates. + const body: Record = {}; if (parentID) body.parentID = parentID; if (title) body.title = title; - const session = await client.post("/session", body, { directory }) as Record; + if (agent) body.agent = agent; + if (providerID && modelID) body.model = { id: modelID, providerID }; + else if (providerID) body.model = { id: "", providerID }; + const session = await client.post("/api/session", { ...body, ...(directory ? { location: { directory } } : {}) }, { directory }) as Record; if (typeof session?.id !== "string") throw new Error("OpenCode returned a session without an ID"); return toolResult(`Session created.\n\n${formatSession(session)}`, false, { sessionId: session.id, session }); } catch (e) { @@ -90,7 +85,7 @@ export function registerSessionTools( readOnly, async ({ id, directory }) => { try { - const session = await client.get(`/session/${id}`, undefined, directory); + const session = await client.get(`/api/session/${encodeURIComponent(id)}`, undefined, directory); return toolResult(formatSession(session), false, { data: session }); } catch (e) { return toolError(e); @@ -108,7 +103,7 @@ export function registerSessionTools( destructive, async ({ id, directory }) => { try { - await client.delete(`/session/${id}`, undefined, directory); + await client.delete(`/api/session/${encodeURIComponent(id)}`, undefined, directory); return toolResult(`Session ${id} deleted.`); } catch (e) { return toolError(e); @@ -128,7 +123,7 @@ export function registerSessionTools( try { const body: Record = {}; if (title !== undefined) body.title = title; - const updated = await client.patch(`/session/${id}`, body, directory); + const updated = await client.patch(`/api/session/${encodeURIComponent(id)}`, body, directory); return toolResult(formatSession(updated)); } catch (e) { return toolError(e); @@ -146,8 +141,11 @@ export function registerSessionTools( readOnly, async ({ id, directory }) => { try { - const children = (await client.get(`/session/${id}/children`, undefined, directory)) as unknown[]; - if (!children || !Array.isArray(children) || children.length === 0) { + // OpenCode 2.x has no dedicated children route; sessions expose + // parentID, so children are the list filtered by that parent. + const sessions = (await client.get("/api/session", undefined, directory)) as Array>; + const children = sessions.filter((s) => s?.parentID === id); + if (!children.length) { return toolResult("No child sessions found."); } return toolResult(formatSessionList(children)); @@ -159,74 +157,32 @@ export function registerSessionTools( server.tool( "opencode_session_status", - "Get status for all sessions (running, idle, etc.)", + "Get running sessions and the last outcome of each session", { directory: directoryParam, }, readOnly, async ({ directory }) => { try { - const raw = await client.get("/session/status", undefined, directory); - const statuses = raw && typeof raw === "object" && !Array.isArray(raw) - ? raw as Record - : {}; - const entries = Object.entries(statuses); - if (entries.length === 0) { - return toolResult("All sessions idle.", false, { data: statuses }); + const [active, sessions] = await Promise.all([ + client.get("/api/session/active", undefined, directory).catch(() => null), + client.get("/api/session", undefined, directory).catch(() => null), + ]); + const running = (active && typeof active === "object" && !Array.isArray(active) ? active : {}) as Record; + const runningIDs = Object.keys(running); + const list = (Array.isArray(sessions) ? sessions : []) as Array>; + const byId = new Map(list.map((s) => [s.id as string, s])); + const lines: string[] = []; + for (const id of runningIDs) { + const session = byId.get(id); + lines.push(`- ${id}: running${session?.title ? ` (${session.title})` : ""}`); } - const lines = entries.map(([id, status]) => `- ${id}: ${resolveSessionStatus(status)}`); - return toolResult(`## Session Status (${entries.length})\n${lines.join("\n")}`, false, { data: statuses }); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_session_todo", - "Get the todo list for a session", - { - id: z.string().describe("Session ID"), - directory: directoryParam, - }, - readOnly, - async ({ id, directory }) => { - try { - const raw = await client.get(`/session/${id}/todo`, undefined, directory); - const todos = Array.isArray(raw) ? raw as Array> : []; - if (todos.length === 0) { - return toolResult("No todos for this session."); + for (const session of list) { + if (runningIDs.includes(session.id as string)) continue; + if (session.outcome) lines.push(`- ${session.id}: ${session.outcome}${session.title ? ` (${session.title})` : ""}`); } - const lines = todos.map((t) => { - const done = t.status === "completed" || t.done === true || t.completed === true; - const check = done ? "[x]" : "[ ]"; - const content = t.content ?? t.title ?? t.text ?? t.description ?? "?"; - const priority = t.priority ? ` (${t.priority})` : ""; - return `- ${check} ${content}${priority}`; - }); - const completed = todos.filter((t) => t.status === "completed" || t.done === true || t.completed === true).length; - return toolResult(`## Todos (${completed}/${todos.length} done)\n${lines.join("\n")}`); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_session_init", - "Analyze the app and create AGENTS.md for a session. NOTE: This is a long-running operation that may take 30-60+ seconds depending on project size.", - { - id: z.string().describe("Session ID"), - messageID: z.string().describe("Message ID"), - providerID: z.string().describe("Provider ID (e.g. 'anthropic')"), - modelID: z.string().describe("Model ID (e.g. 'claude-3-5-sonnet-20241022')"), - variant: z.string().optional().describe("Model variant (e.g. 'fast', 'smart')"), - directory: directoryParam, - }, - async ({ id, messageID, providerID, modelID, variant, directory }) => { - try { - await client.post(`/session/${id}/init`, { messageID, providerID, modelID, variant }, { directory }); - return toolResult("AGENTS.md initialization started."); + if (lines.length === 0) return toolResult("All sessions idle.", false, { data: { running: runningIDs } }); + return toolResult(`## Session Status\n${lines.join("\n")}`, false, { data: { running: runningIDs } }); } catch (e) { return toolError(e); } @@ -235,15 +191,17 @@ export function registerSessionTools( server.tool( "opencode_session_abort", - "Abort a running session", + "Interrupt a running session", { - id: z.string().describe("Session ID to abort"), + id: z.string().describe("Session ID to interrupt"), + resume: z.enum(["true", "false"]).optional().describe("Whether the session may resume afterwards (default false)"), directory: directoryParam, }, - async ({ id, directory }) => { + async ({ id, resume, directory }) => { try { - await client.post(`/session/${id}/abort`, undefined, { directory }); - return toolResult(`Session ${id} aborted.`); + const suffix = resume ? `?resume=${resume}` : ""; + await client.post(`/api/session/${encodeURIComponent(id)}/interrupt${suffix}`, undefined, { directory }); + return toolResult(`Session ${id} interrupted.`); } catch (e) { return toolError(e); } @@ -255,14 +213,14 @@ export function registerSessionTools( "Fork an existing session, optionally at a specific message", { id: z.string().describe("Session ID to fork"), - messageID: z.string().optional().describe("Message ID to fork at (optional)"), + messageID: z.string().optional().describe("Message ID to fork before (optional)"), directory: directoryParam, }, async ({ id, messageID, directory }) => { try { const body: Record = {}; - if (messageID) body.messageID = messageID; - const forked = await client.post(`/session/${id}/fork`, body, { directory }); + if (messageID) body.before = messageID; + const forked = await client.post(`/api/session/${encodeURIComponent(id)}/fork`, body, { directory }); return toolResult(`Session forked.\n\n${formatSession(forked)}`); } catch (e) { return toolError(e); @@ -270,44 +228,6 @@ export function registerSessionTools( }, ); - server.tool( - "opencode_session_share", - "Share a session publicly", - { - id: z.string().describe("Session ID to share"), - directory: directoryParam, - }, - async ({ id, directory }) => { - try { - const result = await client.post(`/session/${id}/share`, undefined, { directory }); - const r = result as Record; - // API may return share URL in different locations - const shareUrl = r.shareUrl ?? (r.share as Record | undefined)?.url ?? null; - const header = shareUrl ? `Session shared.\nURL: ${shareUrl}` : "Session shared."; - return toolResult(`${header}\n\n${formatSession(result)}`); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_session_unshare", - "Unshare a previously shared session", - { - id: z.string().describe("Session ID to unshare"), - directory: directoryParam, - }, - async ({ id, directory }) => { - try { - await client.delete(`/session/${id}/share`, undefined, directory); - return toolResult(`Session ${id} unshared.`); - } catch (e) { - return toolError(e); - } - }, - ); - server.tool( "opencode_session_diff", "Get the diff for a session, optionally for a specific message", @@ -319,8 +239,8 @@ export function registerSessionTools( async ({ id, messageID, directory }) => { try { const query: Record = {}; - if (messageID) query.messageID = messageID; - const diffs = await client.get(`/session/${id}/diff`, query, directory); + if (messageID) query.from = messageID; + const diffs = await client.get(`/api/session/${encodeURIComponent(id)}/diff`, query, directory); return toolResult(formatDiffResponse(diffs as unknown[])); } catch (e) { return toolError(e); @@ -330,18 +250,15 @@ export function registerSessionTools( server.tool( "opencode_session_summarize", - "Summarize a session using a specified model. NOTE: This is a long-running operation that may take 30-60+ seconds.", + "Compact (summarize) a session's history to reclaim context. NOTE: This is a long-running operation.", { id: z.string().describe("Session ID"), - providerID: z.string().describe("Provider ID (e.g. 'anthropic')"), - modelID: z.string().describe("Model ID (e.g. 'claude-3-5-sonnet-20241022')"), - variant: z.string().optional().describe("Model variant (e.g. 'fast', 'smart')"), directory: directoryParam, }, - async ({ id, providerID, modelID, variant, directory }) => { + async ({ id, directory }) => { try { - await client.post(`/session/${id}/summarize`, { providerID, modelID, variant }, { directory }); - return toolResult("Session summarization started."); + await client.post(`/api/session/${encodeURIComponent(id)}/compact`, {}, { directory }); + return toolResult("Session compaction started."); } catch (e) { return toolError(e); } @@ -350,19 +267,18 @@ export function registerSessionTools( server.tool( "opencode_session_revert", - "Revert a message in a session", + "Revert a session to a specific message (stages and commits the revert)", { id: z.string().describe("Session ID"), - messageID: z.string().describe("Message ID to revert"), - partID: z.string().optional().describe("Part ID to revert (optional)"), + messageID: z.string().describe("Message ID to revert to"), + files: z.boolean().optional().describe("Also revert file changes (default true)"), directory: directoryParam, }, - async ({ id, messageID, partID, directory }) => { + async ({ id, messageID, files, directory }) => { try { - const body: Record = { messageID }; - if (partID) body.partID = partID; - await client.post(`/session/${id}/revert`, body, { directory }); - return toolResult(`Message ${messageID} reverted.`); + await client.post(`/api/session/${encodeURIComponent(id)}/revert/stage`, { messageID, ...(files !== undefined ? { files } : {}) }, { directory }); + await client.post(`/api/session/${encodeURIComponent(id)}/revert/commit`, undefined, { directory }); + return toolResult(`Session reverted to ${messageID}.`); } catch (e) { return toolError(e); } @@ -371,15 +287,15 @@ export function registerSessionTools( server.tool( "opencode_session_unrevert", - "Restore all reverted messages in a session", + "Discard staged (uncommitted) revert changes in a session", { id: z.string().describe("Session ID"), directory: directoryParam, }, async ({ id, directory }) => { try { - await client.post(`/session/${id}/unrevert`, undefined, { directory }); - return toolResult("All reverted messages restored."); + await client.delete(`/api/session/${encodeURIComponent(id)}/revert`, undefined, directory); + return toolResult("Staged revert changes discarded."); } catch (e) { return toolError(e); } @@ -396,7 +312,7 @@ export function registerSessionTools( readOnly, async ({ directory }) => { try { - const requests = (await client.get("/permission", undefined, directory)) as Array>; + const requests = (await client.get("/api/permission/request", undefined, directory)) as Array>; if (!requests || !Array.isArray(requests) || requests.length === 0) { return toolResult("No pending permission requests."); } @@ -404,16 +320,12 @@ export function registerSessionTools( const lines = requests.map((r) => { const id = r.id ?? "?"; const session = r.sessionID ?? "?"; - const perm = r.permission ?? "?"; - const patterns = Array.isArray(r.patterns) ? (r.patterns as string[]).join(", ") : ""; - const tool = r.tool as Record | undefined; - const toolName = tool?.name ?? tool?.tool ?? ""; - let line = `- **${perm}** [${id}] (session: ${session})`; - if (toolName) line += `\n Tool: ${toolName}`; - if (patterns) line += `\n Patterns: ${patterns}`; - // Show what "always" would approve - const always = Array.isArray(r.always) ? (r.always as string[]).join(", ") : ""; - if (always) line += `\n Always would approve: ${always}`; + const action = r.action ?? "?"; + const resources = Array.isArray(r.resources) ? (r.resources as string[]).join(", ") : ""; + const message = typeof r.message === "string" ? r.message : ""; + let line = `- **${action}** [${id}] (session: ${session})`; + if (resources) line += `\n Resources: ${resources}`; + if (message) line += `\n ${message.slice(0, 200)}`; return line; }); @@ -436,19 +348,17 @@ export function registerSessionTools( id: z.string().describe("Session ID"), permissionID: z.string().describe("Permission request ID"), reply: z.enum(["once", "always", "reject"]).describe("Response to the permission request: 'once' to approve once, 'always' to auto-approve matching future requests, 'reject' to deny"), + message: z.string().optional().describe("Optional message accompanying the decision"), directory: directoryParam, }, - async ({ id, permissionID, reply, directory }) => { + async ({ id, permissionID, reply, message, directory }) => { try { - // Try the new API first (POST /permission/{requestID}/reply) - try { - await client.post(`/permission/${permissionID}/reply`, { reply }, { directory }); - return toolResult(`Permission ${reply === "reject" ? "rejected" : "approved"} (${reply}).`); - } catch { - // Fall back to the deprecated session-scoped endpoint - await client.post(`/session/${id}/permissions/${permissionID}`, { response: reply }, { directory }); - return toolResult(`Permission ${reply === "reject" ? "rejected" : "approved"} (${reply}).`); - } + await client.post( + `/api/session/${encodeURIComponent(id)}/permission/${encodeURIComponent(permissionID)}/reply`, + { decision: reply, ...(message !== undefined ? { message } : {}) }, + { directory }, + ); + return toolResult(`Permission ${reply === "reject" ? "rejected" : "approved"} (${reply}).`); } catch (e) { return toolError(e); } @@ -466,7 +376,7 @@ export function registerSessionTools( readOnly, async ({ query, directory }) => { try { - const sessions = (await client.get("/session", undefined, directory)) as Array>; + const sessions = (await client.get("/api/session", undefined, directory)) as Array>; if (!sessions || sessions.length === 0) { return toolResult("No sessions found.", false, { data: [] }); } diff --git a/src/tools/tui.ts b/src/tools/tui.ts deleted file mode 100644 index f06a925..0000000 --- a/src/tools/tui.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * TUI control tools — drive the OpenCode TUI remotely. - * Useful for IDE integrations and automation. - */ - -import { z } from "zod"; -import { McpServer } from "../mcp-server.js"; -import { OpenCodeClient } from "../client.js"; -import { toolResult, toolError, directoryParam } from "../helpers.js"; - -export function registerTuiTools(server: McpServer, client: OpenCodeClient) { - server.tool( - "opencode_tui_append_prompt", - "Append text to the TUI's prompt input field", - { - text: z.string().describe("Text to append to the prompt"), - directory: directoryParam, - }, - async ({ text, directory }) => { - try { - await client.post("/tui/append-prompt", { text }, { directory }); - return toolResult("Text appended to prompt."); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_tui_submit_prompt", - "Submit the current prompt in the TUI (equivalent to pressing Enter)", - { - directory: directoryParam, - }, - async ({ directory }) => { - try { - await client.post("/tui/submit-prompt", undefined, { directory }); - return toolResult("Prompt submitted."); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_tui_clear_prompt", - "Clear the current prompt text in the TUI", - { - directory: directoryParam, - }, - async ({ directory }) => { - try { - await client.post("/tui/clear-prompt", undefined, { directory }); - return toolResult("Prompt cleared."); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_tui_execute_command", - "Execute a slash command through the TUI (e.g. '/init', '/undo')", - { - command: z.string().describe("Command to execute (e.g. '/init')"), - directory: directoryParam, - }, - async ({ command, directory }) => { - try { - await client.post("/tui/execute-command", { command }, { directory }); - return toolResult(`Command '${command}' executed.`); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_tui_show_toast", - "Show a toast notification in the TUI", - { - message: z.string().describe("Toast message text"), - title: z.string().optional().describe("Optional toast title"), - variant: z - .enum(["info", "success", "warning", "error"]) - .optional() - .describe("Toast variant (default: info)"), - directory: directoryParam, - }, - async ({ message, title, variant, directory }) => { - try { - const body: Record = { message }; - if (title) body.title = title; - if (variant) body.variant = variant; - await client.post("/tui/show-toast", body, { directory }); - return toolResult("Toast shown."); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_tui_open_help", - "Open the help dialog in the TUI", - { - directory: directoryParam, - }, - async ({ directory }) => { - try { - await client.post("/tui/open-help", undefined, { directory }); - return toolResult("Help dialog opened."); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_tui_open_sessions", - "Open the session selector in the TUI", - { - directory: directoryParam, - }, - async ({ directory }) => { - try { - await client.post("/tui/open-sessions", undefined, { directory }); - return toolResult("Session selector opened."); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_tui_open_models", - "Open the model selector in the TUI", - { - directory: directoryParam, - }, - async ({ directory }) => { - try { - await client.post("/tui/open-models", undefined, { directory }); - return toolResult("Model selector opened."); - } catch (e) { - return toolError(e); - } - }, - ); - - server.tool( - "opencode_tui_open_themes", - "Open the theme selector in the TUI", - { - directory: directoryParam, - }, - async ({ directory }) => { - try { - await client.post("/tui/open-themes", undefined, { directory }); - return toolResult("Theme selector opened."); - } catch (e) { - return toolError(e); - } - }, - ); -} diff --git a/src/tools/workflow.ts b/src/tools/workflow.ts index dfa2a0c..1346657 100644 --- a/src/tools/workflow.ts +++ b/src/tools/workflow.ts @@ -6,13 +6,13 @@ import { z } from "zod"; import { McpServer, type ToolContext } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; -import { JobService, observeSession, type JobSnapshot } from "../jobs.js"; +import { JobService, observeSession, readSessionMessages, type JobSnapshot } from "../jobs.js"; import { createRequestContext, abortableSleep, withAbort } from "../async.js"; import { dispatchShape, runShape } from "../job-contract.js"; +import { switchSessionModel } from "./message.js"; import { formatMessageResponse, formatMessageList, - formatSessionList, analyzeMessageResponse, isProviderConfigured, redactSecrets, @@ -23,7 +23,6 @@ import { toolError, directoryParam, readOnly, - outputFormatParam, } from "../helpers.js"; function jobResult(snapshot: JobSnapshot, note = "", extra: Record = {}) { @@ -63,6 +62,67 @@ async function waitForSnapshot( } } +/** + * Send a prompt and wait for its turn to finish. OpenCode 2.x prompts are + * queued in the session inbox and return immediately; the experimental wait + * endpoint long-polls until the session is idle. Resolves with the final + * assistant message of that turn, or — flagged via `timedOut` — the partial + * state when the deadline elapses. + */ +async function promptAndWait( + client: OpenCodeClient, + sessionId: string, + text: string, + opts: { agent?: string; directory?: string; signal?: AbortSignal; timeoutMs?: number }, +): Promise<{ user: Record; assistant?: Record; idle?: Record; timedOut: boolean }> { + const directory = normalizeDirectory(opts.directory); + // 2.x prompts accept neither an agent nor a model; switch the session first. + if (opts.agent) { + await client.post(`/api/session/${encodeURIComponent(sessionId)}/agent`, { agent: opts.agent }, { ...(opts.signal ? { signal: opts.signal } : {}), directory }); + } + // delivery "queue" keeps the prompt a separate turn: the default "steer" + // would merge it into a still-running turn. + const user = await client.post>( + `/api/session/${encodeURIComponent(sessionId)}/prompt`, + { text, delivery: "queue" }, + { ...(opts.signal ? { signal: opts.signal } : {}), directory }, + ); + const messageId = typeof user?.id === "string" ? user.id : undefined; + + let timedOut = false; + const waitContext = createRequestContext({ signal: opts.signal, timeout: opts.timeoutMs ?? 600_000 }); + try { + await client.post(`/api/experimental/session/${encodeURIComponent(sessionId)}/wait`, undefined, { + signal: waitContext.signal, deadline: waitContext.deadline, directory, + }); + } catch (error) { + // Only the wait's own deadline leaves the turn legitimately running; + // every other failure (unauthorized, not found, unavailable, network) + // must surface instead of masquerading as a finished or partial answer. + if (opts.signal?.aborted) throw error; + if (!waitContext.signal.aborted) throw error; + timedOut = true; + } finally { + waitContext.dispose(); + } + + const messages = await readSessionMessages(client, sessionId, directory, + { ...(opts.signal ? { signal: opts.signal } : {}) }, { targetId: messageId }); + const targetIndex = messageId ? messages.findIndex((message: any) => message?.id === messageId) : -1; + // The turn is everything after our user message, up to and including the + // idle marker that closes it. A newer queued turn starts only after that + // marker, so it never cuts into this slice. + const rest = targetIndex >= 0 ? messages.slice(targetIndex + 1) : []; + const idleIndex = rest.findIndex((message: any) => message?.type === "idle"); + const turn = idleIndex >= 0 ? rest.slice(0, idleIndex + 1) : rest; + return { + user, + assistant: turn.filter((message: any) => message?.type === "assistant").at(-1), + idle: idleIndex >= 0 ? rest[idleIndex] : undefined, + timedOut, + }; +} + export function registerWorkflowTools( server: McpServer, client: OpenCodeClient, @@ -82,126 +142,81 @@ export function registerWorkflowTools( try { const sections: string[] = []; - // 1. Health check - let healthy = false; + // 1. Server info — OpenCode 2.x /api/info responds when healthy. try { - const health = (await client.get("/global/health", undefined, directory)) as Record; - healthy = true; + const info = (await client.get("/api/info", undefined, directory)) as Record; sections.push( - `## Server\nStatus: healthy\nVersion: ${health.version ?? "unknown"}`, + `## Server\nStatus: healthy\nVersion: ${info.version ?? "unknown"}`, ); } catch (e) { sections.push( `## Server\nStatus: UNREACHABLE — is \`opencode serve\` running?\nError: ${e instanceof Error ? e.message : String(e)}`, ); - } - - if (!healthy) { return toolResult(sections.join("\n\n")); } - // 2. Providers — categorize by readiness + // 2. Providers — v2 splits providers, credentials, integrations and models. try { - const raw = await client.get("/provider", undefined, directory); - const providers = ( - raw && typeof raw === "object" && "all" in (raw as Record) - ? (raw as Record).all - : raw - ) as Array>; - - if (Array.isArray(providers) && providers.length > 0) { - // Fetch auth methods for richer guidance - let authMethods: Record | null = null; - try { - authMethods = (await client.get("/provider/auth", undefined, directory)) as Record; - } catch { /* non-critical */ } - - // Helper to count models - const countModels = (p: Record) => { - const m = p.models; - return Array.isArray(m) ? m.length : m && typeof m === "object" ? Object.keys(m).length : 0; - }; - - const ready = providers.filter(isProviderConfigured); - const withOAuth = providers.filter( - (p) => !ready.includes(p) && authMethods && Array.isArray(authMethods[p.id as string]), - ); - - // Popular providers worth highlighting (have free tiers or are well-known) - const popularIds = new Set([ - "anthropic", "openai", "google", "firmware", "openrouter", - "groq", "deepseek", "huggingface", "github-copilot", "mistral", - ]); - const popular = providers.filter( - (p) => !ready.includes(p) && popularIds.has(p.id as string), - ); - const otherCount = providers.length - ready.length - popular.length; + const [providers, credentials, integrations, models] = await Promise.all([ + client.get("/api/provider", undefined, directory).catch(() => null), + client.get("/api/credential", undefined, directory).catch(() => null), + client.get("/api/integration", undefined, directory).catch(() => null), + client.get("/api/model", undefined, directory).catch(() => null), + ]); + const providerList = Array.isArray(providers) ? (providers as Array>) : []; + const credentialList = Array.isArray(credentials) ? (credentials as Array>) : []; + const integrationList = Array.isArray(integrations) ? (integrations as Array>) : []; + const modelList = Array.isArray(models) ? (models as Array>) : []; + + if (providerList.length > 0) { + const countModels = (providerId: string) => + modelList.filter((m) => m?.providerID === providerId && m.enabled !== false).length; + + const ready = providerList.filter((p) => isProviderConfigured(p, credentialList)); + const readyIds = new Set(ready.map((p) => String(p.id))); const providerLines: string[] = []; - - // Section: Ready to use if (ready.length > 0) { providerLines.push("**Ready to use:**"); for (const p of ready) { - const id = p.id as string; - const name = p.name as string; - const mc = countModels(p); - const envVars = (p.env as string[])?.join(", ") ?? ""; - providerLines.push(`- ${id} (${name}): detected via ${envVars} — ${mc} models`); + providerLines.push(`- ${p.id} (${p.name ?? p.id}): ${countModels(String(p.id))} models`); } } else { providerLines.push("**No providers configured yet.** You need at least one to start using OpenCode."); } - // Section: Quick setup options - providerLines.push(""); - providerLines.push("**Quick setup options:**"); - for (const p of popular) { - const id = p.id as string; - const name = p.name as string; - const mc = countModels(p); - const envVars = (p.env as string[]) ?? []; - const envHint = envVars.length > 0 ? `set ${envVars[0]}` : ""; - - // Check for OAuth - const oauthAvailable = authMethods && Array.isArray(authMethods[id]); - const methods: string[] = []; - if (oauthAvailable) { - const labels = (authMethods![id] as Array>) - .filter((m) => m.type === "oauth") - .map((m) => m.label as string); - if (labels.length > 0) methods.push(`OAuth (${labels[0]})`); + // Setup hints from integrations: OAuth, API key or env var methods. + const setupHints: string[] = []; + for (const integration of integrationList) { + const integrationID = String(integration.id ?? ""); + const provider = providerList.find((p) => (p.integrationID ?? p.id) === integrationID); + if (!provider || readyIds.has(String(provider.id))) continue; + const methods = Array.isArray(integration.methods) ? (integration.methods as Array>) : []; + const oauth = methods.find((m) => m?.type === "oauth"); + const key = methods.find((m) => m?.type === "key"); + const env = methods.find((m) => m?.type === "env"); + const options: string[] = []; + if (oauth) options.push(`OAuth (${oauth.label ?? "browser flow"})`); + if (key) options.push("`opencode_auth_set` (API key)"); + if (env) { + const names = Array.isArray(env.names) ? env.names.map(String) : []; + if (names.length > 0) options.push(`env var \`${names[0]}\``); } - if (envHint) methods.push(`\`opencode_auth_set\` or env var \`${envVars[0]}\``); - - // Check for free models - const rawModels = p.models; - const modelValues = rawModels && typeof rawModels === "object" && !Array.isArray(rawModels) - ? Object.values(rawModels as Record) - : Array.isArray(rawModels) ? rawModels : []; - const hasFree = modelValues.some( - (m) => { - const cost = (m as Record).cost as Record | undefined; - return cost && cost.input === 0 && cost.output === 0; - }, - ); - const freeTag = hasFree ? " [has free models]" : ""; - - providerLines.push(`- ${id} (${name}): ${mc} models${freeTag} — ${methods.join(" or ")}`); + if (options.length > 0) setupHints.push(`- ${provider.id}: ${options.join(" or ")}`); } - - // Mention OAuth-only providers not already listed - const oauthOnly = withOAuth.filter((p) => !popular.includes(p) && !ready.includes(p)); - if (oauthOnly.length > 0) { - const names = oauthOnly.map((p) => `${p.id}`).join(", "); - providerLines.push(`- Also available via OAuth: ${names}`); + if (setupHints.length > 0) { + providerLines.push("", "**Quick setup options:**", ...setupHints); } + const hinted = new Set(setupHints.map((line) => line.replace(/^- ([^:]+):.*$/, "$1"))); + const otherCount = providerList.filter( + (p) => !readyIds.has(String(p.id)) && !hinted.has(String(p.id)), + ).length; if (otherCount > 0) { providerLines.push(`\n+${otherCount} more providers available. Use \`opencode_provider_list\` to see all.`); } - sections.push(`## Providers (${providers.length} available)\n${providerLines.join("\n")}`); + sections.push(`## Providers (${providerList.length} available)\n${providerLines.join("\n")}`); } else { sections.push("## Providers\nNo providers found. Is the server running correctly?"); } @@ -209,23 +224,19 @@ export function registerWorkflowTools( sections.push("## Providers\nCould not fetch provider list."); } - // 3. Project info (if directory given or from default) + // 3. Project info — v2 /api/location returns directory + project ref. try { - const project = (await client.get("/project/current", undefined, directory)) as Record; - const worktree = (project.worktree ?? "unknown") as string; - // Derive a readable name: prefer project.name, then last dir component from worktree, then id - const name = project.name - ?? (worktree !== "unknown" ? worktree.split("/").filter(Boolean).pop() : null) - ?? project.id + const location = (await client.get("/api/location", undefined, directory)) as Record; + const project = (location.project ?? {}) as Record; + const dir = location.directory ?? project.directory ?? "unknown"; + const name = project.id + ?? (typeof dir === "string" && dir !== "unknown" ? dir.split("/").filter(Boolean).pop() : null) ?? "unknown"; - const vcs = project.vcs ?? "none"; - sections.push( - `## Project\nName: ${name}\nPath: ${worktree}\nVCS: ${vcs}`, - ); + sections.push(`## Project\nName: ${name}\nPath: ${dir}`); } catch { if (directory) { sections.push( - `## Project\nDirectory: ${directory}\nNote: Could not load project info. Make sure the directory exists and contains a git repository.`, + `## Project\nDirectory: ${directory}\nNote: Could not load project info. Make sure the directory exists.`, ); } else { sections.push( @@ -267,7 +278,7 @@ export function registerWorkflowTools( // ─── One-shot: create session + send prompt + return answer ───────── server.tool( "opencode_ask", - "Ask OpenCode a question in one step. Creates a new session, sends your prompt, and returns the AI response. This is the easiest way to interact with OpenCode.", + "Ask OpenCode a question in one step. Creates a new session, sends your prompt, waits for the turn to finish, and returns the AI response. This is the easiest way to interact with OpenCode.", { prompt: z.string().describe("The question or instruction to send"), title: z @@ -287,39 +298,26 @@ export function registerWorkflowTools( .string() .optional() .describe("Agent to use (e.g. 'build', 'plan')"), - system: z - .string() - .optional() - .describe("Optional system prompt override"), - format: outputFormatParam, directory: directoryParam, }, - async ({ prompt, title, providerID, modelID, variant, agent, system, format, directory }, extra) => { + async ({ prompt, title, providerID, modelID, variant, agent, directory }, extra) => { let sessionId: string | undefined; try { - // 1. Create session - const session = (await client.post("/session", { + directory = normalizeDirectory(directory); + // 1. Create session — v2 takes the agent, model and location in the body. + const model = applyModelDefaults(providerID, modelID); + const session = (await client.post("/api/session", { title: title ?? prompt.slice(0, 80), - }, { directory, ...(extra?.signal ? { signal: extra.signal } : {}) })) as Record; + ...(agent ? { agent } : {}), + ...(model ? { model: { id: model.modelID, providerID: model.providerID, ...(variant ? { variant } : {}) } } : {}), + ...(directory ? { location: { directory } } : {}), + }, { ...(extra?.signal ? { signal: extra.signal } : {}) })) as Record; sessionId = session.id as string; if (!sessionId) throw new Error("OpenCode returned a session without an ID"); - // 2. Send prompt - const body: Record = { - parts: [{ type: "text", text: prompt }], - }; - const model = applyModelDefaults(providerID, modelID); - if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; - if (system) body.system = system; - if (format) body.format = format; - - const response = await client.post( - `/session/${sessionId}/message`, - body, - { directory, ...(extra?.signal ? { signal: extra.signal } : {}) }, - ); + // 2. Prompt and wait for the turn to finish + const { assistant, idle, timedOut } = await promptAndWait(client, sessionId, prompt, { directory, signal: extra?.signal }); + const response = assistant ?? idle; // 3. Analyze for auth / empty response issues const analysis = analyzeMessageResponse(response); @@ -329,10 +327,16 @@ export function registerWorkflowTools( const dirLabel = directory ? `Directory: ${directory}` : "Directory: (server default)"; const parts = [`${dirLabel}\nSession: ${sessionId}`]; if (formatted) parts.push(formatted); - if (analysis.warning) { + if (timedOut) { + parts.push(`\n--- TIMEOUT ---\nStill running after the wait deadline; this is a partial answer. Use opencode_wait({sessionId: "${sessionId}"}) to keep waiting or opencode_session_abort to stop it.`); + } + if (idle?.outcome === "failed" && !analysis.hasError) { + parts.push(`\n--- WARNING ---\nThe run failed${idle.error ? `: ${JSON.stringify(idle.error)}` : ""}.`); + } else if (analysis.warning) { parts.push(`\n--- WARNING ---\n${analysis.warning}`); } - return toolResult(parts.join("\n\n"), analysis.hasError, { sessionId, directory, data: response }); + return toolResult(parts.join("\n\n"), analysis.hasError || idle?.outcome === "failed" || timedOut, + { sessionId, directory, data: response, ...(timedOut ? { timedOut: true } : {}) }); } catch (e) { const result = toolError(e); return { ...result, content: [{ type: "text" as const, text: `${sessionId ? `Session: ${sessionId}\n` : ""}${result.content[0].text}` }], structuredContent: { sessionId, directory } }; @@ -343,7 +347,7 @@ export function registerWorkflowTools( // ─── Continue a conversation ──────────────────────────────────────── server.tool( "opencode_reply", - "Send a follow-up message to an existing session. Use this to continue a conversation started with opencode_ask or opencode_session_create.", + "Send a follow-up message to an existing session and wait for the answer. Use this to continue a conversation started with opencode_ask or opencode_session_create.", { sessionId: z.string().describe("Session ID to reply in"), prompt: z.string().describe("The follow-up message"), @@ -351,31 +355,35 @@ export function registerWorkflowTools( modelID: z.string().optional().describe("Model ID"), variant: z.string().optional().describe("Model variant"), agent: z.string().optional().describe("Agent to use"), - format: outputFormatParam, directory: directoryParam, }, - async ({ sessionId, prompt, providerID, modelID, variant, agent, format, directory }, extra) => { + async ({ sessionId, prompt, providerID, modelID, variant, agent, directory }, extra) => { try { - const body: Record = { - parts: [{ type: "text", text: prompt }], - }; + directory = normalizeDirectory(directory); const model = applyModelDefaults(providerID, modelID); - if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; - if (format) body.format = format; - - const response = await client.post( - `/session/${sessionId}/message`, - body, - { directory, ...(extra?.signal ? { signal: extra.signal } : {}) }, - ); + if (model) { + await switchSessionModel(client, sessionId, model.providerID, model.modelID, variant, directory); + } else if (variant) { + // No explicit model: read the session's current model and switch variant. + const session = await client.get(`/api/session/${encodeURIComponent(sessionId)}`, undefined, directory).catch(() => null); + const current = (session as Record | null)?.model as Record | undefined; + if (current?.id && current?.providerID) { + await switchSessionModel(client, sessionId, current.providerID, current.id, variant, directory); + } + } + const { assistant, idle, timedOut } = await promptAndWait(client, sessionId, prompt, { agent, directory, signal: extra?.signal }); + const response = assistant ?? idle; const analysis = analyzeMessageResponse(response); const formatted = formatMessageResponse(response); const parts: string[] = []; if (formatted) parts.push(formatted); - if (analysis.warning) { + if (timedOut) { + parts.push(`\n--- TIMEOUT ---\nStill running after the wait deadline; this is a partial answer. Use opencode_wait({sessionId: "${sessionId}"}) to keep waiting or opencode_session_abort to stop it.`); + } + if (idle?.outcome === "failed" && !analysis.hasError) { + parts.push(`\n--- WARNING ---\nThe run failed${idle.error ? `: ${JSON.stringify(idle.error)}` : ""}.`); + } else if (analysis.warning) { parts.push(`\n--- WARNING ---\n${analysis.warning}`); } // Session-directory consistency note @@ -384,8 +392,8 @@ export function registerWorkflowTools( } return toolResult( parts.join("\n\n") || "Empty response.", - analysis.hasError, - { sessionId, directory, data: response }, + analysis.hasError || idle?.outcome === "failed" || timedOut, + { sessionId, directory, data: response, ...(timedOut ? { timedOut: true } : {}) }, ); } catch (e) { const result = toolError(e); @@ -412,7 +420,7 @@ export function registerWorkflowTools( const query: Record = {}; if (limit !== undefined) query.limit = String(limit); const messages = await client.get( - `/session/${sessionId}/message`, + `/api/session/${encodeURIComponent(sessionId)}/message`, query, directory, ); @@ -436,20 +444,20 @@ export function registerWorkflowTools( readOnly, async ({ directory }) => { try { - const [sessions, statuses] = await Promise.all([ - client.get("/session", undefined, directory) as Promise>>, - client.get("/session/status", undefined, directory) as Promise>, + const [sessions, active] = await Promise.all([ + client.get("/api/session", undefined, directory) as Promise>>, + client.get("/api/session/active", undefined, directory).catch(() => null) as Promise | null>, ]); if (!sessions || sessions.length === 0) { return toolResult("No sessions found."); } - // Merge status and show enriched overview + // Merge running-state and show enriched overview const lines = sessions.map((s) => { const id = s.id ?? "?"; const title = s.title ?? "(untitled)"; - const status = resolveSessionStatus(statuses[id as string]); + const status = active ? resolveSessionStatus(active[id as string]) : "idle"; const parentTag = s.parentID ? ` (child of ${s.parentID})` : ""; return `- [${status}] ${title} [${id}]${parentTag}`; }); @@ -466,7 +474,7 @@ export function registerWorkflowTools( // ─── Project context ────────────────────────────────────────────── server.tool( "opencode_context", - "Get full project context in one call: current project, path, VCS info, config, and available agents. Useful to understand the current state before starting work.", + "Get full project context in one call: current location and project, VCS info, config, and available agents. Useful to understand the current state before starting work.", { directory: directoryParam, }, @@ -477,65 +485,47 @@ export function registerWorkflowTools( // swallows the validation error. directory = normalizeDirectory(directory) as typeof directory; - const [project, path, vcs, config, agents] = await Promise.all([ - client.get("/project/current", undefined, directory).catch(() => null), - client.get("/path", undefined, directory).catch(() => null), - client.get("/vcs", undefined, directory).catch(() => null), - client.get("/config", undefined, directory).catch(() => null), - client.get("/agent", undefined, directory).catch(() => null), + const [location, vcs, config, agents] = await Promise.all([ + client.get("/api/location", undefined, directory).catch(() => null), + client.get("/api/vcs", undefined, directory).catch(() => null), + client.get("/api/config", undefined, directory).catch(() => null), + client.get("/api/agent", undefined, directory).catch(() => null), ]); const sections: string[] = []; - if (project) { - const p = project as Record; - const worktree = (p.worktree ?? "unknown") as string; - const name = p.name - ?? (worktree !== "unknown" ? worktree.split("/").filter(Boolean).pop() : null) - ?? p.id ?? "unknown"; - const lines = [`Name: ${name}`, `Path: ${worktree}`]; - if (p.vcs) lines.push(`VCS: ${p.vcs}`); - if (p.id) lines.push(`ID: ${p.id}`); + if (location) { + const l = location as Record; + const project = (l.project ?? {}) as Record; + const workDir = l.directory ?? project.directory ?? "unknown"; + const name = project.id + ?? (workDir !== "unknown" ? String(workDir).split("/").filter(Boolean).pop() : null) + ?? "unknown"; + const lines = [`Name: ${name}`, `Path: ${workDir}`]; + if (project.canonical && project.canonical !== workDir) lines.push(`Canonical: ${project.canonical}`); sections.push(`## Project\n${lines.join("\n")}`); } - if (path) { - const pp = path as Record; - const workDir = pp.worktree ?? pp.directory ?? pp.cwd ?? pp.path; - const pathLines: string[] = []; - if (workDir) pathLines.push(`Working directory: ${workDir}`); - if (pp.config && pp.config !== workDir) pathLines.push(`Config: ${pp.config}`); - if (pp.state && pp.state !== workDir) pathLines.push(`State: ${pp.state}`); - if (pp.home && pp.home !== workDir) pathLines.push(`Home: ${pp.home}`); - if (pathLines.length === 0) pathLines.push(`Working directory: ${JSON.stringify(pp)}`); - sections.push(`## Path\n${pathLines.join("\n")}`); - } if (vcs) { const v = vcs as Record; + const branch = (v.branch ?? {}) as Record; const lines: string[] = []; - if (v.branch) lines.push(`Branch: ${v.branch}`); - if (v.remote) lines.push(`Remote: ${v.remote}`); - if (v.sha) lines.push(`HEAD: ${v.sha}`); - if (v.dirty !== undefined) lines.push(`Dirty: ${v.dirty}`); - sections.push(`## VCS (Git)\n${lines.length > 0 ? lines.join("\n") : "No VCS info available."}`); + if (branch.current) lines.push(`Branch: ${branch.current}`); + if (branch.default && branch.default !== branch.current) lines.push(`Default branch: ${branch.default}`); + if (v.provider) lines.push(`Provider: ${v.provider}`); + sections.push(`## VCS\n${lines.length > 0 ? lines.join("\n") : "No VCS branch info available."}`); } if (config) { - // Show config summary with secrets redacted — skip overwhelming nested objects - const c = redactSecrets(config) as Record; - const topLevel: string[] = []; - for (const [k, v] of Object.entries(c)) { - if (v && typeof v === "object" && !Array.isArray(v)) { - const keys = Object.keys(v as Record); - topLevel.push(`${k}: {${keys.length} entries}`); - } else if (Array.isArray(v)) { - topLevel.push(`${k}: [${v.length} items]`); - } else { - topLevel.push(`${k}: ${v}`); - } - } - sections.push(`## Config\n${topLevel.join("\n")}`); + // v2 config is a list of active sources (documents and directories). + const entries = Array.isArray(config) ? (config as Array>) : []; + const lines = entries.map((entry) => { + const path = typeof entry.path === "string" ? entry.path : "(dynamic)"; + return `- ${entry.type ?? "source"}: ${path}`; + }); + const redacted = redactSecrets(entries) as unknown[]; + sections.push(`## Config (${entries.length} source(s))\n${lines.length > 0 ? lines.join("\n") : "No config sources."}\n${JSON.stringify(redacted).length > 2000 ? "(details truncated)" : JSON.stringify(redacted)}`); } if (agents) { - const agentList = agents as Array>; + const agentList = (agents as Array>).filter((a) => a.hidden !== true); sections.push( `## Agents (${agentList.length})\n${agentList.map((a) => `- ${a.name ?? a.id}: ${a.description ?? "(no description)"} [${a.mode ?? "?"}]`).join("\n")}`, ); @@ -586,15 +576,15 @@ export function registerWorkflowTools( messageID: z .string() .optional() - .describe("Specific message ID to get diff for"), + .describe("User message whose turn to diff (defaults to the newest turn)"), directory: directoryParam, }, readOnly, async ({ sessionId, messageID, directory }) => { try { const query: Record = {}; - if (messageID) query.messageID = messageID; - const diffs = await client.get(`/session/${sessionId}/diff`, query, directory); + if (messageID) query.from = messageID; + const diffs = await client.get(`/api/session/${encodeURIComponent(sessionId)}/diff`, query, directory); const { formatDiffResponse } = await import("../helpers.js"); return toolResult(formatDiffResponse(diffs as unknown[])); } catch (e) { @@ -611,43 +601,39 @@ export function registerWorkflowTools( providerId: z.string().describe("Provider ID to test (e.g. 'anthropic', 'openrouter')"), modelID: z.string().optional().describe("Specific model ID to test. If omitted, discovers the provider default or first available model."), variant: z.string().optional().describe("Model variant"), - format: outputFormatParam, directory: directoryParam, }, - async ({ providerId, modelID, variant, format, directory }, extra) => { + async ({ providerId, modelID, variant, directory }, extra) => { let sessionId: string | null = null; try { + directory = normalizeDirectory(directory); if (!modelID) { - const raw = await client.get("/provider", undefined, directory) as Record; - const providers = Array.isArray(raw) ? raw : Array.isArray(raw?.all) ? raw.all : []; - const provider = providers.find((p: Record) => p.id === providerId); - const models = provider?.models; - const choices = Array.isArray(models) - ? models.map((m: Record) => m.id).filter((id: unknown): id is string => typeof id === "string") - : models && typeof models === "object" ? Object.keys(models) : []; - const preferred = (raw?.default as Record | undefined)?.[providerId]; + // v2: models are listed globally, tagged with providerID. + const [models, defaultModel] = await Promise.all([ + client.get("/api/model", undefined, directory).catch(() => null), + client.get("/api/model/default", undefined, directory).catch(() => null), + ]); + const choices = (Array.isArray(models) ? (models as Array>) : []) + .filter((m) => m?.providerID === providerId && m.enabled !== false) + .map((m) => String(m.modelID ?? m.id)); + const fallbackDefault = (defaultModel as Record | null) ?? null; + const preferred = fallbackDefault?.providerID === providerId ? String(fallbackDefault.modelID ?? fallbackDefault.id) : undefined; modelID = preferred && choices.includes(preferred) ? preferred : choices[0]; if (!modelID) throw new Error(`No model discovered for provider "${providerId}". Specify modelID from opencode_provider_models.`); } - // 1. Create a temporary test session - const session = (await client.post("/session", { + // 1. Create a temporary test session with the model under test + const session = (await client.post("/api/session", { title: `[test] ${providerId}`, - }, { directory, ...(extra?.signal ? { signal: extra.signal } : {}) })) as Record; + model: { id: modelID, providerID: providerId, ...(variant ? { variant } : {}) }, + ...(directory ? { location: { directory } } : {}), + }, { ...(extra?.signal ? { signal: extra.signal } : {}) })) as Record; sessionId = session.id as string; - // 2. Send a trivial prompt - const body: Record = { - parts: [{ type: "text", text: "Say hello in one word." }], - }; - body.model = { providerID: providerId, modelID }; - if (variant) body.variant = variant; - if (format) body.format = format; - - const response = await client.post( - `/session/${sessionId}/message`, - body, - { directory, ...(extra?.signal ? { signal: extra.signal } : {}) }, - ); + // 2. Send a trivial prompt and wait for the turn to finish + const { assistant, idle } = await promptAndWait(client, sessionId, "Say hello in one word.", { + directory, signal: extra?.signal, timeoutMs: 120_000, + }); + const response = assistant ?? idle; // 3. Analyze the response const analysis = analyzeMessageResponse(response); @@ -655,11 +641,13 @@ export function registerWorkflowTools( // 4. Cleanup — delete test session try { - await client.delete(`/session/${sessionId}`, undefined, directory); + await client.delete(`/api/session/${encodeURIComponent(sessionId)}`, undefined, directory); } catch { /* best-effort cleanup */ } - if (analysis.hasError || analysis.isEmpty) { - const reason = analysis.warning ?? "Unknown error — no response received."; + if (analysis.hasError || analysis.isEmpty || idle?.outcome === "failed") { + const reason = idle?.outcome === "failed" + ? `The run failed${idle.error ? `: ${JSON.stringify(idle.error)}` : ""}.` + : analysis.warning ?? "Unknown error — no response received."; return toolResult( `Provider "${providerId}" FAILED.\n\n${reason}`, true, @@ -677,7 +665,7 @@ export function registerWorkflowTools( // Cleanup on error if (sessionId) { try { - await client.delete(`/session/${sessionId}`, undefined, directory); + await client.delete(`/api/session/${encodeURIComponent(sessionId)}`, undefined, directory); } catch { /* best-effort cleanup */ } } return { ...toolError(e), structuredContent: { sessionId, directory, providerId, modelID } }; @@ -690,13 +678,13 @@ export function registerWorkflowTools( "opencode_run", "Send a task and wait for its correlated response. Returns a durable job ID; an observation timeout leaves the remote job running and can be resumed with opencode_wait.", runShape, - async ({ prompt, sessionId, title, providerID, modelID, variant, agent, format, maxDurationSeconds, directory }, extra) => { + async ({ prompt, sessionId, title, providerID, modelID, variant, agent, maxDurationSeconds, directory }, extra) => { let snapshot: JobSnapshot = { sessionId: sessionId ?? "", directory, status: "unknown" }; const seconds = maxDurationSeconds ?? 600; const context = createRequestContext({ signal: extra?.signal, timeout: seconds > 0 ? seconds * 1000 : 120000 }); try { snapshot = await service.start({ prompt, sessionId, title, directory, - model: applyModelDefaults(providerID, modelID), variant, agent, format }, context); + model: applyModelDefaults(providerID, modelID), variant, agent }, context); if (["failed", "unknown", "cancelled", "input_required"].includes(snapshot.status)) return jobResult(snapshot); const remaining = seconds === 0 ? 0 : Math.max(0, (context.deadline - Date.now()) / 1000); return await waitForSnapshot(options => service.get(snapshot.jobId!, options), snapshot, remaining, 1000, extra); @@ -713,10 +701,10 @@ export function registerWorkflowTools( "opencode_fire", "Dispatch a durable background task and return its job, session and message IDs immediately. Use opencode_check or opencode_wait to observe; job_cancel stops the task.", dispatchShape, - async ({ prompt, sessionId, title, providerID, modelID, variant, agent, format, directory }, extra) => { + async ({ prompt, sessionId, title, providerID, modelID, variant, agent, directory }, extra) => { try { const snapshot = await service.start({ prompt, sessionId, title, directory, - model: applyModelDefaults(providerID, modelID), variant, agent, format }, { signal: extra?.signal }); + model: applyModelDefaults(providerID, modelID), variant, agent }, { signal: extra?.signal }); return jobResult(snapshot, snapshot.status === "accepted" ? `Task dispatched. Use opencode_check({jobId: "${snapshot.jobId}"}) or opencode_wait({jobId: "${snapshot.jobId}"}) to monitor it.` : ""); } catch (error) { @@ -728,7 +716,7 @@ export function registerWorkflowTools( // ─── Check: summary metadata, never full patches ──────────────────── server.tool( "opencode_check", - "Get current task status, pending input, todos and file counts. Uses session summary metadata instead of fetching patches. Pass jobId for exact durable turn correlation; detailed includes response text.", + "Get current task status, pending input and file counts. Uses session metadata instead of fetching patches. Pass jobId for exact durable turn correlation; detailed includes response text.", { sessionId: z.string().optional().describe("Session ID (required unless jobId is supplied)"), jobId: z.string().optional().describe("Durable job ID"), @@ -745,23 +733,17 @@ export function registerWorkflowTools( const snapshot = jobId ? await service.get(jobId, options) : await observeSession(client, sessionId!, directory, messageId, options); const sid = snapshot.sessionId; if (!sid) return jobResult(snapshot, "Session creation could not be confirmed. Inspect this job before retrying."); - const project = snapshot.directory ?? directory; - const todos = await client.get(`/session/${sid}/todo`, undefined, project, options).catch(() => null) as Array> | null; - const session = snapshot.session as { title?: string; summary?: { files?: number } } | undefined; + const session = snapshot.session as { title?: string } | undefined; const lines: string[] = []; if (session?.title) lines.push(`Title: ${session.title}`); - if (Array.isArray(todos) && todos.length) { - const completed = todos.filter(t => t.status === "completed").length; - const inProgress = todos.filter(t => t.status === "in_progress").length; - lines.push(`Tasks: ${completed}/${todos.length} completed, ${inProgress} in progress`); - const current = todos.find(t => t.status === "in_progress"); - if (current) lines.push(`Current: ${current.content ?? current.title ?? "(unknown)"}`); - } - if (typeof session?.summary?.files === "number") lines.push(`Files changed: ${session.summary.files}`); + // v2: the assistant message's snapshot.files lists files touched by the turn. + const result = snapshot.result as { snapshot?: { files?: unknown[] } } | undefined; + const files = Array.isArray(result?.snapshot?.files) ? result!.snapshot!.files : []; + if (files.length > 0) lines.push(`Files changed: ${files.length}`); if (snapshot.status === "completed") lines.push("Done! Use opencode_review_changes to see changes."); if (detailed && snapshot.text) lines.push(`### Last message\n${snapshot.text.slice(0, 500)}`); // Avoid duplicating a potentially long final response in compact monitoring. - return jobResult({ ...snapshot, text: undefined, result: detailed ? snapshot.result : undefined }, lines.join("\n"), { todos }); + return jobResult({ ...snapshot, text: undefined, result: detailed ? snapshot.result : undefined }, lines.join("\n"), {}); } catch (error) { const result = toolError(error); return { ...result, structuredContent: { sessionId, jobId, messageId, directory, status: "unknown" } }; @@ -783,34 +765,29 @@ export function registerWorkflowTools( // swallows the validation error. directory = normalizeDirectory(directory) as typeof directory; - const [health, providerRaw, sessions, vcs] = await Promise.all([ - client.get("/global/health", undefined, directory).catch(() => null), - client.get("/provider", undefined, directory).catch(() => null), - client.get("/session", undefined, directory).catch(() => null), - client.get("/vcs", undefined, directory).catch(() => null), + const [info, providers, credentials, sessions, vcs] = await Promise.all([ + client.get("/api/info", undefined, directory).catch(() => null), + client.get("/api/provider", undefined, directory).catch(() => null), + client.get("/api/credential", undefined, directory).catch(() => null), + client.get("/api/session", undefined, directory).catch(() => null), + client.get("/api/vcs", undefined, directory).catch(() => null), ]); const lines: string[] = []; - // Health - if (health) { - const h = health as Record; - lines.push(`Server: healthy (v${h.version ?? "?"})`); + // Server + if (info) { + const i = info as Record; + lines.push(`Server: healthy (v${i.version ?? "?"})`); } else { lines.push("Server: UNREACHABLE"); } // Providers - if (providerRaw) { - const providers = ( - providerRaw && typeof providerRaw === "object" && "all" in (providerRaw as Record) - ? (providerRaw as Record).all - : providerRaw - ) as Array>; - if (Array.isArray(providers)) { - const configured = providers.filter(isProviderConfigured).length; - lines.push(`Providers: ${configured} configured / ${providers.length} total`); - } + if (Array.isArray(providers)) { + const credentialList = Array.isArray(credentials) ? (credentials as Array>) : []; + const configured = (providers as Array>).filter((p) => isProviderConfigured(p, credentialList)).length; + lines.push(`Providers: ${configured} configured / ${providers.length} total`); } // Sessions @@ -821,9 +798,8 @@ export function registerWorkflowTools( // VCS if (vcs) { const v = vcs as Record; - const branch = v.branch ?? "unknown"; - const dirty = v.dirty === true ? " (dirty)" : v.dirty === false ? " (clean)" : ""; - lines.push(`Branch: ${branch}${dirty}`); + const branch = (v.branch ?? {}) as Record; + if (branch.current) lines.push(`Branch: ${branch.current}`); } return toolResult(`## Status\n${lines.join("\n")}`); diff --git a/tests/client.test.ts b/tests/client.test.ts index 1f1ce44..74ac9e1 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -15,43 +15,41 @@ vi.mock("../src/server-manager.js", () => ({ })); /** - * Mock `@opencode-ai/sdk` so every call to `createOpencodeClient` (both the - * one in the `OpenCodeClient` constructor and the one issued by - * `buildSdkClient` during reconnect) returns the same factory output. Tests - * push call recorders onto a shared queue and can therefore observe the URL - * that each rebuilt SDK client targets. + * Mock `@opencode-ai/sdk/v2/client` so every call to `createOpencodeClient` + * (both the one in the `OpenCodeClient` constructor and the one issued by + * `buildSdkClient` during reconnect) returns the next queued factory output. + * Every call is recorded with its options, so tests can observe the URL and + * the authenticating `fetch` each rebuilt SDK client uses. */ const sdkClientFactory = vi.hoisted(() => { - const factories: Array<(opts: { baseUrl: string }) => unknown> = []; + type Opts = { baseUrl: string; headers?: Record; fetch?: (request: Request) => Promise }; + const factories: Array<(opts: Opts) => unknown> = []; + const created: Opts[] = []; return { factories, - create: (opts: { baseUrl: string }) => { - const factory = factories.shift() ?? (() => ({ _client: {} })); + created, + create: (opts: Opts) => { + created.push(opts); + const factory = factories.shift() ?? (() => ({ client: {} })); return factory(opts); }, }; }); -vi.mock("@opencode-ai/sdk", () => ({ +vi.mock("@opencode-ai/sdk/v2/client", () => ({ createOpencodeClient: sdkClientFactory.create, OpencodeClient: vi.fn(), })); -import { OpenCodeClient, OpenCodeError } from "../src/client.js"; +import { OpenCodeClient, OpenCodeError, OpenCodeSubmissionError } from "../src/client.js"; import { normalizeDirectory } from "../src/helpers.js"; /** * Client tests. * - * Post-SDK-migration (PR #11), `OpenCodeClient.request()` dispatches through - * `(this.api as any)._client` (the SDK's internal HTTP client), not the - * global `fetch`. The legacy `fetch`-mocking HTTP-method suites do not - * exercise the production code path anymore and have been marked - * `describe.skip` below. - * - * They will be revived once the `HttpTransport` interface lands (roadmap - * item C1) — at which point we can inject a `FetchTransport` in tests and - * exercise the full request/retry/header pipeline without poking at SDK - * internals. + * `OpenCodeClient.request()` dispatches through `(this.api as any).client` + * (the v2 SDK's internal HTTP client). Tests stub that object to observe + * request shapes without a server; end-to-end HTTP behaviour lives in + * tests/transport-reliability.test.ts and tests/http-integration.test.ts. */ // ─── OpenCodeError ─────────────────────────────────────────────────────── @@ -209,16 +207,16 @@ describe("x-opencode-directory header", () => { const calls: Array<{ url: string; headers: Record }> = []; // Stub the SDK's internal HTTP client. The production code reaches into - // `(this.api as any)._client` — we replace it so we can inspect what + // `(this.api as any).client` — we replace it so we can inspect what // headers actually get sent without standing up a real server. - (client.api as unknown as { _client: unknown })._client = { + (client.api as unknown as { client: unknown }).client = { get: async (opts: { url: string; headers: Record }) => { calls.push({ url: opts.url, headers: opts.headers }); return { data: {}, error: undefined, response: { status: 200 } }; }, }; - await client.get("/project/current", undefined, "/tmp"); + await client.get("/api/location", undefined, "/tmp"); expect(calls).toHaveLength(1); expect(calls[0].headers["x-opencode-directory"]).toBe("/tmp"); @@ -230,19 +228,102 @@ describe("x-opencode-directory header", () => { const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); const calls: Array<{ url: string; headers: Record }> = []; - (client.api as unknown as { _client: unknown })._client = { + (client.api as unknown as { client: unknown }).client = { get: async (opts: { url: string; headers: Record }) => { calls.push({ url: opts.url, headers: opts.headers }); return { data: {}, error: undefined, response: { status: 200 } }; }, }; - await client.get("/project/current"); + await client.get("/api/location"); expect(calls[0].headers["x-opencode-directory"]).toBeUndefined(); }); }); +// ─── Response handling: v2 envelopes and error mapping ─────────────────── + +describe("v2 response handling", () => { + /** Stub the SDK transport to answer every verb with a canned result. */ + function clientReturning(result: { data?: unknown; error?: unknown; response?: { ok: boolean; status: number } }) { + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + const respond = async () => ({ request: {}, ...result }); + (client.api as unknown as { client: unknown }).client = { get: respond, post: respond, patch: respond, put: respond, delete: respond }; + return client; + } + const ok = { ok: true, status: 200 }; + const session = { id: "ses_e9c829796ffe2F5R738qhSlPwh", title: "Ping", location: { directory: "/repo" } }; + + it("unwraps a {data} envelope on /api/* paths", async () => { + const client = clientReturning({ data: { data: session }, response: ok }); + await expect(client.get("/api/session/ses_e9c829796ffe2F5R738qhSlPwh")).resolves.toEqual(session); + }); + + it("unwraps a location-scoped {data, location} envelope", async () => { + const client = clientReturning({ data: { data: [{ name: "build" }], location: { directory: "/repo" } }, response: ok }); + await expect(client.get("/api/agent")).resolves.toEqual([{ name: "build" }]); + }); + + it("unwraps envelopes on writes too", async () => { + const client = clientReturning({ data: { data: session }, response: ok }); + await expect(client.post("/api/session", { title: "Ping" })).resolves.toEqual(session); + }); + + it("passes through un-enveloped 2.x bodies such as /api/info", async () => { + const info = { version: "2.1.2", pid: 1, urls: ["http://127.0.0.1:4096"], paths: { tmp: "/tmp" } }; + const client = clientReturning({ data: info, response: ok }); + await expect(client.get("/api/info")).resolves.toEqual(info); + }); + + it("does not unwrap objects that merely contain a data key among others", async () => { + const body = { data: "x", other: 1 }; + const client = clientReturning({ data: body, response: ok }); + await expect(client.get("/api/fixture")).resolves.toEqual(body); + }); + + it("does not unwrap raw file reads that happen to look like an envelope", async () => { + const file = { data: { nested: true } }; + const client = clientReturning({ data: file, response: ok }); + await expect(client.get("/api/fs/read/config.json")).resolves.toEqual(file); + }); + + it("does not unwrap non-/api paths", async () => { + const client = clientReturning({ data: { data: 1 }, response: ok }); + await expect(client.get("/doc")).resolves.toEqual({ data: 1 }); + }); + + it("passes through arrays, empty bodies and 204 responses", async () => { + await expect(clientReturning({ data: [1, 2], response: ok }).get("/api/fixture")).resolves.toEqual([1, 2]); + await expect(clientReturning({ data: {}, response: { ok: true, status: 204 } }).post("/api/session/x/model", {})).resolves.toEqual({}); + }); + + it("unwraps paginated {data, cursor} list envelopes to the item array", async () => { + // GET /api/session and GET /api/session/{id}/message (OpenAPI 2.1.2, live server). + const cursor = { previous: "eyJhbmNob3IiOnt9fQ", next: null }; + const client = clientReturning({ data: { data: [session], cursor }, response: ok }); + await expect(client.get("/api/session", { limit: "1" })).resolves.toEqual([session]); + }); + + it("maps a v2 error response to OpenCodeError with the encoded error body", async () => { + const error = { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }; + const client = clientReturning({ error, response: { ok: false, status: 404 } }); + const failure = await client.get("/api/session/ses_missing").catch((e) => e); + expect(failure).toBeInstanceOf(OpenCodeError); + expect(failure).toMatchObject({ status: 404, method: "GET", path: "/api/session/ses_missing", isNotFound: true }); + expect(JSON.parse(failure.body)).toEqual(error); + }); + + it("flags 401 UnauthorizedError as an auth error", async () => { + const client = clientReturning({ error: { _tag: "UnauthorizedError", message: "Authentication required" }, response: { ok: false, status: 401 } }); + await expect(client.get("/api/session")).rejects.toMatchObject({ name: "OpenCodeError", status: 401, isAuth: true }); + }); + + it("treats an SDK error without a response as a lost submission for writes", async () => { + const client = clientReturning({ error: new TypeError("fetch failed") }); + await expect(client.post("/api/session/x/prompt", { text: "hi" })).rejects.toBeInstanceOf(OpenCodeSubmissionError); + }); +}); + // ─── Reconnect path: URL rebinding + reconnectAttempts reset ───────────── describe("reconnect path", () => { @@ -250,10 +331,16 @@ describe("reconnect path", () => { isServerRunningMock.mockReset(); ensureServerMock.mockReset(); sdkClientFactory.factories.length = 0; + sdkClientFactory.created.length = 0; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); /** - * Queue SDK-client factories such that every produced `_client` shares + * Queue SDK-client factories such that every produced `client` shares * the same call-counter closure. The retry loop in `client.ts` runs * `MAX_RETRIES + 1` = 3 attempts before falling through to the reconnect * branch; tests exercising reconnect need at least 3 failures + 1 success. @@ -263,28 +350,29 @@ describe("reconnect path", () => { * rebound URL. */ const MAX_RETRIES_PLUS_ONE = 3; + type Verb = "get" | "post" | "patch" | "put" | "delete"; function queueFlakySdkClients( factoryCount: number, - ): Array<{ baseUrl: string }> { - const calls: Array<{ baseUrl: string }> = []; + ): Array<{ baseUrl: string; method: Verb }> { + const calls: Array<{ baseUrl: string; method: Verb }> = []; let count = 0; for (let i = 0; i < factoryCount; i++) { - sdkClientFactory.factories.push((opts: { baseUrl: string }) => ({ - _client: { - get: async () => { - count++; - calls.push({ baseUrl: opts.baseUrl }); - if (count <= MAX_RETRIES_PLUS_ONE) { - throw new Error("fetch failed"); - } - return { - data: { ok: true }, - error: undefined, - response: { status: 200 }, - }; - }, - }, - })); + sdkClientFactory.factories.push((opts: { baseUrl: string }) => { + const verb = (method: Verb) => async () => { + count++; + calls.push({ baseUrl: opts.baseUrl, method }); + if (count <= MAX_RETRIES_PLUS_ONE) { + // The v2 SDK returns transport failures as `{ error }` with no response. + return { error: new TypeError("fetch failed"), request: {}, response: undefined }; + } + return { + data: { data: { ok: true } }, + error: undefined, + response: { ok: true, status: 200 }, + }; + }; + return { client: { get: verb("get"), post: verb("post"), patch: verb("patch"), put: verb("put"), delete: verb("delete") } }; + }); } return calls; } @@ -304,54 +392,125 @@ describe("reconnect path", () => { isServerRunningMock.mockResolvedValueOnce({ healthy: false }); ensureServerMock.mockResolvedValueOnce({ running: true, - version: "1.14.46", + version: "2.1.2", managedByUs: true, url: "http://localhost:5000", }); - await client.get("/health"); + await expect(client.get("/api/info")).resolves.toEqual({ ok: true }); expect(ensureServerMock).toHaveBeenCalledOnce(); expect(client.getBaseUrl()).toBe("http://localhost:5000"); // First 3 calls (the initial retry loop) target the original URL; // the 4th call — the retry triggered by the reconnect branch — must // observe the rebound baseUrl. + expect(calls).toHaveLength(4); expect(calls[0].baseUrl).toBe("http://localhost:4096"); expect(calls[calls.length - 1].baseUrl).toBe("http://localhost:5000"); // SDK client should be re-instantiated when the URL changes. expect(client.api).not.toBe(originalApi); }); - it("does not rebuild the SDK client when ensureServer returns the same url", async () => { - queueFlakySdkClients(2); + it("keeps the base URL when ensureServer returns the same url", async () => { + const calls = queueFlakySdkClients(2); const client = new OpenCodeClient({ baseUrl: "http://localhost:4096", autoServe: true, }); - const originalApi = client.api; isServerRunningMock.mockResolvedValueOnce({ healthy: false }); ensureServerMock.mockResolvedValueOnce({ running: true, - version: "1.14.46", + version: "2.1.2", managedByUs: true, - url: "http://localhost:4096", + url: "http://localhost:4096/", }); - await client.get("/health"); + await expect(client.get("/api/info")).resolves.toEqual({ ok: true }); expect(client.getBaseUrl()).toBe("http://localhost:4096"); - expect(client.api).toBe(originalApi); + expect(calls.every((call) => call.baseUrl === "http://localhost:4096")).toBe(true); + }); + + it("adopts the password generated for an auto-started server", async () => { + queueFlakySdkClients(2); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096", username: "opencode", password: "stale", autoServe: true }); + + isServerRunningMock.mockResolvedValueOnce({ healthy: false }); + ensureServerMock.mockResolvedValueOnce({ + running: true, version: "2.1.2", managedByUs: true, url: "http://localhost:4096", password: "generated-pw", + }); + + await client.get("/api/info"); + + // The probe and ensureServer are called with the credentials held at the time. + const stale = "Basic " + Buffer.from("opencode:stale").toString("base64"); + expect(isServerRunningMock).toHaveBeenCalledWith("http://localhost:4096", "opencode", "stale", stale); + expect(ensureServerMock).toHaveBeenCalledWith(expect.objectContaining({ autoServe: true, password: "stale", authorization: stale })); + + // The rebuilt SDK client and the raw SSE path both authenticate with the new password. + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("data: {}\n\n", { status: 200 })); + await sdkClientFactory.created.at(-1)!.fetch!(new Request("http://localhost:4096/api/info")); + for await (const _ of client.subscribeSSE("/api/event")) break; + const generated = "Basic " + Buffer.from("opencode:generated-pw").toString("base64"); + expect(fetchSpy.mock.calls.map(([request]) => (request as Request).headers.get("authorization"))).toEqual([generated, generated]); + }); + + it("connect() resolves the server with the configured auth and adopts an auto-served password", async () => { + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096/", username: "alice", password: "cfg", token: "tok", autoServe: true }); + ensureServerMock.mockResolvedValueOnce({ running: true, managedByUs: true, url: "http://127.0.0.1:4100", password: "generated-pw" }); + + await expect(client.connect()).resolves.toMatchObject({ url: "http://127.0.0.1:4100" }); + + expect(ensureServerMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:4096", autoServe: true, username: "alice", password: "cfg", authorization: "Bearer tok", + }); + expect(client.getBaseUrl()).toBe("http://127.0.0.1:4100"); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("{}", { status: 200 })); + await sdkClientFactory.created.at(-1)!.fetch!(new Request("http://127.0.0.1:4100/api/info")); + expect((fetchSpy.mock.calls[0][0] as Request).headers.get("authorization")).toBe("Basic " + Buffer.from("alice:generated-pw").toString("base64")); + }); + + it("connect() keeps the configured auth for a server it did not start", async () => { + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096", token: "tok" }); + ensureServerMock.mockResolvedValueOnce({ running: true, managedByUs: false, url: "http://localhost:4096" }); + await client.connect(); + expect(ensureServerMock).toHaveBeenCalledWith(expect.objectContaining({ autoServe: false, authorization: "Bearer tok" })); + expect(await client.getAuthorization()).toBe("Bearer tok"); + }); + + it("does not call ensureServer when the server is reachable again", async () => { + queueFlakySdkClients(1); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096", autoServe: true }); + isServerRunningMock.mockResolvedValueOnce({ healthy: true, version: "2.1.2" }); + await expect(client.get("/api/info")).resolves.toEqual({ ok: true }); + expect(ensureServerMock).not.toHaveBeenCalled(); + }); + + it("does not reconnect when autoServe is disabled", async () => { + queueFlakySdkClients(1); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + await expect(client.get("/api/info")).rejects.toThrow("fetch failed"); + expect(isServerRunningMock).not.toHaveBeenCalled(); + expect(ensureServerMock).not.toHaveBeenCalled(); + }, 5000); + + it.each(["post", "patch", "put", "delete"] as const)("never reconnects and replays a %s whose submission was lost", async (method) => { + const calls = queueFlakySdkClients(2); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096", autoServe: true }); + isServerRunningMock.mockResolvedValue({ healthy: false }); + ensureServerMock.mockResolvedValue({ running: true, managedByUs: true, url: "http://localhost:4096" }); + + const request = method === "post" ? client.post("/api/session/x/prompt", { text: "hi" }) + : client[method]("/api/session/x", undefined); + await expect(request).rejects.toMatchObject({ name: "OpenCodeSubmissionError", submissionOutcome: "unknown" }); + expect(calls).toHaveLength(1); + expect(isServerRunningMock).not.toHaveBeenCalled(); + expect(ensureServerMock).not.toHaveBeenCalled(); }); it("resets reconnectAttempts after a successful request", async () => { - // 4 round-trips × at most 2 factories each (constructor + possible - // rebuild). Constructor only registers one factory; the rebuild path - // only fires when ensureServer returns a different URL, which it - // doesn't here. So 4 round-trips share the constructor factory's - // counter — that's exactly what we want: each round-trip resets the - // counter independently because we requeue a fresh factory. const client = new OpenCodeClient({ baseUrl: "http://localhost:4096", autoServe: true, @@ -360,15 +519,15 @@ describe("reconnect path", () => { // 4 round-trips, each: 3 failures → reconnect (healthy probe) → success. for (let i = 0; i < 4; i++) { queueFlakySdkClients(1); - // Replace the SDK client's `_client` with the freshly-queued factory's + // Replace the SDK client's `client` with the freshly-queued factory's // output so the next request hits a fresh 3-fail-then-succeed cycle. const factory = sdkClientFactory.factories.shift(); if (factory) { - const fresh = factory({ baseUrl: client.getBaseUrl() }) as { _client: unknown }; - (client.api as unknown as { _client: unknown })._client = fresh._client; + const fresh = factory({ baseUrl: client.getBaseUrl() }) as { client: unknown }; + (client.api as unknown as { client: unknown }).client = fresh.client; } - isServerRunningMock.mockResolvedValueOnce({ healthy: true, version: "1.14.46" }); - await client.get("/health"); + isServerRunningMock.mockResolvedValueOnce({ healthy: true, version: "2.1.2" }); + await client.get("/api/info"); } // All 4 requests should have invoked the reconnect path. If @@ -450,30 +609,60 @@ describe("subscribeSSE", () => { expect(events).toEqual([{ event: "ready", data: "hello" }]); }); -}); -// ─── HTTP method dispatch (deferred — see comment at top of file) ──────── + /** + * OpenCode 2.x `/api/event` wire format, captured from a live 2.1.2 server: + * no `event:` lines, the event name lives in the JSON `type` field, and + * heartbeats are SSE comments. + */ + it("names v2 events after their JSON type and skips heartbeat comments", async () => { + const connected = '{"id":"evt_16392e314001cjTQrygGeKUSCv","type":"server.connected","data":{}}'; + const busy = '{"id":"evt_2","type":"session.status","data":{"sessionID":"ses_1","status":{"type":"busy"}}}'; + installFakeFetch(`data: ${connected}\n\n: heartbeat\n\ndata: ${busy}\n\n`); + + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + const events: Array<{ event: string; data: string }> = []; + for await (const e of client.subscribeSSE("/api/event")) events.push(e); + + expect(events).toEqual([ + { event: "server.connected", data: connected }, + { event: "session.status", data: busy }, + ]); + }); + + it("prefers an explicit event line and falls back to 'message' for untyped data", async () => { + installFakeFetch('event: custom\ndata: {"type":"ignored"}\n\ndata: not json\n\ndata: {"type":42}\n\n'); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + const events: Array<{ event: string; data: string }> = []; + for await (const e of client.subscribeSSE("/api/event")) events.push(e); + expect(events.map((e) => e.event)).toEqual(["custom", "message", "message"]); + }); -describe.skip("OpenCodeClient HTTP methods (TODO: revive after C1 — HttpTransport interface)", () => { - // These tests mocked global `fetch`, which the SDK-routed client no longer - // calls directly. Reintroduce when `src/transport.ts` lands so we can - // inject a `FetchTransport` and exercise: - // - get / post / patch / put / delete request shapes - // - retry on transient (429/502/503/504) and network errors - // - 204 No Content handling - // - non-JSON content-type passthrough - // - Authorization header presence/absence - // - x-opencode-directory header propagation across all verbs - // - MAX_RETRIES exhaustion behaviour - it("placeholder", () => { - /* intentionally empty */ + it("sends Basic auth, the directory header and SSE accept headers", async () => { + const fetchSpy = installFakeFetch(""); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096", password: "pw" }); + for await (const _ of client.subscribeSSE("/api/event", { directory: "/repo" })) { /* drain */ } + const request = fetchSpy.mock.calls[0][0] as Request; + expect(request.url).toBe("http://localhost:4096/api/event"); + expect(Object.fromEntries(request.headers)).toEqual({ + accept: "text/event-stream", + "cache-control": "no-cache", + "x-opencode-directory": "/repo", + authorization: "Basic " + Buffer.from("opencode:pw").toString("base64"), + }); }); - beforeEach(() => { - /* no-op */ + it("keeps the base URL path prefix", async () => { + const fetchSpy = installFakeFetch(""); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096/prefix/" }); + for await (const _ of client.subscribeSSE("/api/event")) { /* drain */ } + expect((fetchSpy.mock.calls[0][0] as Request).url).toBe("http://localhost:4096/prefix/api/event"); }); - afterEach(() => { - /* no-op */ + it("surfaces a 401 as an auth OpenCodeError", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response('{"_tag":"UnauthorizedError","message":"Authentication required"}', { status: 401 })); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + await expect((async () => { for await (const _ of client.subscribeSSE("/api/event")) { /* none */ } })()) + .rejects.toMatchObject({ name: "OpenCodeError", status: 401, isAuth: true, path: "/api/event" }); }); }); diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index b42ee9a..048fae5 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -21,7 +21,10 @@ function capture() { resources.set(name, { uri, handler }); }, } as unknown as McpServer; - const client = { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn() }; + const client = { get: vi.fn(), getWithCursor: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }; + // The paged reader goes through getWithCursor; delegate to get so per-call + // stubs keep serving both entry points. + client.getWithCursor.mockImplementation(async (path: string, ...args: any[]) => ({ data: await client.get(path, ...args) })); for (const register of [registerConfigTools, registerFileTools, registerProviderTools, registerMessageTools, registerSessionTools]) { register(server, client as unknown as OpenCodeClient); } @@ -33,40 +36,80 @@ function capture() { return { tools, resources, client, call }; } +// OpenCode 2.x flat messages (after envelope unwrapping). +const user = (id: string, created: number) => ({ id, type: "user", text: "Test", time: { created } }); +const assistant = (id: string, created: number, text: string) => ({ + id, type: "assistant", agent: "build", model: { id: "model", providerID: "provider" }, + time: { created, completed: created }, content: [{ type: "text", text }], finish: "stop", +}); +const idle = (id: string, created: number, outcome: string, extra: Record = {}) => ({ id, type: "idle", outcome, time: { created }, ...extra }); +const inboxUser = (id: string, created: number) => ({ id, sessionID: "ses_example", type: "user", time: { created }, payload: { text: "Test" }, delivery: "queue" }); + describe("provider and configuration contracts", () => { - it("sanitizes resource and config-update output, including short passwords", async () => { - const { resources, client, call } = capture(); - const config = { provider: { example: { options: { apiKey: "synthetic-private-credential", password: "tiny" } } } }; - client.get.mockResolvedValue(config); - client.patch.mockResolvedValue(config); + it("sanitizes config and provider resources, including short passwords", async () => { + const { resources, client } = capture(); + // GET /api/config: layered config documents; GET /api/provider: Provider.Info list. + const config = [{ type: "document", path: "/home/user/.config/opencode/opencode.json", + info: { providers: { example: { settings: { apiKey: "synthetic-private-credential", password: "tiny" } } } } }]; + const providers = [{ id: "example", name: "Example", activation: "enabled", package: "@opencode/ai/providers/openai-compatible", + settings: { apiKey: "synthetic-private-credential", password: "tiny", baseURL: "https://example.invalid/v1" } }]; + client.get.mockImplementation(async (path: string) => path === "/api/config" ? config : providers); for (const name of ["config", "providers"]) { const result = await resources.get(name)!.handler(); expect(result.contents[0].text).not.toContain("synthetic-private-credential"); expect(result.contents[0].text).not.toContain('"tiny"'); } - const result = await call("opencode_config_update", { config: { theme: "dark" } }); - expect(result.structuredContent.data).toEqual(redactSecrets(config)); - expect(JSON.stringify(result)).not.toContain('"tiny"'); + expect(client.get.mock.calls.map(([path]) => path)).toEqual(["/api/config", "/api/provider"]); + }); + + it("patches the experimental config endpoint and redacts any echoed secrets", async () => { + const { client, call } = capture(); + // 2.1.2 answers 204 (Config.Patch accepts `shell`); tolerate a body but never leak secrets from it. + client.patch.mockResolvedValueOnce(undefined); + const result = await call("opencode_config_update", { config: { shell: "/bin/zsh" }, directory: "/srv/project" }); + expect(client.patch).toHaveBeenLastCalledWith("/api/experimental/config", { shell: "/bin/zsh" }, "/srv/project"); + expect(result.isError).toBeUndefined(); + const echoed = { shell: "/bin/zsh", password: "tiny" }; + client.patch.mockResolvedValueOnce(echoed); + const redacted = await call("opencode_config_update", { config: { shell: "/bin/zsh" } }); + expect(redacted.structuredContent.data).toEqual(redactSecrets(echoed)); + expect(JSON.stringify(redacted)).not.toContain('"tiny"'); + }); + + it("starts integration OAuth with an explicit method and completes it with the attempt code", async () => { + const { client, call } = capture(); + client.post.mockResolvedValue({ attemptID: "att_1", url: "https://example.invalid/authorize", instructions: "Open the URL", mode: "code", time: { created: 1, expires: 2 } }); + const started = await call("opencode_provider_oauth_authorize", { providerId: "example", methodID: "browser" }); + expect(client.post).toHaveBeenLastCalledWith("/api/integration/example/connect/oauth", { methodID: "browser" }); + expect(started.structuredContent.data).toMatchObject({ attemptID: "att_1", mode: "code" }); + client.post.mockResolvedValue(undefined); + await call("opencode_provider_oauth_callback", { providerId: "example", attemptID: "att_1", code: "synthetic-code" }); + expect(client.post).toHaveBeenLastCalledWith("/api/integration/example/connect/oauth/att_1/complete", { code: "synthetic-code" }); + await call("opencode_provider_oauth_callback", { providerId: "example", attemptID: "att_1" }); + expect(client.post).toHaveBeenLastCalledWith("/api/integration/example/connect/oauth/att_1/complete", {}); + expect(() => call("opencode_provider_oauth_callback", { providerId: "example" })).toThrow(); }); - it("sends OAuth method and additional inputs, and validates the callback", async () => { + it("defaults OAuth to the integration's first oauth method", async () => { const { client, call } = capture(); - client.post.mockResolvedValue({ url: "https://example.invalid/authorize", method: "code" }); - await call("opencode_provider_oauth_authorize", { providerId: "example", method: 2, inputs: { organization: "test" } }); - expect(client.post).toHaveBeenLastCalledWith("/provider/example/oauth/authorize", { method: 2, inputs: { organization: "test" } }); + client.get.mockResolvedValue([{ id: "example", name: "Example", methods: [{ type: "key" }, { id: "browser", type: "oauth", label: "Login" }], connections: [] }]); + client.post.mockResolvedValue({ attemptID: "att_1", url: "https://example.invalid/authorize", instructions: "", mode: "auto", time: { created: 1, expires: 2 } }); await call("opencode_provider_oauth_authorize", { providerId: "example" }); - expect(client.post).toHaveBeenLastCalledWith("/provider/example/oauth/authorize", { method: 0 }); - await call("opencode_provider_oauth_callback", { providerId: "example", callbackData: { method: 2, code: "synthetic-code" } }); - expect(client.post).toHaveBeenLastCalledWith("/provider/example/oauth/callback", { method: 2, code: "synthetic-code" }); - expect(() => call("opencode_provider_oauth_callback", { providerId: "example", callbackData: { method: -1 } })).toThrow(); + expect(client.post).toHaveBeenLastCalledWith("/api/integration/example/connect/oauth", { methodID: "browser" }); }); - it("includes method indices and input metadata in discovery", async () => { + it("lists integration connection methods and preserves the raw method IDs", async () => { const { client, call } = capture(); - const data = { example: [{ type: "oauth", label: "Personal", prompts: [{ key: "organization", type: "text" }] }] }; + const data = [ + { id: "digitalocean", name: "DigitalOcean", connections: [], + methods: [{ type: "key" }, { type: "env", names: ["DIGITALOCEAN_ACCESS_TOKEN"] }, { id: "browser", type: "oauth", label: "Login with DigitalOcean" }] }, + { id: "example", name: "Example", connections: [{ id: "conn_1" }], methods: [{ id: "personal", type: "oauth", label: "Personal" }] }, + ]; client.get.mockResolvedValue(data); const result = await call("opencode_provider_auth_methods", {}); - expect(result.content[0].text).toContain("[0] oauth (Personal)"); + expect(client.get).toHaveBeenLastCalledWith("/api/integration", undefined, undefined); + expect(result.content[0].text).toContain("- digitalocean: key, env(DIGITALOCEAN_ACCESS_TOKEN), oauth"); + expect(result.content[0].text).toContain("- example: oauth (1 connection(s))"); expect(result.structuredContent.data).toEqual(data); }); }); @@ -84,55 +127,104 @@ describe("machine-readable response contracts", () => { }); it("recognizes typed errors without marking normal debugging prose as failure", () => { - expect(analyzeMessageResponse({ parts: [{ type: "text", text: "Fixed the error in validation." }] }).hasError).toBe(false); - expect(analyzeMessageResponse({ info: { error: { name: "APIError", data: { message: "Unavailable" } } }, parts: [] }).hasError).toBe(true); - expect(analyzeMessageResponse({ parts: [{ type: "tool", state: { status: "error", error: "Compiler failed" } }] }).hasError).toBe(true); + expect(analyzeMessageResponse(assistant("msg_a", 1, "Fixed the error in validation.")).hasError).toBe(false); + expect(analyzeMessageResponse({ ...assistant("msg_a", 1, ""), content: [], error: { type: "APIError", message: "Unavailable" } }).hasError).toBe(true); + expect(analyzeMessageResponse({ ...assistant("msg_a", 1, ""), content: [ + { type: "tool", id: "call_1", name: "shell", state: { status: "error", input: {}, error: { type: "ToolError", message: "Compiler failed" } } }, + ] }).hasError).toBe(true); + expect(analyzeMessageResponse(idle("msg_i", 2, "failed", { error: { type: "ProviderError", message: "Down" } })).hasError).toBe(true); + expect(analyzeMessageResponse(idle("msg_i", 2, "succeeded")).hasError).toBe(false); expect(toolResult("summary", false, { data: [1] }).structuredContent).toEqual({ data: [1] }); - const structured = { info: { structured: { answer: 42 } }, parts: [] }; - expect(analyzeMessageResponse(structured)).toEqual({ isEmpty: false, hasError: false, warning: null }); - expect(JSON.parse(formatMessageResponse(structured))).toEqual({ answer: 42 }); }); - it("returns navigable SDK symbol locations and preserves structured source data", async () => { + it("formats assistant text and tool results while omitting reasoning", () => { + const text = formatMessageResponse({ ...assistant("msg_a", 1, "Done"), content: [ + { type: "reasoning", text: "private chain of thought" }, + { type: "tool", id: "call_1", name: "shell", state: { status: "completed", input: {}, content: [{ type: "text", text: "exit 0" }] } }, + { type: "text", text: '{"answer":42}' }, + ] }); + expect(text).toContain("[Tool: shell] exit 0"); + expect(text).toContain('{"answer":42}'); + expect(text).not.toContain("private chain of thought"); + }); + + it("returns navigable file-search entries and preserves structured source data", async () => { const { client, call } = capture(); - const data = [{ name: "example", kind: 12, location: { uri: "file:///project/example.ts", range: { start: { line: 0, character: 0 } } } }]; + // GET /api/fs/find returns FileSystem.Entry ({ path, type }) items. + const data = [{ path: "src/example.ts", type: "file" }, { path: "src", type: "directory" }]; client.get.mockResolvedValue(data); - const result = await call("opencode_find_symbol", { query: "example" }); - expect(result.content[0].text).toContain("file:///project/example.ts:1"); + const result = await call("opencode_find_file", { query: "example", type: "file", limit: 5, directory: "/project" }); + expect(client.get).toHaveBeenLastCalledWith("/api/fs/find", { query: "example", type: "file", limit: "5" }, "/project"); + expect(result.content[0].text).toContain("src/example.ts"); + expect(result.content[0].text).toContain("[DIR] src"); expect(result.content[0].text).not.toContain("[object Object]"); expect(result.structuredContent.data).toEqual(data); + const text = await call("opencode_find_text", { pattern: "example" }); + expect(client.get).toHaveBeenLastCalledWith("/api/fs/find", { query: "example" }, undefined); + expect(text.structuredContent.data).toEqual(data); }); - it("returns session creation IDs as structured data", async () => { + it("returns session creation IDs as structured data and binds the location in the body", async () => { const { client, call } = capture(); - const session = { id: "ses_example", title: "Example" }; + const session = { id: "ses_example", title: "Example", time: { created: 1, updated: 1 }, location: { directory: "/srv/project" } }; client.post.mockResolvedValue(session); - expect((await call("opencode_session_create", {})).structuredContent).toEqual({ sessionId: session.id, session }); + expect((await call("opencode_session_create", { title: "Example", directory: "/srv/project" })).structuredContent).toEqual({ sessionId: session.id, session }); + expect(client.post.mock.lastCall?.[0]).toBe("/api/session"); + expect(client.post.mock.lastCall?.[1]).toEqual({ title: "Example", location: { directory: "/srv/project" } }); }); - it("forwards supported prompt format for sync and async messages", async () => { + it("submits plain-text prompts and correlates async sends by a pre-assigned message ID", async () => { const { client, call, tools } = capture(); - const format = { type: "json_schema", schema: { type: "object" }, retryCount: 1 }; - client.post.mockResolvedValue({ info: { id: "msg_example" }, parts: [{ type: "text", text: "{}" }] }); - await call("opencode_message_send", { sessionId: "ses_example", text: "Test", format }); - expect(client.post.mock.lastCall?.[1]).toMatchObject({ format }); - const asynchronous = await call("opencode_message_send_async", { sessionId: "ses_example", text: "Test", format }); - expect(client.post.mock.lastCall?.[1]).toMatchObject({ format }); + client.post.mockResolvedValue(inboxUser("msg_inbox", 1)); + const asynchronous = await call("opencode_message_send_async", { sessionId: "ses_example", text: "Test" }); + expect(client.post.mock.lastCall?.[0]).toBe("/api/session/ses_example/prompt"); expect(asynchronous.structuredContent.data.messageId).toMatch(/^msg_[a-f0-9]{26}$/); - expect(client.post.mock.lastCall?.[1].messageID).toBe(asynchronous.structuredContent.data.messageId); - expect(tools.get("opencode_shell_execute")!.schema).not.toHaveProperty("format"); + expect(client.post.mock.lastCall?.[1]).toEqual({ id: asynchronous.structuredContent.data.messageId, text: "Test", delivery: "queue" }); + // OpenCode 2.x has no prompt-level output format. + for (const name of ["opencode_message_send", "opencode_message_send_async", "opencode_shell_execute"]) { + expect(tools.get(name)!.schema).not.toHaveProperty("format"); + } + }); + + it("waits for the synchronous turn and returns its own assistant answer", async () => { + const { client, call } = capture(); + client.post.mockImplementation(async (path: string) => path.endsWith("/prompt") ? inboxUser("msg_new", 4) : undefined); + // Newest-first, as returned by GET /api/session/{id}/message. + client.get.mockResolvedValue([idle("msg_i2", 6, "succeeded"), assistant("msg_a2", 5, "New answer"), user("msg_new", 4), + idle("msg_i1", 3, "succeeded"), assistant("msg_a1", 2, "Old answer"), user("msg_old", 1)]); + const result = await call("opencode_message_send", { sessionId: "ses_example", text: "Test" }); + expect(client.post.mock.calls.map(([path, body]) => [path, body])).toEqual([ + ["/api/session/ses_example/prompt", { text: "Test", delivery: "queue" }], + ["/api/experimental/session/ses_example/wait", undefined], + ]); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("New answer"); + expect(result.content[0].text).not.toContain("Old answer"); + expect(result.structuredContent.data).toMatchObject({ id: "msg_a2" }); + }); + + // Regression: the idle marker must be the one closing this turn, never an + // older one in the same history window. + it("judges a synchronous send by its own turn's idle marker, not an older one", async () => { + const { client, call } = capture(); + client.post.mockImplementation(async (path: string) => path.endsWith("/prompt") ? inboxUser("msg_new", 4) : undefined); + client.get.mockResolvedValue([idle("msg_i2", 6, "succeeded"), assistant("msg_a2", 5, "New answer"), user("msg_new", 4), + idle("msg_i1", 3, "failed", { error: { type: "ProviderError", message: "old outage" } }), assistant("msg_a1", 2, "Old answer"), user("msg_old", 1)]); + const result = await call("opencode_message_send", { sessionId: "ses_example", text: "Test" }); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).not.toContain("old outage"); }); }); describe("project-scoped resource contracts", () => { it.each(["/srv/project with spaces", "C:\\work\\project", "/srv/100%ready"])("reads server directory %s without local resolution", async (directory) => { const { resources, client } = capture(); - client.get.mockResolvedValue({ id: "project" }); + client.get.mockResolvedValue({ directory, project: { id: "project" } }); const resource = resources.get("project-scoped")!; const uri = new URL(`opencode://projects/${encodeURIComponent(directory)}/current`); const variables = (resource.uri as ResourceTemplate).uriTemplate.match(uri.href)!; const result = await resource.handler(uri, variables); - expect(client.get).toHaveBeenLastCalledWith("/project/current", undefined, directory); + expect(client.get).toHaveBeenLastCalledWith("/api/location", undefined, directory); expect(result.contents[0].uri).toBe(uri.href); }); @@ -143,7 +235,7 @@ describe("project-scoped resource contracts", () => { const resource = resources.get(name)!; const uri = new URL(`opencode://projects/%2Fsrv%2Fproject/sessions/ses_example${suffix}`); await resource.handler(uri, (resource.uri as ResourceTemplate).uriTemplate.match(uri.href)!); - expect(client.get).toHaveBeenLastCalledWith(`/session/ses_example${endpoint}`, undefined, "/srv/project"); + expect(client.get).toHaveBeenLastCalledWith(`/api/session/ses_example${endpoint}`, undefined, "/srv/project"); } }); }); diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 23c3531..135dc54 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -18,108 +18,123 @@ import { directoryParam, } from "../src/helpers.js"; +// ─── OpenCode 2.x message fixtures ─────────────────────────────────────── +// Shapes follow components/schemas Session.Message.{User,Assistant,Idle} and +// Session.Message.Assistant.Tool / ToolState.* from the 2.x OpenAPI spec. + +const tok = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }; + +function userMsg(id: string, text: string, created = 1) { + return { id, time: { created }, type: "user", text }; +} + +function assistantMsg(content: unknown[], extra: Record = {}) { + return { id: "msg_a", time: { created: 2 }, type: "assistant", content, ...extra }; +} + +function toolPart(name: string, state: Record, id = `call_${name}`) { + return { type: "tool", id, name, state, time: { created: 1 } }; +} + +const completed = (input: Record, text?: string) => ({ + status: "completed", + input, + content: [{ type: "text", text: text ?? "ok" }], +}); + +const failed = (input: Record, message: string) => ({ + status: "error", + input, + error: { type: "ToolError", message }, +}); + // ─── formatMessageResponse ─────────────────────────────────────────────── describe("formatMessageResponse", () => { - it("shows validated JSON instead of the StructuredOutput receipt", () => { - const message = { - info: { role: "assistant", structured: { status: "ok", sum: 42 } }, - parts: [ - { type: "tool", tool: "StructuredOutput", state: { status: "completed", output: "Structured output captured successfully." } }, - { type: "step-finish", cost: 0, tokens: { output: 23 } }, - ], - }; - expect(JSON.parse(formatMessageResponse(message))).toEqual({ status: "ok", sum: 42 }); - expect(formatMessageList([message])).toContain('"sum": 42'); - expect(formatMessageList([message])).not.toContain("Agent performed"); - }); it("returns empty string for null/undefined input", () => { expect(formatMessageResponse(null)).toBe(""); expect(formatMessageResponse(undefined)).toBe(""); }); - it("omits verbose message info header for cleaner output", () => { - const result = formatMessageResponse({ - info: { id: "msg-1", role: "assistant", createdAt: "2025-01-01" }, - parts: [], - }); - // Message header is now omitted — output should be empty for messages with no parts - expect(result).toBe(""); + it("omits verbose message header for cleaner output", () => { + // No header is emitted — an assistant message with no content renders empty + expect(formatMessageResponse(assistantMsg([]))).toBe(""); }); - it("extracts text parts", () => { - const result = formatMessageResponse({ - parts: [ - { type: "text", text: "Hello world" }, - { type: "text", text: "Second part" }, - ], - }); + it("extracts text content items", () => { + const result = formatMessageResponse(assistantMsg([ + { type: "text", text: "Hello world" }, + { type: "text", text: "Second part" }, + ])); expect(result).toContain("Hello world"); expect(result).toContain("Second part"); }); - it("extracts text from content field as fallback", () => { - const result = formatMessageResponse({ - parts: [{ type: "text", content: "Fallback content" }], - }); - expect(result).toContain("Fallback content"); + it("omits reasoning content from user-facing output", () => { + const result = formatMessageResponse(assistantMsg([ + { type: "reasoning", text: "internal chain of thought" }, + { type: "text", text: "Final answer" }, + ])); + expect(result).toBe("Final answer"); + expect(result).not.toContain("chain of thought"); }); - it("formats tool-invocation parts", () => { - const result = formatMessageResponse({ - parts: [ - { - type: "tool-invocation", - toolName: "readFile", - input: { path: "/foo" }, - }, - ], - }); - expect(result).toContain("[Tool: readFile]"); - expect(result).toContain("/foo"); + it("formats running tool parts with their status", () => { + const result = formatMessageResponse(assistantMsg([ + toolPart("read", { status: "running", input: { path: "/foo" }, metadata: {} }), + ])); + expect(result).toBe("[Tool: read] running"); }); - it("formats tool-result parts with output", () => { - const result = formatMessageResponse({ - parts: [ - { - type: "tool-result", - toolName: "readFile", - output: "file contents here", - }, - ], - }); - expect(result).toContain("[Tool: readFile]"); + it("formats completed tool parts with their text content", () => { + const result = formatMessageResponse(assistantMsg([ + toolPart("read", completed({ path: "/foo" }, "file contents here")), + ])); + expect(result).toContain("[Tool: read]"); expect(result).toContain("file contents here"); }); - it("formats tool errors", () => { - const result = formatMessageResponse({ - parts: [ - { - type: "tool-invocation", - toolName: "readFile", - error: "File not found", - }, - ], - }); - expect(result).toContain("ERROR: File not found"); + it("truncates long completed tool content to 300 chars", () => { + const result = formatMessageResponse(assistantMsg([ + toolPart("read", completed({ path: "/foo" }, "x".repeat(1000))), + ])); + expect(result).toBe(`[Tool: read] ${"x".repeat(297)}...`); }); - it("handles unknown part types", () => { - const result = formatMessageResponse({ - parts: [{ type: "custom-type", data: "something" }], - }); - expect(result).toContain("[custom-type]"); + it("formats tool errors using the structured error message", () => { + const result = formatMessageResponse(assistantMsg([ + toolPart("read", failed({ path: "/foo" }, "File not found")), + ])); + expect(result).toContain("[Tool: read] ERROR: File not found"); }); - it("handles missing info gracefully and still extracts text", () => { - const result = formatMessageResponse({ - info: { role: undefined, id: undefined }, - parts: [{ type: "text", text: "hello" }], - }); - // No header emitted, but text content is still extracted - expect(result).toContain("hello"); + it("skips unknown content types without text", () => { + const result = formatMessageResponse(assistantMsg([ + { type: "custom-type", data: "something" }, + { type: "text", text: "visible" }, + ])); + expect(result).toBe("visible"); + }); + + it("formats user messages by their text", () => { + expect(formatMessageResponse(userMsg("msg_u", "Hi there"))).toBe("Hi there"); + }); + + it("formats idle markers with their outcome", () => { + expect(formatMessageResponse({ id: "msg_i", time: { created: 3 }, type: "idle", outcome: "succeeded" })) + .toBe("Run succeeded."); + expect(formatMessageResponse({ + id: "msg_i", time: { created: 3 }, type: "idle", outcome: "failed", + error: { type: "ProviderError", message: "boom" }, + })).toContain("Run failed:"); + }); + + it("surfaces the assistant error when there is no content", () => { + const result = formatMessageResponse(assistantMsg([], { + finish: "error", + error: { type: "ProviderAuthError", message: "Invalid API key", status: 401 }, + })); + expect(result).toBe("Error: Invalid API key"); }); }); @@ -136,103 +151,87 @@ describe("formatMessageList", () => { ); }); - it("formats a list of messages with indices", () => { + it("formats a list of messages with type tags and ids", () => { const messages = [ - { - info: { role: "user", id: "m1" }, - parts: [{ type: "text", text: "Hi there" }], - }, - { - info: { role: "assistant", id: "m2" }, - parts: [{ type: "text", text: "Hello!" }], - }, + userMsg("m1", "Hi there", 1), + { ...assistantMsg([{ type: "text", text: "Hello!" }]), id: "m2" }, ]; const result = formatMessageList(messages); - expect(result).toContain("Message 1 [user]"); - expect(result).toContain("Message 2 [assistant]"); + expect(result).toContain(`--- [user] (m1) ${new Date(1).toISOString()} ---`); + expect(result).toContain("--- [assistant] (m2)"); expect(result).toContain("Hi there"); expect(result).toContain("Hello!"); }); - it("counts tool calls when text is also present", () => { + it("orders messages chronologically regardless of input order", () => { const messages = [ - { - info: { role: "assistant", id: "m1" }, - parts: [ - { type: "text", text: "Let me check" }, - { type: "tool-invocation", toolName: "search" }, - { type: "tool-result", toolName: "search" }, - ], - }, + { id: "m3", time: { created: 30 }, type: "idle", outcome: "succeeded" }, + { ...assistantMsg([{ type: "text", text: "Answer" }]), id: "m2", time: { created: 20 } }, + userMsg("m1", "Question", 10), ]; const result = formatMessageList(messages); + expect(result.indexOf("Question")).toBeLessThan(result.indexOf("Answer")); + expect(result.indexOf("Answer")).toBeLessThan(result.indexOf("[idle] (m3)")); + expect(result).toContain("Run succeeded."); + }); + + it("includes the assistant model when present", () => { + const result = formatMessageList([ + assistantMsg([{ type: "text", text: "Hi" }], { model: { providerID: "anthropic", id: "claude-x" } }), + ]); + expect(result).toContain("model: anthropic/claude-x"); + }); + + it("counts tool calls when text is also present", () => { + const result = formatMessageList([ + assistantMsg([ + { type: "text", text: "Let me check" }, + toolPart("grep", completed({ pattern: "x" }), "c1"), + toolPart("grep", completed({ pattern: "y" }), "c2"), + ]), + ]); expect(result).toContain("2 tool call(s)"); expect(result).toContain("Let me check"); }); it("shows tool summaries when text is empty (the empty-message bug)", () => { - const messages = [ - { - info: { role: "assistant", id: "m1" }, - parts: [ - { type: "text", text: "" }, - { type: "tool-invocation", toolName: "Write", input: { path: "/src/App.tsx" } }, - { type: "tool-result", toolName: "Write" }, - ], - }, - ]; - const result = formatMessageList(messages); + const result = formatMessageList([ + assistantMsg([ + { type: "text", text: "" }, + toolPart("write", completed({ path: "/src/App.tsx" }), "c1"), + toolPart("read", completed({ path: "/src/main.tsx" }), "c2"), + ]), + ]); expect(result).toContain("Agent performed 2 action(s)"); - expect(result).toContain("Write: /src/App.tsx"); + expect(result).toContain("write: /src/App.tsx"); }); it("shows tool summaries when there are no text parts at all", () => { - const messages = [ - { - info: { role: "assistant", id: "m1" }, - parts: [ - { type: "tool-invocation", toolName: "Bash", input: { command: "npm install" } }, - ], - }, - ]; - const result = formatMessageList(messages); + const result = formatMessageList([ + assistantMsg([toolPart("bash", completed({ command: "npm install" }))]), + ]); expect(result).toContain("Agent performed 1 action(s)"); - expect(result).toContain("Bash: npm install"); + expect(result).toContain("bash: npm install"); }); - it("shows (no content) when parts array is empty", () => { - const messages = [ - { info: { role: "assistant", id: "m1" }, parts: [] }, - ]; - const result = formatMessageList(messages); - expect(result).toContain("(no content)"); + it("shows (no content) when content array is empty", () => { + expect(formatMessageList([assistantMsg([])])).toContain("(no content)"); }); it("truncates long tool input hints to 80 chars", () => { const longPath = "/very/long/path/that/goes/on/and/on/and/on/and/repeats/many/times/so/it/exceeds/eighty/chars.tsx"; - const messages = [ - { - info: { role: "assistant", id: "m1" }, - parts: [ - { type: "tool-invocation", toolName: "Write", input: { path: longPath } }, - ], - }, - ]; - const result = formatMessageList(messages); + const result = formatMessageList([ + assistantMsg([toolPart("write", completed({ path: longPath }))]), + ]); expect(result).toContain("..."); expect(result.length).toBeLessThan(longPath.length + 200); }); it("caps tool summaries at 10 and shows overflow count", () => { - const toolParts = Array.from({ length: 15 }, (_, i) => ({ - type: "tool-invocation", - toolName: `Tool${i}`, - input: { path: `/file${i}.ts` }, - })); - const messages = [ - { info: { role: "assistant", id: "m1" }, parts: toolParts }, - ]; - const result = formatMessageList(messages); + const parts = Array.from({ length: 15 }, (_, i) => + toolPart(`Tool${i}`, completed({ path: `/file${i}.ts` })), + ); + const result = formatMessageList([assistantMsg(parts)]); expect(result).toContain("Agent performed 15 action(s)"); expect(result).toContain("... and 5 more"); expect(result).toContain("Tool0"); @@ -241,47 +240,40 @@ describe("formatMessageList", () => { }); it("marks tool errors in summaries", () => { - const messages = [ - { - info: { role: "assistant", id: "m1" }, - parts: [ - { type: "tool-result", toolName: "Bash", error: "command not found" }, - ], - }, - ]; - const result = formatMessageList(messages); - expect(result).toContain("Bash"); - expect(result).toContain("ERROR"); + const result = formatMessageList([ + assistantMsg([toolPart("bash", failed({ command: "foo" }, "command not found"))]), + ]); + expect(result).toContain("bash: foo ERROR"); + }); + + it("appends cost and token metadata from the assistant message", () => { + const result = formatMessageList([ + assistantMsg([{ type: "text", text: "Done!" }], { + cost: 0.0023, + tokens: { input: 1500, output: 200, reasoning: 0, cache: { read: 100, write: 20 } }, + }), + ]); + expect(result).toContain("cost: $0.0023"); + expect(result).toContain("tokens: 1500 in, 200 out, 120 cache"); }); - it("appends cost metadata from step-finish parts", () => { - const messages = [ - { - info: { role: "assistant", id: "m1" }, - parts: [ - { type: "text", text: "Done!" }, - { type: "step-finish", cost: 0.0023, tokens: { input: 1500, output: 200 } }, - ], - }, - ]; - const result = formatMessageList(messages); - expect(result).toContain("cost: $0.0023"); - expect(result).toContain("tokens: 1500 in, 200 out"); + it("omits cost metadata when cost and tokens are zero", () => { + const result = formatMessageList([ + assistantMsg([{ type: "text", text: "Done!" }], { cost: 0, tokens: tok }), + ]); + expect(result).not.toContain("cost:"); + expect(result).not.toContain("tokens:"); }); it("prefers command/query/url hints from tool input", () => { - const messages = [ - { - info: { role: "assistant", id: "m1" }, - parts: [ - { type: "tool-invocation", toolName: "Search", input: { query: "todo context" } }, - { type: "tool-invocation", toolName: "Fetch", input: { url: "https://example.com" } }, - ], - }, - ]; - const result = formatMessageList(messages); - expect(result).toContain("Search: todo context"); - expect(result).toContain("Fetch: https://example.com"); + const result = formatMessageList([ + assistantMsg([ + toolPart("websearch", completed({ query: "todo context" }), "c1"), + toolPart("webfetch", completed({ url: "https://example.com" }), "c2"), + ]), + ]); + expect(result).toContain("websearch: todo context"); + expect(result).toContain("webfetch: https://example.com"); }); }); @@ -343,15 +335,21 @@ describe("formatSessionList", () => { ); }); - it("formats sessions with title, id, and date", () => { + it("formats sessions with title, id, and created time", () => { + const created = Date.UTC(2025, 0, 1); const sessions = [ - { id: "s1", title: "Fix bug", createdAt: "2025-01-01" }, - { id: "s2", title: "Add feature", createdAt: "2025-01-02" }, + { id: "s1", title: "Fix bug", time: { created, updated: created } }, + { id: "s2", title: "Add feature", time: { created: created + 1, updated: created + 1 } }, ]; const result = formatSessionList(sessions); expect(result).toContain("Fix bug [s1]"); expect(result).toContain("Add feature [s2]"); - expect(result).toContain("created 2025-01-01"); + expect(result).toContain("created 2025-01-01T00:00:00.000Z"); + }); + + it("shows the last run outcome", () => { + const result = formatSessionList([{ id: "s1", title: "Done", outcome: "failed" }]); + expect(result).toBe("- Done [s1] [failed]"); }); it("shows parent relationship", () => { @@ -384,128 +382,138 @@ describe("analyzeMessageResponse", () => { expect(result.warning).toContain("API key"); }); - it("detects response with empty parts array as empty", () => { - const result = analyzeMessageResponse({ parts: [] }); + it("detects response with empty content array as empty", () => { + const result = analyzeMessageResponse(assistantMsg([])); expect(result.isEmpty).toBe(true); expect(result.warning).toContain("no text content"); }); it("detects response with only whitespace text as empty", () => { - const result = analyzeMessageResponse({ - parts: [{ type: "text", text: " " }], - }); + const result = analyzeMessageResponse(assistantMsg([{ type: "text", text: " " }])); expect(result.isEmpty).toBe(true); expect(result.warning).toContain("no text content"); }); - it("recognizes legacy tool-only activity", () => { - const result = analyzeMessageResponse({ - parts: [{ type: "tool-invocation", toolName: "something" }], - }); + it("detects reasoning-only responses as empty", () => { + const result = analyzeMessageResponse(assistantMsg([{ type: "reasoning", text: "thinking" }])); + expect(result.isEmpty).toBe(true); + }); + + it("recognizes tool-only activity", () => { + const result = analyzeMessageResponse(assistantMsg([ + toolPart("bash", { status: "running", input: { command: "ls" }, metadata: {} }), + ])); expect(result.isEmpty).toBe(false); expect(result.warning).toBeNull(); }); - it("detects error parts with .error field", () => { - const result = analyzeMessageResponse({ - parts: [{ type: "tool-result", error: "Unauthorized" }], - }); + it("detects the assistant-level structured error", () => { + const result = analyzeMessageResponse(assistantMsg([], { + finish: "error", + error: { type: "ProviderAuthError", message: "Unauthorized", status: 401 }, + })); expect(result.hasError).toBe(true); expect(result.isEmpty).toBe(false); expect(result.warning).toContain("Unauthorized"); expect(result.warning).not.toContain("verify your API key"); }); - it("does not treat error keywords in prose as tool failure", () => { + it("detects failed idle markers", () => { const result = analyzeMessageResponse({ - parts: [{ type: "text", text: "Error: invalid key provided" }], + id: "msg_i", time: { created: 3 }, type: "idle", outcome: "failed", + error: { type: "ProviderError", message: "overloaded" }, }); + expect(result.hasError).toBe(true); + expect(result.warning).toContain("overloaded"); + }); + + it("does not treat succeeded idle markers as errors", () => { + const result = analyzeMessageResponse({ id: "msg_i", time: { created: 3 }, type: "idle", outcome: "succeeded" }); + expect(result.hasError).toBe(false); + }); + + it("does not treat error keywords in prose as tool failure", () => { + const result = analyzeMessageResponse(assistantMsg([{ type: "text", text: "Error: invalid key provided" }])); expect(result.hasError).toBe(false); expect(result.warning).toBeNull(); }); it("preserves explanations of unauthorized requests as content", () => { - const result = analyzeMessageResponse({ - parts: [{ type: "text", text: "Request unauthorized by provider" }], - }); + const result = analyzeMessageResponse(assistantMsg([{ type: "text", text: "Request unauthorized by provider" }])); expect(result.hasError).toBe(false); }); it("returns no warning for valid response with text", () => { - const result = analyzeMessageResponse({ - parts: [{ type: "text", text: "Hello, world!" }], - }); + const result = analyzeMessageResponse(assistantMsg([{ type: "text", text: "Hello, world!" }])); expect(result.isEmpty).toBe(false); expect(result.hasError).toBe(false); expect(result.warning).toBeNull(); }); - it("returns no warning for response with mixed parts including text", () => { - const result = analyzeMessageResponse({ - info: { id: "m1", role: "assistant" }, - parts: [ - { type: "text", text: "Let me check that." }, - { type: "tool-invocation", toolName: "readFile" }, - ], - }); + it("returns no warning for response with mixed content including text", () => { + const result = analyzeMessageResponse(assistantMsg([ + { type: "text", text: "Let me check that." }, + toolPart("read", completed({ path: "/foo" })), + ])); expect(result.isEmpty).toBe(false); expect(result.hasError).toBe(false); expect(result.warning).toBeNull(); }); - it("handles response with no parts field", () => { - const result = analyzeMessageResponse({ info: { id: "m1" } }); + it("handles response with no content field", () => { + const result = analyzeMessageResponse({ id: "m1", time: { created: 1 }, type: "assistant" }); expect(result.isEmpty).toBe(true); expect(result.warning).toContain("no text content"); }); + + it("treats user messages with text as non-empty", () => { + expect(analyzeMessageResponse(userMsg("m1", "hi")).isEmpty).toBe(false); + }); }); // ─── isProviderConfigured ───────────────────────────────────────────────── describe("isProviderConfigured", () => { - it("returns true for source=env", () => { - expect(isProviderConfigured({ id: "huggingface", source: "env" })).toBe(true); + const provider = (id: string, extra: Record = {}) => ({ + id, name: id, activation: "auto", package: "@ai-sdk/openai-compatible", ...extra, + }); + const credential = (integrationID: string, active: boolean) => ({ + id: `cred_${integrationID}`, integrationID, label: "default", active, value: { type: "key", key: "x" }, }); - it("returns true for source=config", () => { - expect(isProviderConfigured({ id: "google", source: "config" })).toBe(true); + it("returns true when provider settings carry a non-empty apiKey", () => { + expect(isProviderConfigured(provider("opencode", { settings: { apiKey: "public" } }))).toBe(true); }); - it("returns true for source=api", () => { - expect(isProviderConfigured({ id: "zai-coding", source: "api" })).toBe(true); + it("returns false when provider settings have an empty apiKey", () => { + expect(isProviderConfigured(provider("deepseek", { settings: { apiKey: "" } }))).toBe(false); }); - it("returns true for source=custom with non-empty apiKey", () => { - expect(isProviderConfigured({ id: "opencode", source: "custom", options: { apiKey: "public" } })).toBe(true); + it("returns false with no settings and no credentials", () => { + expect(isProviderConfigured(provider("groq"))).toBe(false); }); - it("returns true for anthropic with OAuth headers (heuristic)", () => { - expect(isProviderConfigured({ - id: "anthropic", - source: "custom", - options: { apiKey: "", headers: { "anthropic-beta": "test" } }, - })).toBe(true); + it("returns true when an active credential matches the integrationID", () => { + expect(isProviderConfigured( + provider("anthropic", { integrationID: "anthropic-oauth" }), + [credential("anthropic-oauth", true)], + )).toBe(true); }); - it("returns false for source=custom with empty options", () => { - expect(isProviderConfigured({ id: "groq", source: "custom", options: {} })).toBe(false); + it("returns true when an active credential matches the provider id", () => { + expect(isProviderConfigured(provider("google"), [credential("google", true)])).toBe(true); }); - it("returns false for source=custom with only empty apiKey", () => { - expect(isProviderConfigured({ id: "deepseek", source: "custom", options: { apiKey: "" } })).toBe(false); + it("returns false when the matching credential is inactive", () => { + expect(isProviderConfigured(provider("google"), [credential("google", false)])).toBe(false); }); - it("returns false when source is undefined", () => { - expect(isProviderConfigured({ id: "unknown" })).toBe(false); + it("returns false when credentials belong to another integration", () => { + expect(isProviderConfigured(provider("openai"), [credential("anthropic", true)])).toBe(false); }); - it("returns false for non-anthropic provider with extra options but no apiKey", () => { - // The anthropic heuristic should NOT apply to other providers - expect(isProviderConfigured({ - id: "openai", - source: "custom", - options: { apiKey: "", headers: { "some-header": "value" } }, - })).toBe(false); + it("returns false when the provider has no id", () => { + expect(isProviderConfigured({}, [credential("unknown", true)])).toBe(false); }); }); @@ -1040,19 +1048,21 @@ describe("Issue #13: cross-platform server paths", () => { }); -describe("OpenCode 1.18 response formats", () => { - const message = { info: { role: "assistant", id: "message" }, parts: [ - { type: "tool", tool: "bash", state: { status: "completed", input: { command: "pwd" }, output: "/project" } }, - ] }; - it("formats current tool parts and does not misdiagnose them as missing credentials", () => { +describe("OpenCode 2.x response formats", () => { + const message = assistantMsg([ + toolPart("bash", completed({ command: "pwd" }, "/project")), + ]); + it("formats completed tool parts and does not misdiagnose them as missing credentials", () => { expect(formatMessageResponse(message)).toContain("[Tool: bash] /project"); expect(formatMessageList([message])).toContain("bash: pwd"); expect(analyzeMessageResponse(message)).toEqual({ isEmpty: false, hasError: false, warning: null }); }); - it("reports errors nested in current tool state", () => { - const failed = { parts: [{ type: "tool", tool: "bash", state: { status: "error", error: "command failed" } }] }; - expect(formatMessageResponse(failed)).toContain("ERROR: command failed"); - expect(analyzeMessageResponse(failed).hasError).toBe(true); + it("reports structured errors nested in tool state", () => { + const failedMsg = assistantMsg([toolPart("bash", failed({ command: "x" }, "command failed"))]); + expect(formatMessageResponse(failedMsg)).toContain("ERROR: command failed"); + const analysis = analyzeMessageResponse(failedMsg); + expect(analysis.hasError).toBe(true); + expect(analysis.warning).toContain("command failed"); }); it("includes the current patch field when formatting session diffs", () => { expect(formatDiffResponse([{ file: "app.ts", patch: "@@ -1 +1 @@\n-old\n+new", additions: 1, deletions: 1 }])).toContain("+new"); diff --git a/tests/http-integration.test.ts b/tests/http-integration.test.ts index e0ab4bc..0e33f5b 100644 --- a/tests/http-integration.test.ts +++ b/tests/http-integration.test.ts @@ -5,27 +5,51 @@ import { registerWorkflowTools } from "../src/tools/workflow.js"; import { registerMessageTools } from "../src/tools/message.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -// Exercise the real SDK transport against HTTP, without credentials or inference. +// Exercise the real v2 SDK transport against an HTTP fixture that speaks the +// OpenCode 2.x `/api/*` wire format (`{ data }` envelopes), without inference. describe("SDK HTTP integration", () => { let server: Server; let client: OpenCodeClient; let requests: Array<{ method: string; url: URL; headers: Record; body: any }>; + const session = (id = "http-session") => ({ + id, projectID: "dc5d1411d7913906498c7d27548dba8263847dd9", cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1792670525577, updated: 1792670525577 }, title: "HTTP fixture", + location: { directory: "/remote/project" }, + }); + beforeEach(async () => { requests = []; server = createServer(async (req, res) => { let raw = ""; for await (const chunk of req) raw += chunk; const url = new URL(req.url!, "http://localhost"); - requests.push({ method: req.method!, url, headers: req.headers, body: raw ? JSON.parse(raw) : undefined }); - if (url.pathname.endsWith("/prompt_async")) { + const body = raw ? JSON.parse(raw) : undefined; + requests.push({ method: req.method!, url, headers: req.headers, body }); + const send = (payload: unknown) => { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(payload)); + }; + const path = url.pathname; + if (path.endsWith("/wait")) { + // Deliberately never answer the blocking wait endpoint. A regression + // that waits for inference during dispatch fails the test timeout. + } else if (path.endsWith("/model") || path.endsWith("/command") || path.endsWith("/agent")) { res.writeHead(204).end(); - } else if (url.pathname.endsWith("/message") && req.method === "POST") { - // Deliberately never answer the synchronous inference endpoint. - // A regression to /message will fail the workflow test's timeout. + } else if (path.endsWith("/prompt")) { + // v2 prompts return the queued user message immediately. + send({ data: { id: body?.id ?? "msg_1", time: { created: Date.now() }, text: body?.text, type: "user" } }); + } else if (path === "/api/session" && req.method === "POST") { + send({ data: session() }); + } else if (path === "/api/session/active") { + send({ data: {} }); + } else if (path.endsWith("/message")) { + send({ data: [], cursor: { previous: null, next: null } }); + } else if (path.startsWith("/api/session/")) { + send({ data: session(decodeURIComponent(path.split("/")[3])) }); } else { - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify(url.pathname === "/session" ? { id: "http-session" } : { ok: true })); + send({ data: { ok: true } }); } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -44,41 +68,70 @@ describe("SDK HTTP integration", () => { return result; } - it("serializes JSON bodies and preserves auth and remote directory scope", async () => { + it("serializes JSON bodies, unwraps envelopes and preserves auth and remote directory scope", async () => { const directory = "/remote/only/project"; - await client.post("/session", { title: "HTTP fixture" }, { directory }); - await client.patch("/session/http-session", { title: "Renamed" }, directory); - await client.put("/auth/fixture", { type: "api", key: "fixture" }); - await client.get("/session", { limit: "2" }, directory); - await client.delete("/session/http-session", undefined, directory); + const created = await client.post("/api/session", { title: "HTTP fixture" }, { directory }); + await client.patch("/api/session/http-session", { title: "Renamed" }, directory); + await client.put("/api/session/http-session/environment", { variables: { FIXTURE: "1" } }); + await client.get("/api/session/active", { limit: "2" }, directory); + await client.delete("/api/session/http-session", undefined, directory); + expect(created).toEqual(session()); expect(requests.map((r) => r.method)).toEqual(["POST", "PATCH", "PUT", "GET", "DELETE"]); expect(requests[0].body).toEqual({ title: "HTTP fixture" }); expect(requests[1].body).toEqual({ title: "Renamed" }); - expect(requests[2].body).toEqual({ type: "api", key: "fixture" }); + expect(requests[2].body).toEqual({ variables: { FIXTURE: "1" } }); expect(requests.every((r) => r.headers.authorization === "Basic " + Buffer.from("test:fixture-password").toString("base64"))).toBe(true); for (const index of [0, 1, 3, 4]) { const request = requests[index]; expect(request.url.searchParams.get("directory") ?? request.headers["x-opencode-directory"]).toBe(directory); } + expect(requests[2].headers["x-opencode-directory"]).toBeUndefined(); expect(requests[3].url.searchParams.get("limit")).toBe("2"); }); - it.each(["opencode_fire", "opencode_run"])("%s returns while synchronous inference is blocked", async (name) => { + it.each(["opencode_fire", "opencode_run"])("%s returns without waiting for inference", async (name) => { const result = await handlers(registerWorkflowTools).get(name)!({ prompt: "Background task", providerID: "fixture", modelID: "model", variant: "high", directory: "/remote/project", maxDurationSeconds: 0, }); expect(result.content[0].text).toContain("http-session"); - expect(requests[1].url.pathname).toBe("/session/http-session/prompt_async"); - expect(requests[1].body.variant).toBe("high"); - expect(requests[1].body.model).toEqual({ providerID: "fixture", modelID: "model" }); + expect(requests.some((r) => r.url.pathname.endsWith("/wait"))).toBe(false); + // v2: the session is created with its model and location... + expect(requests[0].method).toBe("POST"); + expect(requests[0].url.pathname).toBe("/api/session"); + expect(requests[0].body).toMatchObject({ + model: { id: "model", providerID: "fixture", variant: "high" }, + location: { directory: "/remote/project" }, + }); + // ...and the queued prompt carries only the pre-assigned message ID and + // text, with delivery "queue" so it runs as its own turn. + expect(requests[1].url.pathname).toBe("/api/session/http-session/prompt"); + expect(requests[1].body).toEqual({ id: expect.stringMatching(/^msg_/), text: "Background task", delivery: "queue" }); }, 1500); - it("sends slash-command model as provider/model, with a separate variant", async () => { + it("switches the model on an existing session before prompting", async () => { + await handlers(registerWorkflowTools).get("opencode_fire")!({ + prompt: "Follow up", sessionId: "ses_existing", providerID: "fixture", modelID: "model", variant: "high", + }); + expect(requests.map((r) => `${r.method} ${r.url.pathname}`)).toEqual([ + "POST /api/session/ses_existing/model", + "POST /api/session/ses_existing/prompt", + ]); + expect(requests[0].body).toEqual({ model: { id: "model", providerID: "fixture", variant: "high" } }); + expect(requests[1].body).not.toHaveProperty("model"); + }); + + it("sends slash commands as {name, text} to the v2 command endpoint", async () => { await handlers(registerMessageTools).get("opencode_command_execute")!({ - sessionId: "http-session", command: "test", providerID: "fixture", modelID: "model", variant: "high", + sessionId: "http-session", command: "review", arguments: "src/client.ts", }); - expect(requests[0].body.model).toBe("fixture/model"); - expect(requests[0].body.variant).toBe("high"); + expect(requests[0].method).toBe("POST"); + expect(requests[0].url.pathname).toBe("/api/session/http-session/command"); + expect(requests[0].body).toEqual({ name: "review", text: "src/client.ts" }); + }); + + it("sends an empty text for slash commands without arguments (text is required by the spec)", async () => { + await handlers(registerMessageTools).get("opencode_command_execute")!({ sessionId: "http-session", command: "init" }); + expect(requests[0].body).toEqual({ name: "init", text: "" }); }); }); diff --git a/tests/input.test.ts b/tests/input.test.ts index 0eee4dc..cf82ac0 100644 --- a/tests/input.test.ts +++ b/tests/input.test.ts @@ -6,11 +6,12 @@ import { McpServer, withStructuredText } from "../src/mcp-server.js"; import { OpenCodeClient } from "../src/client.js"; import { JobService } from "../src/jobs.js"; +// OpenCode 2.x shapes: Permission.Request and Form.Info (GET /api/permission/request, GET /api/form). const pending = [ - { id: "perm1", kind: "permission" as const, sessionID: "ses1", permission: "bash", patterns: ["npm test"] }, - { id: "q1", kind: "question" as const, sessionID: "ses1", questions: [ - { question: "Target?", options: [{ label: "web" }, { label: "mobile" }] }, - { question: "Checks?", multiple: true, options: [{ label: "unit" }, { label: "integration" }] }, + { id: "per1", kind: "permission" as const, sessionID: "ses1", action: "bash", resources: ["npm test"], message: "Run the test suite" }, + { id: "frm1", kind: "question" as const, sessionID: "ses1", title: "Release", fields: [ + { key: "target", type: "string", title: "Target?", options: [{ value: "web", label: "web" }, { value: "mobile", label: "mobile" }] }, + { key: "checks", type: "multiselect", title: "Checks?", options: [{ value: "unit", label: "unit" }, { value: "integration", label: "integration" }] }, ] }, ]; @@ -19,7 +20,7 @@ function setup() { const server = { tool: (name: string, _description: string, shape: z.ZodRawShape, ...rest: unknown[]) => { entries.set(name, { shape, handler: rest.at(-1) as Function }); } } as unknown as McpServer; - const client = { get: vi.fn(async () => []), post: vi.fn(async () => true) }; + const client = { get: vi.fn(async () => []), post: vi.fn(async () => undefined), delete: vi.fn(async () => undefined) }; const job = { jobId: "job_test", sessionId: "ses1", status: "input_required", inputs: pending }; const jobs = { get: vi.fn(async () => job), update: vi.fn(async () => ({ ...job, status: "running", inputs: [] })), list: vi.fn(async () => [job]), cancel: vi.fn(async () => ({ ...job, status: "cancelled" })) }; @@ -34,25 +35,41 @@ function setup() { describe("input form translation", () => { it("requires explicit permission decisions and never preselects approval", () => { const requests = inputRequests(pending); - const permission: any = requests["permission:perm1"]; + const permission: any = requests["permission:per1"]; expect(permission.method).toBe("elicitation/create"); + expect(permission.params.message).toContain("bash"); + expect(permission.params.message).toContain('["npm test"]'); + expect(permission.params.message).toContain("Run the test suite"); expect(permission.params.requestedSchema.properties.decision.enum).toEqual(["once", "always", "reject"]); expect(permission.params.requestedSchema.properties.decision).not.toHaveProperty("default"); expect(permission.params.requestedSchema.required).toEqual(["decision"]); }); + it("builds one string property per form field and flags multiselect fields", () => { + const form: any = inputRequests(pending)["question:frm1"]; + expect(form.params.message).toBe("Target?\nChecks?"); + expect(Object.keys(form.params.requestedSchema.properties)).toEqual(["answer_0", "answer_1"]); + expect(form.params.requestedSchema.properties.answer_0.description).toContain("Options: web, mobile."); + expect(form.params.requestedSchema.properties.answer_0.description).not.toContain("JSON array"); + expect(form.params.requestedSchema.properties.answer_1.description).toContain("JSON array"); + expect(form.params.requestedSchema.required).toEqual(["answer_0", "answer_1"]); + }); + it("decodes mixed form answers and maps explicit decline to rejection", () => { expect(decodeInputResponses(pending, { - "permission:perm1": { action: "decline" }, - "question:q1": { action: "accept", content: { answer_0: "web", answer_1: '["unit","integration"]' } }, + "permission:per1": { action: "decline" }, + "question:frm1": { action: "accept", content: { answer_0: "web", answer_1: '["unit","integration"]' } }, })).toEqual([ - { id: "perm1", kind: "permission", reply: "reject" }, - { id: "q1", kind: "question", answers: [["web"], ["unit", "integration"]] }, + { id: "per1", kind: "permission", reply: "reject" }, + { id: "frm1", kind: "question", answers: [["web"], ["unit", "integration"]] }, + ]); + expect(decodeInputResponses(pending, { "question:frm1": { action: "cancel" } })).toEqual([ + { id: "frm1", kind: "question", reject: true }, ]); }); it("validates missing decisions and mutually exclusive answer kinds", () => { - expect(() => decodeInputResponses(pending, { "permission:perm1": { action: "accept" } })).toThrow(); + expect(() => decodeInputResponses(pending, { "permission:per1": { action: "accept" } })).toThrow(); for (const value of [ { id: "x", kind: "permission" }, { id: "x", kind: "permission", reply: "once", answers: [] }, @@ -75,13 +92,39 @@ describe("question and job input tools", () => { expect(result.structuredContent).toMatchObject({ text: "Job is running", isError: false, status: "running" }); }); - it("filters questions by session and encodes reply request IDs", async () => { + it("lists pending forms from /api/form filtered by session", async () => { const { call, client } = setup(); - client.get.mockResolvedValue([{ id: "q1", sessionID: "ses1" }, { id: "q2", sessionID: "other" }] as never); + client.get.mockResolvedValue([{ id: "frm1", sessionID: "ses1" }, { id: "frm2", sessionID: "other" }] as never); const list = await call("opencode_question_list", { sessionId: "ses1", directory: "/remote/project" }); - expect(list.structuredContent.data).toEqual([{ id: "q1", sessionID: "ses1" }]); - await call("opencode_question_reply", { requestId: "q/1", answers: [["web"]], directory: "/remote/project" }); - expect(client.post).toHaveBeenLastCalledWith("/question/q%2F1/reply", { answers: [["web"]] }, { directory: "/remote/project" }); + expect(client.get).toHaveBeenLastCalledWith("/api/form", undefined, "/remote/project"); + expect(list.structuredContent.data).toEqual([{ id: "frm1", sessionID: "ses1" }]); + }); + + it("replies to a form at its session-scoped route with an encoded ID and keyed answer map", async () => { + const { call, client } = setup(); + await call("opencode_question_reply", { requestId: "frm/1", sessionId: "ses1", answer: { target: "web", checks: ["unit"] }, directory: "/remote/project" }); + expect(client.get).not.toHaveBeenCalled(); + expect(client.post).toHaveBeenLastCalledWith("/api/session/ses1/form/frm%2F1/reply", + { answer: { target: "web", checks: ["unit"] } }, { directory: "/remote/project" }); + }); + + it("auto-detects the owning session from the pending form list", async () => { + const { call, client } = setup(); + client.get.mockResolvedValue([{ id: "frm1", sessionID: "ses/9" }] as never); + await call("opencode_question_reply", { requestId: "frm1", answer: { target: "web" } }); + expect(client.get).toHaveBeenCalledWith("/api/form", undefined, undefined); + expect(client.post).toHaveBeenLastCalledWith("/api/session/ses%2F9/form/frm1/reply", { answer: { target: "web" } }, { directory: undefined }); + const missing = await call("opencode_question_reply", { requestId: "frm_unknown", answer: {} }); + expect(missing.isError).toBe(true); + expect(missing.content[0].text).toContain("Form not found"); + }); + + it("dismisses a form with DELETE on its session-scoped route", async () => { + const { call, client } = setup(); + client.get.mockResolvedValue([{ id: "frm1", sessionID: "ses1" }] as never); + await call("opencode_question_reject", { requestId: "frm1", directory: "/remote/project" }); + expect(client.delete).toHaveBeenLastCalledWith("/api/session/ses1/form/frm1", undefined, "/remote/project"); + expect(client.post).not.toHaveBeenCalled(); }); it("returns manual input data when the client cannot display forms", async () => { @@ -95,17 +138,18 @@ describe("question and job input tools", () => { const { call, jobs } = setup(); const result = await call("opencode_job_input", { jobId: "job_test" }, { mcpReq: { envelope: { "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } } } } }); expect(result.resultType).toBe("input_required"); - expect(result.inputRequests).toHaveProperty("permission:perm1"); + expect(result.inputRequests).toHaveProperty("permission:per1"); + expect(result.inputRequests).toHaveProperty("question:frm1"); expect(jobs.update).not.toHaveBeenCalled(); }); it("forwards explicit decisions and validates MRTR responses", async () => { const { call, jobs } = setup(); - const responses = [{ id: "perm1", kind: "permission", reply: "once" }]; + const responses = [{ id: "per1", kind: "permission", reply: "once" }]; await call("opencode_job_input", { jobId: "job_test", responses }); expect(jobs.update).toHaveBeenLastCalledWith("job_test", responses, expect.any(Object)); - await call("opencode_job_input", { jobId: "job_test" }, { mcpReq: { inputResponses: { "permission:perm1": { action: "accept", content: { decision: "reject" } } } } }); - expect(jobs.update).toHaveBeenLastCalledWith("job_test", [{ id: "perm1", kind: "permission", reply: "reject" }], expect.any(Object)); + await call("opencode_job_input", { jobId: "job_test" }, { mcpReq: { inputResponses: { "permission:per1": { action: "accept", content: { decision: "reject" } } } } }); + expect(jobs.update).toHaveBeenLastCalledWith("job_test", [{ id: "per1", kind: "permission", reply: "reject" }], expect.any(Object)); }); it("does not submit an empty update for stale MRTR response keys", async () => { diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index 7996266..02578df 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, readFile, readdir, rm, stat, mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir, hostname } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -11,106 +12,154 @@ let root: string; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "opencode-jobs-")); }); afterEach(async () => { vi.restoreAllMocks(); await rm(root, { recursive: true, force: true }); }); +const SID = "ses_fixture"; + +/** + * In-memory OpenCode 2.x backend. Shapes follow the 2.x OpenAPI spec after + * the client has unwrapped the `{ data }` envelope: flat messages tagged by + * `type` (user / assistant / idle), GET /api/session/active keyed by session, + * per-session GET /api/session/{id}/form and /api/session/{id}/permission lists. + */ function backend() { - let status = "idle"; + let running = false; let messages: any[] = []; - let questions: any[] = []; + let forms: any[] = []; let permissions: any[] = []; - const get = vi.fn(async (path: string) => { - if (path === "/session/status") return { session: { type: status } }; - if (path === "/session/session") return { id: "session", title: "Fixture", summary: { files: 2 } }; - if (path === "/session/session/message") return messages; - if (path === "/question") return questions; - if (path === "/permission") return permissions; + let clock = 1000; + let counter = 0; + const tick = () => ++clock; + const get = vi.fn(async (path: string, ..._args: any[]) => { + if (path === "/api/session/active") return running ? { [SID]: { type: "running" } } : {}; + if (path === `/api/session/${SID}`) return { id: SID, title: "Fixture", time: { created: 1, updated: 2 }, location: { directory: "/remote/project" } }; + // The real API returns newest-first. + if (path === `/api/session/${SID}/message`) return [...messages].reverse(); + if (path === `/api/session/${SID}/form`) return forms; + if (path === `/api/session/${SID}/permission`) return permissions; throw new Error(`Unexpected GET ${path}`); }); - const post = vi.fn(async (path: string, body?: any) => { - if (path === "/session") return { id: "session" }; - if (path.endsWith("/prompt_async")) { - messages.push({ info: { id: body.messageID, role: "user", time: { created: Date.now() } }, parts: [] }); - return undefined; + // The real client pages with `{data, cursor}` envelopes; this fixture serves + // the whole newest-first history as one page without a follow-up cursor. + const getWithCursor = vi.fn(async (path: string, ...args: any[]) => ({ data: await get(path, ...args) })); + const post = vi.fn(async (path: string, body?: any, ..._args: any[]) => { + if (path === "/api/session") return { id: SID, title: body?.title, time: { created: tick(), updated: clock } }; + if (path === `/api/session/${SID}/agent`) return undefined; + if (path === `/api/session/${SID}/model`) return undefined; + if (path === `/api/session/${SID}/prompt`) { + const id = body.id ?? `msg_user${++counter}`; + const created = tick(); + messages.push({ id, type: "user", text: body.text, time: { created } }); + return { id, sessionID: SID, type: "user", time: { created }, payload: { text: body.text }, delivery: body.delivery ?? "steer" }; + } + if (path === `/api/session/${SID}/form/frm_1/reply`) { forms = []; return undefined; } + if (path === `/api/session/${SID}/permission/per_1/reply`) { permissions = []; return undefined; } + if (path === `/api/session/${SID}/interrupt?resume=false`) { + messages.push({ id: `msg_idle${++counter}`, type: "idle", outcome: "interrupted", time: { created: tick() } }); + return { interrupted: true }; } - if (path === "/question/question-1/reply" || path === "/question/question-1/reject") { questions = []; return true; } - if (path === "/permission/permission-1/reply") { permissions = []; return true; } - if (path.endsWith("/abort")) return true; throw new Error(`Unexpected POST ${path}`); }); - const client = { getBaseUrl: () => "http://fixture:4096", get, post } as unknown as OpenCodeClient; + const del = vi.fn(async (path: string, ..._args: any[]) => { + if (path === `/api/session/${SID}/form/frm_1`) { forms = []; return undefined; } + throw new Error(`Unexpected DELETE ${path}`); + }); + const client = { getBaseUrl: () => "http://fixture:4096", get, getWithCursor, post, delete: del } as unknown as OpenCodeClient; return { - client, get, post, - setStatus: (next: string) => { status = next; }, + client, get, getWithCursor, post, delete: del, + setRunning: (next: boolean) => { running = next; }, setMessages: (next: any[]) => { messages = next; }, - setQuestions: (next: any[]) => { questions = next; }, + setForms: (next: any[]) => { forms = next; }, setPermissions: (next: any[]) => { permissions = next; }, - complete(parentID: string, overrides: Record = {}) { - messages.push({ info: { id: "msg_assistant", parentID, role: "assistant", time: { created: Date.now() + 1, completed: Date.now() + 2 }, ...overrides }, parts: [{ type: "text", text: "Fixed the error successfully." }] }); + addUser(id: string) { messages.push({ id, type: "user", text: "fixture", time: { created: tick() } }); }, + assistant(overrides: Record = {}) { + messages.push({ + id: `msg_assistant${++counter}`, type: "assistant", agent: "build", model: { id: "model", providerID: "provider" }, + time: { created: tick(), completed: tick() }, content: [{ type: "text", text: "Fixed the error successfully." }], + finish: "stop", ...overrides, + }); + }, + idle(outcome: "succeeded" | "failed" | "interrupted" = "succeeded", extra: Record = {}) { + messages.push({ id: `msg_idle${++counter}`, type: "idle", outcome, time: { created: tick() }, ...extra }); + }, + /** A finished turn: final assistant message followed by the idle marker. */ + complete(overrides: Record = {}) { + this.assistant(overrides); + this.idle(); }, }; } async function namespace() { return join(root, (await readdir(root))[0]); } +const user = (id: string, created: number) => ({ id, type: "user", text: "fixture", time: { created } }); +const assistant = (id: string, created: number, text = "Done", extra: Record = {}) => ({ + id, type: "assistant", agent: "build", model: { id: "model", providerID: "provider" }, + time: { created, completed: created }, content: [{ type: "text", text }], finish: "stop", ...extra, +}); +const idle = (id: string, created: number, outcome = "succeeded", extra: Record = {}) => ({ id, type: "idle", outcome, time: { created }, ...extra }); -describe("OpenCode structured-output observation", () => { - const formatError = () => new OpenCodeError("Cannot encode format", 400, "GET", "/session/session/message", - JSON.stringify({ name: "BadRequest", data: { kind: "Body", message: 'Expected OutputFormatJsonSchema, got {"type":"json_schema"}\n at [0]["info"]["format"]' } })); - const result = (parentID = "target", structured: unknown = { sum: 42 }) => ({ - info: { id: "assistant", parentID, role: "assistant", structured, finish: "tool-calls", time: { created: 2, completed: 3 } }, - parts: [{ type: "tool", tool: "StructuredOutput", state: { status: "completed", output: "Structured output captured successfully." } }], - }); - function brokenHistory(latest: unknown[] | Error) { - const fixture = backend(); - const get = fixture.get.getMockImplementation()!; - fixture.get.mockImplementation(async (path: string, ...args: any[]) => { - if (path.endsWith("/message")) { - if (args[0]?.limit !== "1") throw formatError(); - if (latest instanceof Error) throw latest; - return latest; - } - return get(path); - }); - return fixture; - } - - it("recognizes a final StructuredOutput tool call with complete history", async () => { +describe("OpenCode 2.x history observation", () => { + it("normalizes newest-first history and returns the final assistant of the turn", async () => { const fixture = backend(); - fixture.setMessages([{ info: { role: "user", id: "target", time: { created: 1 } } }, result()]); - expect(await observeSession(fixture.client, "session", undefined, "target")).toMatchObject({ status: "completed", result: { sum: 42 } }); - }); - - it("recovers the correlated result through a bounded read without resubmitting", async () => { - const fixture = brokenHistory([result()]); - const observed = await observeSession(fixture.client, "session", "/remote/project", "target"); - expect(observed).toMatchObject({ status: "completed", result: { sum: 42 } }); - expect(JSON.parse(observed.text!)).toEqual({ sum: 42 }); - expect(fixture.get).toHaveBeenCalledWith("/session/session/message", { limit: "1" }, "/remote/project", expect.objectContaining({ signal: expect.any(AbortSignal) })); + fixture.setMessages([ + user("msg_target", 1), + assistant("msg_step", 2, "", { finish: "tool-calls", content: [{ type: "tool", id: "call_1", name: "shell", state: { status: "completed", input: {}, content: [{ type: "text", text: "ok" }] } }] }), + assistant("msg_final", 3, "Final answer", { snapshot: { files: ["src/a.ts"] } }), + idle("msg_idle", 4), + ]); + const observed = await observeSession(fixture.client, SID, "/remote/project", "msg_target"); + expect(observed).toMatchObject({ status: "completed", messageId: "msg_target", result: { id: "msg_final" } }); + expect(observed.text).toBe("Final answer"); + expect(fixture.get).toHaveBeenCalledWith(`/api/session/${SID}/message`, { limit: "100" }, "/remote/project", expect.objectContaining({ signal: expect.any(AbortSignal) })); expect(fixture.post).not.toHaveBeenCalled(); }); - it.each([[], [result("another-turn")], formatError()].map(latest => ({ latest })))("keeps incomplete or unrelated history unknown ($latest)", async ({ latest }) => { - const fixture = brokenHistory(latest); - fixture.setQuestions([{ id: "unrelated", sessionID: "session" }]); - const observed = await observeSession(fixture.client, "session", undefined, "target"); - expect(observed).toMatchObject({ status: "unknown", inputs: [], error: { name: "MessageHistoryUnavailable" } }); - expect(fixture.get).not.toHaveBeenCalledWith("/question", expect.anything(), expect.anything(), expect.anything()); + it("does not treat an unrelated older idle marker as the requested turn's completion", async () => { + const fixture = backend(); + fixture.setMessages([user("msg_old", 1), assistant("msg_old_answer", 2), idle("msg_old_idle", 3), user("msg_target", 4)]); + const observed = await observeSession(fixture.client, SID, undefined, "msg_target"); + expect(observed.status).toBe("accepted"); + expect(observed.result).toBeUndefined(); + expect(observed.text).toBeUndefined(); }); - it("does not claim completion while OpenCode is still busy", async () => { - const fixture = brokenHistory([result()]); - fixture.setStatus("busy"); - expect((await observeSession(fixture.client, "session", undefined, "target")).status).toBe("unknown"); + it("preserves unrelated errors from the history read", async () => { + const denied = new OpenCodeError("Forbidden", 403, "GET", `/api/session/${SID}/message`, "{}"); + const fixture = backend(); + const original = fixture.get.getMockImplementation()!; + fixture.get.mockImplementation(async (path: string, ...args: any[]) => { + if (path.endsWith("/message")) throw denied; + return original(path, ...args); + }); + await expect(observeSession(fixture.client, SID, undefined, "msg_target")).rejects.toBe(denied); + fixture.get.mockRejectedValue(denied); + await expect(observeSession(fixture.client, SID, undefined, "msg_target")).rejects.toBe(denied); }); - it("does not infer a target from partial history", async () => { - const fixture = brokenHistory([result()]); - await expect(observeSession(fixture.client, "session")).rejects.toThrow("Cannot encode format"); - expect(fixture.get.mock.calls.filter(([path]) => path.endsWith("/message"))).toHaveLength(1); + it("rejects a non-list history instead of guessing a turn outcome", async () => { + const fixture = backend(); + const original = fixture.get.getMockImplementation()!; + fixture.get.mockImplementation(async (path: string, ...args: any[]) => path.endsWith("/message") ? { unexpected: true } : original(path, ...args)); + await expect(observeSession(fixture.client, SID, undefined, "msg_target")).rejects.toThrow("Invalid session message response"); }); - it("preserves unrelated errors from either read", async () => { - const denied = new OpenCodeError("Forbidden", 403, "GET", "/session/session/message", "{}"); - const fixture = brokenHistory(denied); - await expect(observeSession(fixture.client, "session", undefined, "target")).rejects.toBe(denied); - fixture.get.mockRejectedValue(denied); - await expect(observeSession(fixture.client, "session", undefined, "target")).rejects.toBe(denied); + // Paginated v2 list endpoints answer `{ data: [...], cursor: {...} }` + // (verified against a live 2.1.2 server); the real client must unwrap it. + it("observes a completed turn through the real client and paginated envelope", async () => { + const server = createServer((request, response) => { + const url = new URL(request.url!, "http://127.0.0.1"); + const body = url.pathname === "/api/session/active" ? { data: {} } + : url.pathname === `/api/session/${SID}` ? { data: { id: SID, title: "Fixture", time: { created: 1, updated: 2 } } } + : url.pathname === `/api/session/${SID}/message` + ? { data: [idle("msg_idle", 3), assistant("msg_answer", 2, "Done"), user("msg_target", 1)], cursor: { previous: "opaque" } } + : { data: [], location: { directory: "/remote/project" } }; + response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address() as { port: number }; + const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}` }); + expect(await observeSession(client, SID, undefined, "msg_target")).toMatchObject({ status: "completed", text: "Done" }); + } finally { + await new Promise((resolve) => server.close(resolve)); + } }); }); @@ -118,15 +167,15 @@ it("persists ownership before dispatch, with safe file modes and no prompt", asy const fixture = backend(); const original = fixture.post.getMockImplementation()!; fixture.post.mockImplementation(async (path, body) => { - if (path === "/session") { + if (path === "/api/session") { const files = await readdir(await namespace()); expect(files.some((name) => name.endsWith(".json"))).toBe(true); } - if (path.endsWith("/prompt_async")) { + if (path.endsWith("/prompt")) { const files = await readdir(await namespace()); const saved = JSON.parse(await readFile(join(await namespace(), files.find((name) => name.endsWith(".json"))!), "utf8")); - expect(saved.sessionId).toBe("session"); - expect(saved.messageId).toBe(body.messageID); + expect(saved.sessionId).toBe(SID); + expect(saved.messageId).toBe(body.id); expect(saved.status).toBe("unknown"); expect(JSON.stringify(saved)).not.toContain("private submitted prompt"); } @@ -145,15 +194,55 @@ it("persists ownership before dispatch, with safe file modes and no prompt", asy } }); +describe("v2 dispatch requests", () => { + it("creates the session with model and location in the body, then submits a plain-text prompt with the pre-assigned ID", async () => { + const fixture = backend(); + const job = await new JobService(fixture.client, { storeRoot: null }).start({ + prompt: "Task", title: "Fixture task", directory: "/remote/project", agent: "build", + model: { providerID: "provider", modelID: "model" }, variant: "fast", + }); + expect(fixture.post.mock.calls[0]).toEqual(["/api/session", { + title: "Fixture task", agent: "build", model: { id: "model", providerID: "provider", variant: "fast" }, location: { directory: "/remote/project" }, + }, expect.objectContaining({ directory: "/remote/project" })]); + expect(fixture.post.mock.calls[1][0]).toBe(`/api/session/${SID}/prompt`); + // 2.x prompts take no agent: it rides on session creation, and the prompt + // is queued as its own turn instead of steering a running one. + expect(fixture.post.mock.calls[1][1]).toEqual({ id: job.messageId, text: "Task", delivery: "queue" }); + expect(fixture.post).toHaveBeenCalledTimes(2); + }); + + it("switches an existing session's agent and model instead of sending them with the prompt", async () => { + const fixture = backend(); + await new JobService(fixture.client, { storeRoot: null }).start({ + prompt: "Task", sessionId: SID, agent: "plan", model: { providerID: "provider", modelID: "model" }, + }); + expect(fixture.post.mock.calls.map(([path]) => path)).toEqual([ + `/api/session/${SID}/agent`, `/api/session/${SID}/model`, `/api/session/${SID}/prompt`, + ]); + expect(fixture.post.mock.calls[0][1]).toEqual({ agent: "plan" }); + expect(fixture.post.mock.calls[1][1]).toEqual({ model: { id: "model", providerID: "provider" } }); + expect(fixture.post.mock.calls[2][1]).toEqual({ id: expect.any(String), text: "Task", delivery: "queue" }); + }); + + it("switches an existing session's model instead of sending a model with the prompt", async () => { + const fixture = backend(); + await new JobService(fixture.client, { storeRoot: null }).start({ prompt: "Task", sessionId: SID, model: { providerID: "provider", modelID: "model" } }); + expect(fixture.post.mock.calls.map(([path]) => path)).toEqual([`/api/session/${SID}/model`, `/api/session/${SID}/prompt`]); + expect(fixture.post.mock.calls[0][1]).toEqual({ model: { id: "model", providerID: "provider" } }); + expect(fixture.post.mock.calls[1][1]).not.toHaveProperty("model"); + }); +}); + it("reconnects a new service instance to a persisted job and retains terminal output", async () => { const fixture = backend(); const first = new JobService(fixture.client, { storeRoot: root, scope: "user-one" }); - const created = await first.start({ prompt: "fixture", format: { type: "json_schema", schema: { type: "object" } } }); - fixture.complete(created.messageId!, { structured: { files: 2 } }); + const created = await first.start({ prompt: "fixture" }); + fixture.complete({ snapshot: { files: ["a.ts", "b.ts"] } }); const second = new JobService(fixture.client, { storeRoot: root, scope: "user-one" }); const complete = await second.get(created.jobId!); expect(complete.status).toBe("completed"); - expect(complete.result).toEqual({ files: 2 }); + expect(complete.result).toMatchObject({ type: "assistant", snapshot: { files: ["a.ts", "b.ts"] } }); + expect(complete.text).toContain("Fixed the error"); fixture.get.mockRejectedValue(new Error("server offline")); expect((await first.get(created.jobId!)).status).toBe("completed"); expect(await second.list()).toHaveLength(1); @@ -163,21 +252,24 @@ it("reconnects a new service instance to a persisted job and retains terminal ou it("retains all known IDs when accepted submission loses its response", async () => { const fixture = backend(); fixture.post.mockImplementation(async (path) => { - if (path === "/session") return { id: "session" }; + if (path === "/api/session") return { id: SID }; throw new OpenCodeSubmissionError("POST", path, new Error("socket hang up")); }); const service = new JobService(fixture.client, { storeRoot: root }); const job = await service.start({ prompt: "fixture" }); - expect(job).toMatchObject({ status: "unknown", sessionId: "session", jobId: expect.any(String), messageId: expect.any(String) }); - expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/prompt_async"))).toHaveLength(1); + expect(job).toMatchObject({ status: "unknown", sessionId: SID, jobId: expect.any(String), messageId: expect.any(String) }); + expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/prompt"))).toHaveLength(1); expect((await service.get(job.jobId!)).status).toBe("unknown"); - fixture.complete(job.messageId!); + // The lost prompt was in fact accepted: OpenCode recorded it under our ID. + fixture.addUser(job.messageId!); + fixture.complete(); expect((await service.get(job.jobId!)).status).toBe("completed"); + expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/prompt"))).toHaveLength(1); }); it("retains a job handle even if session creation outcome is unknown", async () => { const fixture = backend(); - fixture.post.mockRejectedValue(new OpenCodeSubmissionError("POST", "/session", new Error("socket hang up"))); + fixture.post.mockRejectedValue(new OpenCodeSubmissionError("POST", "/api/session", new Error("socket hang up"))); const service = new JobService(fixture.client, { storeRoot: null }); const job = await service.start({ prompt: "fixture" }); expect(job.status).toBe("unknown"); @@ -189,76 +281,155 @@ it("retains a job handle even if session creation outcome is unknown", async () it("marks explicit submission rejection failed without resubmitting", async () => { const fixture = backend(); - fixture.post.mockRejectedValue(new OpenCodeError("forbidden", 403, "POST", "/session", "fixture")); - const job = await new JobService(fixture.client, { storeRoot: null }).start({ prompt: "fixture", sessionId: "session" }); + fixture.post.mockRejectedValue(new OpenCodeError("forbidden", 403, "POST", `/api/session/${SID}/prompt`, "fixture")); + const job = await new JobService(fixture.client, { storeRoot: null }).start({ prompt: "fixture", sessionId: SID }); expect(job.status).toBe("failed"); expect(job.error).toMatchObject({ status: 403 }); expect(fixture.post).toHaveBeenCalledTimes(1); }); describe("turn-specific observations", () => { - it("does not mistake idle/no assistant, latest user, or old assistant for completion", async () => { + it("does not mistake idle/no assistant, latest user, or an old turn for completion", async () => { + const fixture = backend(); + fixture.setMessages([assistant("msg_old_assistant", 1), idle("msg_old_idle", 2), user("msg_target", 3)]); + expect((await observeSession(fixture.client, SID, undefined, "msg_target")).status).toBe("accepted"); + expect((await observeSession(fixture.client, SID)).status).toBe("accepted"); + fixture.setMessages([]); + expect((await observeSession(fixture.client, SID)).status).toBe("accepted"); + }); + + it("cuts the turn at its idle marker instead of a steered follow-up prompt", async () => { const fixture = backend(); - fixture.complete("old-parent"); fixture.setMessages([ - { info: { id: "old-assistant", role: "assistant", parentID: "old-parent", time: { created: 1, completed: 2 } }, parts: [] }, - { info: { id: "target-user", role: "user", time: { created: 3 } }, parts: [] }, + user("msg_target", 1), + assistant("msg_first", 2, "First answer"), + // A steered prompt joins the running turn before it goes idle; the turn + // ends at the idle marker, not at that newer user message. + user("msg_steer", 3), + assistant("msg_merged", 4, "Merged answer"), + idle("msg_idle", 5), ]); - expect((await observeSession(fixture.client, "session", undefined, "target-user")).status).toBe("accepted"); - expect((await observeSession(fixture.client, "session")).status).toBe("accepted"); - fixture.setMessages([]); - expect((await observeSession(fixture.client, "session")).status).toBe("accepted"); + const observed = await observeSession(fixture.client, SID, undefined, "msg_target"); + expect(observed).toMatchObject({ status: "completed", messageId: "msg_target", result: { id: "msg_merged" } }); + expect(observed.text).toContain("Merged answer"); }); - it.each(["ProviderAuthError", "StructuredOutputError", "MessageAbortedError"])("classifies structured %s while idle", async (name) => { + it("pages through long histories with the cursor until the observed turn is complete", async () => { const fixture = backend(); - fixture.complete("user", { error: { name, data: { message: "fixture typed failure" } } }); - const result = await observeSession(fixture.client, "session", "/remote/project", "user"); - expect(result.status).toBe(name === "MessageAbortedError" ? "cancelled" : "failed"); - expect(result.error).toMatchObject({ name }); + const messages = [ + user("msg_target", 1), + assistant("msg_first", 2, "First answer"), + // More than 100 newer messages push the turn past the default page. + ...Array.from({ length: 120 }, (_, index) => assistant(`msg_noise${index}`, 3 + index)), + idle("msg_idle", 200), + ]; + fixture.setMessages(messages); + const newest = [...messages].reverse(); + const pageSize = 50; + const pages: Record = {}; + for (let index = 0; index * pageSize < newest.length; index++) { + pages[String(index + 1)] = newest.slice(index * pageSize, (index + 1) * pageSize); + } + const seen: string[] = []; + const passthrough = fixture.getWithCursor.getMockImplementation()!; + fixture.getWithCursor.mockImplementation(async (path: string, query?: Record) => { + if (path !== `/api/session/${SID}/message`) return passthrough(path, query); + const index = query?.cursor ? Number(query.cursor) : 1; + seen.push(String(index)); + return { data: pages[String(index)] ?? [], cursor: { next: pages[String(index + 1)] ? String(index + 1) : null } }; + }); + const observed = await observeSession(fixture.client, SID, undefined, "msg_target"); + expect(observed.status).toBe("completed"); + // The final assistant message of the turn is the newest noise message. + expect(observed.result).toMatchObject({ id: "msg_noise119" }); + expect(observed.text).toContain("Done"); + // Pages 1 and 2 hold only newer messages; page 3 reaches the target. + expect(seen).toEqual(["1", "2", "3"]); }); - it("requires completion metadata and ignores successful prose containing error", async () => { + it.each([ + ["failed", "failed", { error: { type: "ProviderAuthError", message: "fixture typed failure" } }], + ["interrupted", "cancelled", {}], + ] as const)("classifies an idle marker with outcome %s as %s", async (outcome, status, extra) => { const fixture = backend(); - fixture.complete("user", { time: { created: 1 } }); - expect((await observeSession(fixture.client, "session", undefined, "user")).status).toBe("accepted"); - fixture.setMessages([]); - fixture.complete("user"); - const result = await observeSession(fixture.client, "session", undefined, "user"); + fixture.addUser("msg_target"); + fixture.assistant({ content: [], finish: "error" }); + fixture.idle(outcome, extra); + const result = await observeSession(fixture.client, SID, "/remote/project", "msg_target"); + expect(result.status).toBe(status); + if (status === "failed") expect(result.error).toMatchObject({ type: "ProviderAuthError" }); + }); + + it("supplies a generic error for a failed turn without structured details", async () => { + const fixture = backend(); + fixture.addUser("msg_target"); + fixture.idle("failed"); + expect(await observeSession(fixture.client, SID, undefined, "msg_target")).toMatchObject({ status: "failed", error: { name: "SessionError" } }); + }); + + it("requires the idle marker and ignores successful prose containing error", async () => { + const fixture = backend(); + fixture.addUser("msg_target"); + fixture.assistant(); + // A completed assistant message alone does not end the turn. + expect((await observeSession(fixture.client, SID, undefined, "msg_target")).status).toBe("running"); + fixture.idle(); + const result = await observeSession(fixture.client, SID, undefined, "msg_target"); expect(result.status).toBe("completed"); + expect(result.error).toBeUndefined(); expect(result.text).toContain("Fixed the error"); expect(fixture.get.mock.calls.some(([path]) => path.includes("/diff"))).toBe(false); }); it("does not finish a tool-call intermediate step", async () => { const fixture = backend(); - fixture.complete("user", { finish: "tool-calls" }); - expect((await observeSession(fixture.client, "session", undefined, "user")).status).toBe("accepted"); + fixture.setRunning(true); + fixture.addUser("msg_target"); + fixture.assistant({ finish: "tool-calls", content: [{ type: "tool", id: "call_1", name: "shell", state: { status: "running", input: {}, metadata: {} } }] }); + expect((await observeSession(fixture.client, SID, undefined, "msg_target")).status).toBe("running"); + }); + + it("reports running while the session is active, before any assistant output", async () => { + const fixture = backend(); + fixture.setRunning(true); + fixture.addUser("msg_target"); + expect((await observeSession(fixture.client, SID, undefined, "msg_target")).status).toBe("running"); }); it("exposes pending input only for the requested session", async () => { const fixture = backend(); - fixture.setStatus("busy"); - fixture.setQuestions([{ id: "question-1", sessionID: "session", questions: [{ question: "Select a color", options: [] }] }, { id: "other", sessionID: "another", questions: [] }]); - fixture.setPermissions([{ id: "permission-1", sessionID: "session", permission: "edit", patterns: ["src/**"] }]); - const result = await observeSession(fixture.client, "session"); + fixture.setRunning(true); + fixture.addUser("msg_target"); + fixture.setForms([ + { id: "frm_1", sessionID: SID, title: "Color", fields: [{ key: "color", type: "string", title: "Select a color", options: [] }] }, + { id: "frm_other", sessionID: "ses_another", fields: [] }, + ]); + fixture.setPermissions([ + { id: "per_1", sessionID: SID, action: "edit", resources: ["src/**"] }, + { id: "per_other", sessionID: "ses_another", action: "edit", resources: ["*"] }, + ]); + const result = await observeSession(fixture.client, SID, "/remote/project"); expect(result.status).toBe("input_required"); - expect(result.inputs?.map((input) => [input.kind, input.id])).toEqual([["question", "question-1"], ["permission", "permission-1"]]); + expect(result.inputs?.map((input) => [input.kind, input.id])).toEqual([["question", "frm_1"], ["permission", "per_1"]]); + expect(result.inputs?.[0]).toMatchObject({ fields: [{ key: "color" }] }); + // Pending inputs are read from the per-session routes. + expect(fixture.get).toHaveBeenCalledWith(`/api/session/${SID}/form`, undefined, "/remote/project", expect.anything()); + expect(fixture.get).toHaveBeenCalledWith(`/api/session/${SID}/permission`, undefined, "/remote/project", expect.anything()); }); it("accepts unsupported pending endpoints only for HTTP 404", async () => { const fixture = backend(); const original = fixture.get.getMockImplementation()!; - fixture.get.mockImplementation(async (path) => { - if (path === "/question" || path === "/permission") throw new OpenCodeError("unsupported", 404, "GET", path, ""); - return original(path); + fixture.get.mockImplementation(async (path, ...args) => { + if (path === `/api/session/${SID}/form` || path === `/api/session/${SID}/permission`) throw new OpenCodeError("unsupported", 404, "GET", path, ""); + return original(path, ...args); }); - expect((await observeSession(fixture.client, "session")).status).toBe("accepted"); - fixture.get.mockImplementation(async (path) => { - if (path === "/question") throw new OpenCodeError("unauthorized", 401, "GET", path, ""); - return original(path); + expect((await observeSession(fixture.client, SID)).status).toBe("accepted"); + fixture.get.mockImplementation(async (path, ...args) => { + if (path === `/api/session/${SID}/form`) throw new OpenCodeError("unauthorized", 401, "GET", path, ""); + return original(path, ...args); }); - await expect(observeSession(fixture.client, "session")).rejects.toMatchObject({ status: 401 }); + await expect(observeSession(fixture.client, SID)).rejects.toMatchObject({ status: 401 }); }); }); @@ -267,55 +438,101 @@ it("deduplicates permission replies across two service instances", async () => { const first = new JobService(fixture.client, { storeRoot: root }); const second = new JobService(fixture.client, { storeRoot: root }); const job = await first.start({ prompt: "fixture" }); - fixture.setPermissions([{ id: "permission-1", sessionID: "session", permission: "edit", patterns: ["src/**"] }]); - const responses = [{ id: "permission-1", kind: "permission" as const, reply: "once" as const }]; + fixture.setPermissions([{ id: "per_1", sessionID: SID, action: "edit", resources: ["src/**"] }]); + const responses = [{ id: "per_1", kind: "permission" as const, reply: "once" as const }]; await Promise.all([first.update(job.jobId!, responses), second.update(job.jobId!, responses)]); - expect(fixture.post.mock.calls.filter(([path]) => path === "/permission/permission-1/reply")).toHaveLength(1); + const replies = fixture.post.mock.calls.filter(([path]) => path === `/api/session/${SID}/permission/per_1/reply`); + expect(replies).toHaveLength(1); + expect(replies[0][1]).toEqual({ decision: "once" }); await expect(first.update(job.jobId!, [{ ...responses[0], reply: "always" }])).rejects.toThrow("different submitted response"); }); -it("does not replay an ambiguous question reply after reconnection", async () => { +it("does not replay an ambiguous form reply after reconnection", async () => { const fixture = backend(); const first = new JobService(fixture.client, { storeRoot: root }); const job = await first.start({ prompt: "fixture" }); - fixture.setQuestions([{ id: "question-1", sessionID: "session", questions: [] }]); - fixture.post.mockRejectedValue(new OpenCodeSubmissionError("POST", "/question/question-1/reply", new Error("socket hang up"))); - const responses = [{ id: "question-1", kind: "question" as const, answers: [["Blue"]] }]; + fixture.setForms([{ id: "frm_1", sessionID: SID, fields: [{ key: "color", type: "string" }] }]); + fixture.post.mockRejectedValue(new OpenCodeSubmissionError("POST", `/api/session/${SID}/form/frm_1/reply`, new Error("socket hang up"))); + const responses = [{ id: "frm_1", kind: "question" as const, answers: [["Blue"]] }]; await expect(first.update(job.jobId!, responses)).rejects.toBeInstanceOf(OpenCodeSubmissionError); const second = new JobService(fixture.client, { storeRoot: root }); await expect(second.update(job.jobId!, responses)).rejects.toThrow("cannot be safely resent"); - expect(fixture.post.mock.calls.filter(([path]) => path === "/question/question-1/reply")).toHaveLength(1); + expect(fixture.post.mock.calls.filter(([path]) => path === `/api/session/${SID}/form/frm_1/reply`)).toHaveLength(1); }); -it("does not reply to another session's pending question or auto-approve invalid input", async () => { +it("maps answer labels onto the form's typed answer map", async () => { const fixture = backend(); const service = new JobService(fixture.client, { storeRoot: null }); const job = await service.start({ prompt: "fixture" }); - fixture.setQuestions([{ id: "question-1", sessionID: "another-session", questions: [] }]); - await expect(service.update(job.jobId!, [{ id: "question-1", kind: "question", answers: [["Yes"]] }])).rejects.toThrow("does not belong"); + fixture.setForms([{ id: "frm_1", sessionID: SID, fields: [ + { key: "note", type: "string" }, + { key: "tags", type: "multiselect", options: [{ value: "a", label: "Alpha" }, { value: "b", label: "Beta" }] }, + { key: "confirm", type: "boolean" }, + { key: "count", type: "integer" }, + ] }]); + const result = await service.update(job.jobId!, [{ id: "frm_1", kind: "question", answers: [["free text"], ["Alpha", "b"], ["true"], ["3"]] }]); + const reply = fixture.post.mock.calls.find(([path]) => path === `/api/session/${SID}/form/frm_1/reply`)!; + expect(reply[1]).toEqual({ answer: { note: "free text", tags: ["a", "b"], confirm: true, count: 3 } }); + expect(result.inputs).toEqual([]); +}); + +it("resolves option labels to values for single-choice string fields", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: null }); + const job = await service.start({ prompt: "fixture" }); + fixture.setForms([{ id: "frm_1", sessionID: SID, fields: [{ key: "color", type: "string", options: [{ value: "blue", label: "Blue" }] }] }]); + await service.update(job.jobId!, [{ id: "frm_1", kind: "question", answers: [["Blue"]] }]); + const reply = fixture.post.mock.calls.find(([path]) => path === `/api/session/${SID}/form/frm_1/reply`)!; + expect(reply[1]).toEqual({ answer: { color: "blue" } }); +}); + +it("rejects a form by deleting it, exactly once", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: null }); + const job = await service.start({ prompt: "fixture" }); + fixture.setForms([{ id: "frm_1", sessionID: SID, fields: [] }]); + const responses = [{ id: "frm_1", kind: "question" as const, reject: true }]; + await service.update(job.jobId!, responses); + await service.update(job.jobId!, responses); + expect(fixture.delete).toHaveBeenCalledTimes(1); + expect(fixture.delete).toHaveBeenCalledWith(`/api/session/${SID}/form/frm_1`, undefined, undefined, expect.anything()); + expect(fixture.post.mock.calls.some(([path]) => path.includes("/form/"))).toBe(false); +}); + +it("does not reply to another session's pending form or auto-approve invalid input", async () => { + const fixture = backend(); + const service = new JobService(fixture.client, { storeRoot: null }); + const job = await service.start({ prompt: "fixture" }); + fixture.setForms([{ id: "frm_1", sessionID: "ses_another", fields: [] }]); + await expect(service.update(job.jobId!, [{ id: "frm_1", kind: "question", answers: [["Yes"]] }])).rejects.toThrow("does not belong"); + fixture.setPermissions([{ id: "per_1", sessionID: SID, action: "edit", resources: ["*"] }]); + await expect(service.update(job.jobId!, [{ id: "per_1", kind: "permission" } as any])).rejects.toThrow("once, always, or reject"); expect(fixture.post).toHaveBeenCalledTimes(2); }); -it("explicitly aborts owned work once and never aborts on observer cancellation", async () => { +it("explicitly interrupts owned work once and never interrupts on observer cancellation", async () => { const fixture = backend(); const service = new JobService(fixture.client, { storeRoot: root }); const job = await service.start({ prompt: "fixture" }); const controller = new AbortController(); controller.abort(); await expect(service.get(job.jobId!, { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" }); - expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/abort"))).toHaveLength(0); + expect(fixture.post.mock.calls.filter(([path]) => path.includes("/interrupt"))).toHaveLength(0); expect((await service.cancel(job.jobId!)).status).toBe("cancelled"); expect((await service.cancel(job.jobId!)).status).toBe("cancelled"); - expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/abort"))).toHaveLength(1); + // resume=false ends the turn instead of continuing with queued inbox work. + expect(fixture.post.mock.calls.filter(([path]) => path.includes("/interrupt")).map(([path]) => path)).toEqual([`/api/session/${SID}/interrupt?resume=false`]); + expect((await service.get(job.jobId!)).status).toBe("cancelled"); }); -it("refuses session-wide cancellation when a newer turn is active", async () => { +it("refuses session-wide interruption when a newer turn is active", async () => { const fixture = backend(); const service = new JobService(fixture.client, { storeRoot: null }); const job = await service.start({ prompt: "fixture" }); - fixture.setMessages([{ info: { id: "newer-user", role: "user", time: { created: Date.now() + 10 } }, parts: [] }]); + fixture.setRunning(true); + fixture.addUser("msg_newer"); await expect(service.cancel(job.jobId!)).rejects.toThrow("newer turn"); - expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/abort"))).toHaveLength(0); + expect(fixture.post.mock.calls.filter(([path]) => path.includes("/interrupt"))).toHaveLength(0); }); it("expires scoped handles without discarding remote sessions", async () => { @@ -325,7 +542,8 @@ it("expires scoped handles without discarding remote sessions", async () => { await new Promise((resolve) => setTimeout(resolve, 40)); await expect(service.get(job.jobId!)).rejects.toThrow("expired"); expect(await service.list()).toEqual([]); - expect(fixture.post.mock.calls.some(([path]) => path.endsWith("/abort"))).toBe(false); + expect(fixture.post.mock.calls.some(([path]) => path.includes("/interrupt"))).toBe(false); + expect(fixture.delete).not.toHaveBeenCalled(); }); it("recovers a cross-process lock only after confirming its owner exited", async () => { @@ -356,40 +574,48 @@ it("inherits cancellation through AsyncLocalStorage for existing tool handlers", const controller = new AbortController(); controller.abort(); const client = new OpenCodeClient({ baseUrl: "http://127.0.0.1:1" }); - await expect(withRequestOptions({ signal: controller.signal }, () => client.get("/session"))).rejects.toMatchObject({ name: "AbortError" }); + await expect(withRequestOptions({ signal: controller.signal }, () => client.get("/api/session"))).rejects.toMatchObject({ name: "AbortError" }); }); it("does not surface a newer turn's approval as input for an older job", async () => { const fixture = backend(); - fixture.setStatus("busy"); - fixture.setMessages([{ info: { id: "new-user", role: "user", time: { created: 10 } }, parts: [] }]); - fixture.setPermissions([{ id: "permission-1", sessionID: "session", permission: "edit", patterns: ["*"] }]); - const result = await observeSession(fixture.client, "session", undefined, "old-user"); + fixture.setRunning(true); + fixture.setMessages([user("msg_old", 1), user("msg_new", 10)]); + fixture.setPermissions([{ id: "per_1", sessionID: SID, action: "edit", resources: ["*"] }]); + const result = await observeSession(fixture.client, SID, undefined, "msg_old"); expect(result.status).toBe("unknown"); expect(result.inputs).toEqual([]); + expect(fixture.get).not.toHaveBeenCalledWith(`/api/session/${SID}/permission`, expect.anything(), expect.anything(), expect.anything()); +}); + +it("attributes a steered follow-up's merged completion to the unfinished turn", async () => { + const fixture = backend(); + // With queued delivery a second user message can only join the running + // turn by steering it: both prompts then share one merged turn closed by + // one idle marker, which completes the job for the original prompt too. + fixture.setMessages([user("msg_old", 1), user("msg_new", 2), assistant("msg_new_answer", 3, "New result"), idle("msg_new_idle", 4)]); + const result = await observeSession(fixture.client, SID, undefined, "msg_old"); + expect(result.status).toBe("completed"); + expect(result.text).toContain("New result"); }); -it("recognizes completed older turns even while a newer turn is busy", async () => { +it("recognizes completed older turns even while a newer turn is running", async () => { const fixture = backend(); - fixture.setStatus("busy"); - fixture.setMessages([ - { info: { id: "old-user", role: "user", time: { created: 1 } }, parts: [] }, - { info: { id: "old-answer", role: "assistant", parentID: "old-user", time: { created: 2, completed: 3 } }, parts: [{ type: "text", text: "Done" }] }, - { info: { id: "new-user", role: "user", time: { created: 4 } }, parts: [] }, - ]); - expect((await observeSession(fixture.client, "session", undefined, "old-user")).status).toBe("completed"); + fixture.setRunning(true); + fixture.setMessages([user("msg_old", 1), assistant("msg_old_answer", 2, "Done"), idle("msg_old_idle", 3), user("msg_new", 4)]); + expect(await observeSession(fixture.client, SID, undefined, "msg_old")).toMatchObject({ status: "completed", text: "Done" }); }); -it("keeps an ambiguous cancellation unknown without sending abort twice", async () => { +it("keeps an ambiguous interruption unknown without sending it twice", async () => { const fixture = backend(); const service = new JobService(fixture.client, { storeRoot: root }); const job = await service.start({ prompt: "fixture" }); - fixture.setStatus("busy"); - fixture.post.mockRejectedValue(new OpenCodeSubmissionError("POST", "/session/session/abort", new Error("socket hang up"))); + fixture.setRunning(true); + fixture.post.mockRejectedValue(new OpenCodeSubmissionError("POST", `/api/session/${SID}/interrupt?resume=false`, new Error("socket hang up"))); expect((await service.cancel(job.jobId!)).status).toBe("unknown"); expect((await service.get(job.jobId!)).status).toBe("unknown"); expect((await service.cancel(job.jobId!)).status).toBe("unknown"); - expect(fixture.post.mock.calls.filter(([path]) => path.endsWith("/abort"))).toHaveLength(1); + expect(fixture.post.mock.calls.filter(([path]) => path.includes("/interrupt"))).toHaveLength(1); }); it("fails closed for interrupted lock initialization or recovery", async () => { diff --git a/tests/modern-stdio.test.ts b/tests/modern-stdio.test.ts index 8a7c76c..ff2a3a5 100644 --- a/tests/modern-stdio.test.ts +++ b/tests/modern-stdio.test.ts @@ -16,7 +16,9 @@ const metadata = { extensions: { "io.modelcontextprotocol/tasks": {} }, elicitation: { form: {} }, }, }; -interface Session { id: string; messageID?: string; prompt?: string; state: "busy" | "idle"; complete?: boolean; } +interface Session { id: string; messageID?: string; prompt?: string; state: "busy" | "idle"; complete?: boolean; interrupted?: boolean; } +const password = "fixture-password"; +const authorization = "Basic " + Buffer.from(`opencode:${password}`).toString("base64"); let http: Server; let root: string; let env: Record; @@ -26,6 +28,20 @@ let replies: number; const children: ChildProcessWithoutNullStreams[] = []; const clients: LegacyClient[] = []; +/** OpenCode 2.x Session.Message.Info list for a session, newest-first like the real server. */ +function messagesFor(session: Session) { + const messages: Record[] = []; + if (session.messageID) messages.push({ id: session.messageID, type: "user", text: session.prompt, time: { created: 1 } }); + if (session.complete) { + messages.push({ id: `msg_assistant_${session.id}`, type: "assistant", agent: "build", model: { id: "fixture", providerID: "fixture" }, + time: { created: 2, completed: 3 }, finish: "stop", content: [{ type: "text", text: "Fixture task completed." }] }); + messages.push({ id: `msg_idle_${session.id}`, type: "idle", outcome: "succeeded", time: { created: 4 } }); + } else if (session.interrupted) { + messages.push({ id: `msg_idle_${session.id}`, type: "idle", outcome: "interrupted", time: { created: 4 } }); + } + return messages.reverse(); +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "opencode-modern-stdio-")); sessions = new Map(); aborts = 0; replies = 0; @@ -34,49 +50,71 @@ beforeEach(async () => { for await (const chunk of req) text += chunk; const body = text ? JSON.parse(text) : {}; const path = new URL(req.url!, "http://fixture").pathname; - const json = (value: unknown) => { res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(value)); }; - if (path === "/global/health") { json({ healthy: true, version: "fixture" }); return; } - if (path === "/session" && req.method === "POST") { - const session: Session = { id: `session_${sessions.size + 1}`, state: "idle" }; - sessions.set(session.id, session); json({ id: session.id }); return; + const json = (value: unknown, status = 200) => { res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value)); }; + const location = { directory: root }; + // OpenCode 2.x enforces HTTP Basic auth on every route. + if (req.headers.authorization !== authorization) { json({ _tag: "UnauthorizedError", message: "Unauthorized" }, 401); return; } + if (path === "/api/info") { json({ version: "2.1.2", pid: process.pid, urls: ["http://fixture"], paths: { tmp: tmpdir() } }); return; } + if (path === "/api/session" && req.method === "POST") { + const session: Session = { id: `ses_fixture${sessions.size + 1}`, state: "idle" }; + sessions.set(session.id, session); + json({ data: { id: session.id, projectID: "fixture", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, title: body.title, model: body.model, location: body.location ?? location } }); + return; + } + if (path === "/api/session/active") { + json({ data: Object.fromEntries([...sessions.values()].filter((s) => s.state === "busy").map((s) => [s.id, { type: "running" }])) }); + return; } - if (path === "/session/status") { json(Object.fromEntries([...sessions.values()].map((session) => [session.id, { type: session.state }]))); return; } - if (path === "/question") { json([]); return; } - if (path === "/permission") { - json([...sessions.values()].filter((session) => session.prompt === "ask permission" && !session.complete).map((session) => ({ - id: `permission_${session.id}`, sessionID: session.id, permission: "edit", patterns: ["src/fixture.ts"], always: [], metadata: {}, - }))); return; + if (path === "/api/form") { json({ location, data: [] }); return; } + if (path === "/api/permission/request") { + json({ location, data: [...sessions.values()].filter((s) => s.prompt === "ask permission" && !s.complete).map((s) => ({ + id: `per_${s.id}`, sessionID: s.id, action: "edit", resources: ["src/fixture.ts"], save: [], metadata: {}, + })) }); + return; } - if (path.startsWith("/permission/") && path.endsWith("/reply")) { - const session = sessions.get(path.split("/")[2].replace("permission_", ""))!; + const parts = path.split("/"); + const session = parts[1] === "api" && parts[2] === "session" ? sessions.get(parts[3]) : undefined; + if (!session) { json({ _tag: "SessionNotFoundError", message: "unknown fixture route" }, 404); return; } + if (parts[4] === "permission" && parts.length === 5 && req.method === "GET") { + // Per-session pending permission requests. + const pending = session.prompt === "ask permission" && !session.complete + ? [{ id: `per_${session.id}`, sessionID: session.id, action: "edit", resources: ["src/fixture.ts"], save: [], metadata: {} }] + : []; + json({ data: pending }); + return; + } + if (parts[4] === "form" && parts.length === 5 && req.method === "GET") { + json({ data: [] }); + return; + } + if (parts[4] === "permission" && parts[6] === "reply" && req.method === "POST") { + if (body.decision !== "once") { json({ _tag: "InvalidRequestError", message: "unexpected decision" }, 400); return; } replies++; session.complete = true; session.state = "idle"; - json(true); return; + res.writeHead(204).end(); return; } - const session = sessions.get(path.split("/")[2]); - if (!session) { res.writeHead(404, { "content-type": "application/json" }).end('{"error":"unknown fixture route"}'); return; } - if (path.endsWith("/prompt_async")) { - session.messageID = body.messageID; session.prompt = body.parts[0].text; + if (parts[4] === "prompt" && req.method === "POST") { + session.messageID = body.id; session.prompt = body.text; session.complete = session.prompt !== "hold" && session.prompt !== "ask permission"; session.state = session.complete ? "idle" : "busy"; - res.writeHead(204).end(); return; + json({ data: { id: body.id, sessionID: session.id, type: "user", time: { created: 1 }, payload: { text: body.text }, delivery: "queued" } }); + return; } - if (path.endsWith("/abort")) { aborts++; session.state = "idle"; json(true); return; } - if (path.endsWith("/message")) { - const messages = session.messageID ? [{ info: { id: session.messageID, role: "user", time: { created: 1 } }, parts: [] }] : []; - if (session.complete) messages.push({ info: { - id: `assistant_${session.id}`, role: "assistant", parentID: session.messageID, - time: { created: 2, completed: 3 }, finish: "stop", structured: { fixture: true }, - }, parts: [{ type: "text", text: "Fixture task completed." }] } as any); - json(messages); return; + if (parts[4] === "interrupt") { aborts++; session.state = "idle"; session.interrupted = true; json({ interrupted: true }); return; } + if (parts[4] === "message") { json({ data: messagesFor(session), cursor: { previous: null, next: null } }); return; } + if (parts.length === 4) { + json({ data: { id: session.id, projectID: "fixture", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, title: "Fixture session", location } }); + return; } - if (path.endsWith("/todo")) { json([]); return; } - json({ id: session.id, title: "Fixture session", summary: { files: 0 } }); + json({ _tag: "NotFound", message: "unknown fixture route" }, 404); }); await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve)); const base = `http://127.0.0.1:${(http.address() as { port: number }).port}`; env = Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)); + delete env.OPENCODE_SERVER_USERNAME; Object.assign(env, { OPENCODE_BASE_URL: base, OPENCODE_AUTO_SERVE: "false", OPENCODE_TASK_STORE: root, - OPENCODE_SERVER_USERNAME: "", OPENCODE_SERVER_PASSWORD: "", OPENCODE_TOOL_PROFILE: "full", + OPENCODE_SERVER_PASSWORD: password, OPENCODE_TOOL_PROFILE: "full", OPENCODE_DEFAULT_PROVIDER: "fixture", OPENCODE_DEFAULT_MODEL: "fixture" }); }); @@ -143,7 +181,9 @@ describe("July 2026 stdio interoperability", () => { const second = rawConnection(); const get = await second.request("tasks/get", { taskId: started.result.taskId }); expect(get.error).toBeUndefined(); - expect(get.result).toMatchObject({ resultType: "complete", status: "completed", result: { resultType: "complete", structuredContent: { result: { fixture: true } } } }); + expect(get.result).toMatchObject({ resultType: "complete", status: "completed", result: { resultType: "complete", + structuredContent: { status: "completed", result: { type: "assistant", id: expect.stringMatching(/^msg_assistant_/) } } } }); + expect(get.result.result.content[0].text).toContain("Fixture task completed."); expect(sessions.size).toBe(1); }); @@ -155,7 +195,8 @@ describe("July 2026 stdio interoperability", () => { const get = await connection.request("tasks/get", { taskId }); expect(get.result.status).toBe("input_required"); const key = Object.keys(get.result.inputRequests)[0]; - expect(key).toBe("permission:permission_session_1"); + expect(key).toBe("permission:per_ses_fixture1"); + expect(get.result.inputRequests[key].params.message).toContain("edit"); expect(replies).toBe(0); const updated = await connection.request("tasks/update", { taskId, inputResponses: { [key]: { action: "accept", content: { decision: "once" } }, @@ -230,6 +271,6 @@ describe("July 2026 stdio interoperability", () => { expect(jobId).toMatch(/^job_/); const finished = await legacy.callTool({ name: "opencode_job_get", arguments: { jobId } }); expect(finished.isError).not.toBe(true); - expect(finished.structuredContent).toMatchObject({ status: "completed", result: { fixture: true } }); + expect(finished.structuredContent).toMatchObject({ status: "completed", result: { type: "assistant", finish: "stop" } }); }); }); diff --git a/tests/openchamber-auth.test.ts b/tests/openchamber-auth.test.ts index 045cd66..086a472 100644 --- a/tests/openchamber-auth.test.ts +++ b/tests/openchamber-auth.test.ts @@ -7,8 +7,8 @@ import { registerEventTools } from "../src/tools/events.js"; const PASSWORD = "fixture-ui-password"; -// Minimal OpenChamber stand-in: password login issues bearer client tokens, -// and the OpenCode API is proxied under /api and rejects anything else. +// Minimal OpenChamber 2.x stand-in: password login issues bearer client tokens, +// and OpenCode 2.x's /api/* routes pass through unchanged; anything else is rejected. describe("OpenChamber authentication", () => { let server: Server; let origin: string; @@ -58,8 +58,8 @@ describe("OpenChamber authentication", () => { if (!url.pathname.startsWith("/api/") || !bearer || !valid.has(bearer)) { return send(401, { error: "UI authentication required", locked: true }); } - if (url.pathname === "/api/global/health") return send(200, { healthy: true, version: "fixture" }); - if (url.pathname === "/api/event" || url.pathname === "/api/global/event") { + if (url.pathname === "/api/info") return send(200, { version: "fixture", pid: 1, urls: [], paths: { tmp: "/tmp" } }); + if (url.pathname === "/api/event") { res.writeHead(200, { "content-type": "text/event-stream" }); return res.end("event: ping\ndata: {}\n\n"); } @@ -75,14 +75,14 @@ describe("OpenChamber authentication", () => { }); function openchamberClient(options: Partial[0]> = {}) { - return new OpenCodeClient({ baseUrl: `${origin}/api`, openchamberPassword: PASSWORD, ...options }); + return new OpenCodeClient({ baseUrl: origin, openchamberPassword: PASSWORD, ...options }); } it("signs in lazily and sends the client token as a bearer header", async () => { const client = openchamberClient({ username: "ignored", password: "ignored" }); expect(logins).toHaveLength(0); - await client.get("/session"); - await client.post("/session", { title: "x" }); + await client.get("/api/session"); + await client.post("/api/session", { title: "x" }); expect(logins).toHaveLength(1); expect(logins[0]).toMatchObject({ password: PASSWORD, issueClientToken: true, trustDevice: true, clientKind: "desktop" }); expect(logins[0].clientLabel).toMatch(/^opencode-mcp/); @@ -92,9 +92,9 @@ describe("OpenChamber authentication", () => { it("signs in again once after a 401 and replays the request body", async () => { const client = openchamberClient(); - await client.get("/session"); + await client.get("/api/session"); valid.clear(); // token expired or revoked - const result = await client.post("/session", { title: "replayed" }); + const result = await client.post("/api/session", { title: "replayed" }); expect(result).toMatchObject({ ok: true }); expect(logins).toHaveLength(2); expect(requests.slice(1).map((r) => [r.authorization, r.body])).toEqual([ @@ -105,32 +105,32 @@ describe("OpenChamber authentication", () => { it("shares one renewal between concurrent rejected requests", async () => { const client = openchamberClient(); - await client.get("/session"); + await client.get("/api/session"); valid.clear(); - await Promise.all([client.get("/a"), client.get("/b"), client.get("/c")]); + await Promise.all([client.get("/api/a"), client.get("/api/b"), client.get("/api/c")]); expect(logins).toHaveLength(2); }); it("gives two independent clients distinct dedupeKeys so neither invalidates the other", async () => { const a = openchamberClient(); const b = openchamberClient(); - await a.get("/session"); - await b.get("/session"); // against the real OpenChamber store, this used to revoke a's token + await a.get("/api/session"); + await b.get("/api/session"); // against the real OpenChamber store, this used to revoke a's token expect(a).not.toBe(b); expect(logins).toHaveLength(2); expect(logins[0].dedupeKey).not.toBe(logins[1].dedupeKey); // a's token must still be valid: logging in as b did not evict it. - await expect(a.get("/still-valid")).resolves.toMatchObject({ ok: true }); + await expect(a.get("/api/still-valid")).resolves.toMatchObject({ ok: true }); expect(logins).toHaveLength(2); }); it("renews both independent clients concurrently without a spurious 401", async () => { const a = openchamberClient(); const b = openchamberClient(); - await a.get("/session"); - await b.get("/session"); + await a.get("/api/session"); + await b.get("/api/session"); valid.clear(); // both tokens expired or were revoked out of band - const results = await Promise.allSettled([a.get("/a"), b.get("/b")]); + const results = await Promise.allSettled([a.get("/api/a"), b.get("/api/b")]); expect(results.every((r) => r.status === "fulfilled")).toBe(true); // One renewal per client; neither client's renewal evicted the other's. expect(logins).toHaveLength(4); @@ -138,18 +138,18 @@ describe("OpenChamber authentication", () => { it("surfaces a 401 when the renewed token is rejected too, without looping", async () => { const client = openchamberClient(); - await client.get("/session"); + await client.get("/api/session"); valid.clear(); const add = valid.add.bind(valid); valid.add = () => valid; // the server stops accepting new tokens - await expect(client.get("/session")).rejects.toMatchObject({ status: 401 }); + await expect(client.get("/api/session")).rejects.toMatchObject({ status: 401 }); valid.add = add; expect(logins).toHaveLength(2); }); it("reports a rejected password without echoing it", async () => { const client = openchamberClient({ openchamberPassword: "wrong-secret" }); - const error = await client.get("/session").catch((e) => e); + const error = await client.get("/api/session").catch((e) => e); expect(String(error.message)).toContain("OPENCODE_OPENCHAMBER_PASSWORD"); expect(String(error.message)).not.toContain("wrong-secret"); expect(requests).toHaveLength(0); @@ -157,7 +157,7 @@ describe("OpenChamber authentication", () => { it("reports rate limiting", async () => { loginStatus = 429; - await expect(openchamberClient().get("/session")).rejects.toThrow(/rate limited; retry after 900s/); + await expect(openchamberClient().get("/api/session")).rejects.toThrow(/rate limited; retry after 900s/); }); describe("rejected sign-ins", () => { @@ -170,31 +170,31 @@ describe("OpenChamber authentication", () => { it("does not post a rejected password again before a restart", async () => { const client = openchamberClient({ openchamberPassword: "wrong-secret" }); - await expect(client.get("/session")).rejects.toThrow(/OPENCODE_OPENCHAMBER_PASSWORD and restart/); + await expect(client.get("/api/session")).rejects.toThrow(/OPENCODE_OPENCHAMBER_PASSWORD and restart/); now += 24 * 60 * 60_000; - await expect(client.get("/session")).rejects.toThrow(/OPENCODE_OPENCHAMBER_PASSWORD/); + await expect(client.get("/api/session")).rejects.toThrow(/OPENCODE_OPENCHAMBER_PASSWORD/); expect(logins).toHaveLength(1); }); it("waits out Retry-After when rate limited, then signs in", async () => { loginStatus = 429; const client = openchamberClient(); - await expect(client.get("/session")).rejects.toThrow(/rate limited; retry after 900s/); + await expect(client.get("/api/session")).rejects.toThrow(/rate limited; retry after 900s/); loginStatus = 200; now += 899_000; - await expect(client.get("/session")).rejects.toThrow(/rate limited/); + await expect(client.get("/api/session")).rejects.toThrow(/rate limited/); expect(logins).toHaveLength(1); now += 1_001; - await client.get("/session"); + await client.get("/api/session"); expect(logins).toHaveLength(2); }); it("retries straight away after a 5xx, which never reached OpenChamber's limiter", async () => { loginStatus = 503; const client = openchamberClient(); - await expect(client.get("/session")).rejects.toThrow(/failed \(503\)/); + await expect(client.get("/api/session")).rejects.toThrow(/failed \(503\)/); loginStatus = 200; - await client.get("/session"); + await client.get("/api/session"); expect(logins).toHaveLength(2); }); @@ -202,10 +202,10 @@ describe("OpenChamber authentication", () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); const signIns = () => fetchSpy.mock.calls.filter(([input]) => String(input).endsWith("/auth/session")).length; const client = openchamberClient({ openchamberUrl: "http://127.0.0.1:1" }); - await expect(client.get("/session")).rejects.toThrow(/OpenChamber login to .* failed/); + await expect(client.get("/api/session")).rejects.toThrow(/OpenChamber login to .* failed/); const first = signIns(); expect(first).toBeGreaterThan(0); - await expect(client.get("/session")).rejects.toThrow(/OpenChamber login to .* failed/); + await expect(client.get("/api/session")).rejects.toThrow(/OpenChamber login to .* failed/); expect(signIns()).toBeGreaterThan(first); }); @@ -219,58 +219,57 @@ describe("OpenChamber authentication", () => { }); it("uses OPENCODE_OPENCHAMBER_URL for login when the API lives elsewhere", async () => { - const client = new OpenCodeClient({ baseUrl: `${origin}/api`, openchamberPassword: PASSWORD, openchamberUrl: `${origin}/ignored/path` }); - await client.get("/session"); + const client = new OpenCodeClient({ baseUrl: origin, openchamberPassword: PASSWORD, openchamberUrl: `${origin}/ignored/path` }); + await client.get("/api/session"); expect(logins).toHaveLength(1); }); it("authenticates event streams", async () => { const client = openchamberClient(); - // Exercise subscribeSSE the same way opencode_events_poll does: an - // unprefixed path that relies on the client preserving `baseUrl`'s `/api` - // prefix, not a path with the prefix already baked in. + // Exercise subscribeSSE the same way opencode_events_poll does. const events = []; - for await (const event of client.subscribeSSE("/event")) events.push(event); + for await (const event of client.subscribeSSE("/api/event")) events.push(event); expect(events).toEqual([{ event: "ping", data: "{}" }]); expect(requests.at(-1)?.authorization).toBe("Bearer oc_client_1"); }); - it("authenticates the events-poll tool through the OpenChamber proxy prefix", async () => { + it("authenticates the events-poll tool through OpenChamber", async () => { const client = openchamberClient(); let handler: Function = () => {}; registerEventTools({ tool: (...args: any[]) => { handler = args.at(-1); } } as any, client); const result = await handler({ durationMs: 200 }); expect(result.isError).toBeFalsy(); - expect(requests.some((r) => r.path === "/api/event")).toBe(true); + expect(requests.some((r) => r.path === "/api/event" && r.authorization === "Bearer oc_client_1")).toBe(true); }); - it("authenticates the global events-poll tool through the OpenChamber proxy prefix", async () => { + it("authenticates the global events-poll tool through OpenChamber", async () => { const client = openchamberClient(); let handler: Function = () => {}; registerEventTools({ tool: (...args: any[]) => { handler = args.at(-1); } } as any, client); const result = await handler({ durationMs: 200, scope: "global" }); expect(result.isError).toBeFalsy(); - expect(requests.some((r) => r.path === "/api/global/event")).toBe(true); + // OpenCode 2.x has one /api/event stream; global scope just omits the directory. + expect(requests.some((r) => r.path === "/api/event" && r.authorization === "Bearer oc_client_1")).toBe(true); }); it("authenticates the health probe", async () => { const client = openchamberClient(); - const status = await ensureServer({ baseUrl: `${origin}/api`, authorization: await client.getAuthorization() }); + const status = await ensureServer({ baseUrl: origin, authorization: await client.getAuthorization() }); expect(status).toMatchObject({ running: true, version: "fixture", managedByUs: false }); }); describe("static bearer token", () => { it("sends the token and never signs in", async () => { valid.add("oc_client_static"); - const client = new OpenCodeClient({ baseUrl: `${origin}/api`, token: "oc_client_static", password: "basic-ignored" }); - await client.get("/session"); + const client = new OpenCodeClient({ baseUrl: origin, token: "oc_client_static", password: "basic-ignored" }); + await client.get("/api/session"); expect(requests[0].authorization).toBe("Bearer oc_client_static"); expect(logins).toHaveLength(0); }); it("does not retry a rejected static token", async () => { - const client = new OpenCodeClient({ baseUrl: `${origin}/api`, token: "oc_client_revoked" }); - const error = await client.get("/session").catch((e) => e); + const client = new OpenCodeClient({ baseUrl: origin, token: "oc_client_revoked" }); + const error = await client.get("/api/session").catch((e) => e); expect(error).toBeInstanceOf(OpenCodeError); expect(error.status).toBe(401); expect(requests).toHaveLength(1); diff --git a/tests/resources.test.ts b/tests/resources.test.ts index 4e0d6d4..fc5aea1 100644 --- a/tests/resources.test.ts +++ b/tests/resources.test.ts @@ -68,7 +68,7 @@ describe("Resource registration", () => { describe("Resource handlers", () => { it("config resource returns JSON from client", async () => { const mockClient = createMockClient(async (path) => { - if (path === "/config") return { theme: "dark", model: "claude-3" }; + if (path === "/api/config") return { theme: "dark", model: "claude-3" }; return {}; }); const resources = new Map(); @@ -120,8 +120,8 @@ describe("Resource handlers", () => { expect(result.contents[0].text).toContain("No VCS information available"); }); - it("health resource fetches from /global/health", async () => { - const getMock = vi.fn().mockResolvedValue({ version: "1.0", ok: true }); + it("health resource fetches from /api/info", async () => { + const getMock = vi.fn().mockResolvedValue({ version: "2.1.2", pid: 1, urls: ["http://127.0.0.1:4096"], paths: { tmp: "/tmp" } }); const mockClient = createMockClient(getMock as unknown as (path: string) => Promise); const resources = new Map(); const mockServer = { @@ -132,7 +132,31 @@ describe("Resource handlers", () => { registerResources(mockServer, mockClient); const handler = resources.get("health")!; - await handler(); - expect(getMock).toHaveBeenCalledWith("/global/health"); + const result = await handler(); + expect(getMock).toHaveBeenCalledWith("/api/info"); + expect(result.contents[0].text).toContain("2.1.2"); + }); + + it.each([ + ["project-current", "/api/location"], + ["providers", "/api/provider"], + ["agents", "/api/agent"], + ["commands", "/api/command"], + ["vcs", "/api/vcs"], + ["sessions", "/api/session"], + ["mcp-servers", "/api/mcp"], + ["file-status", "/api/vcs/status"], + ])("%s resource reads OpenCode 2.x endpoint %s", async (name, endpoint) => { + const getMock = vi.fn().mockResolvedValue([]); + const mockClient = createMockClient(getMock as unknown as (path: string) => Promise); + const resources = new Map(); + const mockServer = { + resource: vi.fn((_n: string, _u: string, _o: unknown, handler: Function) => { + resources.set(_n, handler); + }), + } as unknown as McpServer; + registerResources(mockServer, mockClient); + await resources.get(name)!(); + expect(getMock).toHaveBeenCalledWith(endpoint); }); }); diff --git a/tests/server-manager.test.ts b/tests/server-manager.test.ts index 4691c0d..4a96f9b 100644 --- a/tests/server-manager.test.ts +++ b/tests/server-manager.test.ts @@ -1,28 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; /** - * Server manager tests. + * Server manager tests (OpenCode 2.x). * - * Post-SDK-migration (PR #11), `startServer` calls `createOpencodeServer` from - * `@opencode-ai/sdk` instead of spawning `opencode serve` as a subprocess. - * These tests cover the surviving contract: `isServerRunning` health probes - * via `fetch`, and `ensureServer`'s detect-or-start branching. - * - * Deep startup / lifecycle integration is deferred to a real integration - * suite (see roadmap C2) — we can't fully exercise `createOpencodeServer` - * without booting a real port. + * `startServer` spawns `opencode serve` itself with a generated + * `OPENCODE_SERVER_PASSWORD`, waits for the `server listening on ` line, + * then probes `GET /api/info` with Basic auth (user `opencode`). These tests + * mock `node:child_process.spawn` and `fetch`, so no real server is started. */ -// Mock the SDK before importing the server-manager module so the import chain -// picks up the mock. `vi.hoisted` keeps the mock reference accessible from -// both the (hoisted) `vi.mock` factory and the assertions below. -const { createOpencodeServerMock } = vi.hoisted(() => ({ - createOpencodeServerMock: vi.fn(), -})); -vi.mock("@opencode-ai/sdk", () => ({ - createOpencodeServer: createOpencodeServerMock, - OpencodeClient: vi.fn(), -})); +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); +vi.mock("node:child_process", () => ({ spawn: spawnMock })); import { isServerRunning, @@ -33,44 +22,87 @@ import { resetShutdownRegisteredForTests, } from "../src/server-manager.js"; -// ─── Helpers ───────────────────────────────────────────────────────────── +// ─── Fixtures ──────────────────────────────────────────────────────────── -let fetchMock: ReturnType; -let consoleErrorSpy: ReturnType; +/** `GET /api/info` body as served by OpenCode 2.1.2 (ServerInfo schema). */ +function serverInfo(version = "2.1.2", url = "http://127.0.0.1:4096") { + return { version, pid: 4242, urls: [url], paths: { tmp: "/tmp/opencode" }, capabilities: { persistentPty: true } }; +} -function mockFetchHealthy(version = "1.14.46") { - fetchMock.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ healthy: true, version }), - } as unknown as Response); +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } -function mockFetchDown() { - fetchMock.mockRejectedValueOnce(new Error("ECONNREFUSED")); +/** Real 2.x 401 body for a missing or wrong password. */ +const unauthorized = () => json({ _tag: "UnauthorizedError", message: "Authentication required" }, 401); +/** OpenCode 2.x serves its web UI for unknown non-API paths such as `/global/health`. */ +const spaFallback = () => new Response("", { status: 200, headers: { "content-type": "text/html" } }); +/** OpenCode 1.x `/global/health`. */ +const legacyHealth = () => json({ healthy: true, version: "1.14.46" }); +const refused = () => Promise.reject(Object.assign(new TypeError("fetch failed"), { cause: new Error("connect ECONNREFUSED 127.0.0.1:4096") })); + +const basic = (user: string, password: string) => "Basic " + Buffer.from(`${user}:${password}`).toString("base64"); + +class FakeProcess extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + kill = vi.fn(() => { this.killed = true; return true; }); } -function mockFetchUnhealthy() { - fetchMock.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ healthy: false }), - } as unknown as Response); +interface Spawned { proc: FakeProcess; password: string; url: string; args: string[]; options: any } +let spawned: Spawned[]; + +/** + * Make `spawn("opencode", ...)` return a fake process. By default it prints + * the 2.x startup line for the requested host/port on the next tick and then + * answers `/api/info` (only with the generated password) via the fetch router. + */ +function fakeOpencode(behaviour: { lines?: string[]; listen?: boolean; exitCode?: number; error?: Error } = {}) { + spawnMock.mockImplementation((_command: string, args: string[], options: any) => { + const proc = new FakeProcess(); + const host = args.find((a) => a.startsWith("--hostname="))!.split("=")[1]; + const port = args.find((a) => a.startsWith("--port="))!.split("=")[1]; + const url = `http://${host}:${port}`; + spawned.push({ proc, password: options.env.OPENCODE_SERVER_PASSWORD, url, args, options }); + setImmediate(() => { + for (const line of behaviour.lines ?? []) proc.stdout.emit("data", Buffer.from(line + "\n")); + if (behaviour.error) { proc.emit("error", behaviour.error); return; } + if (behaviour.exitCode !== undefined) { proc.emit("exit", behaviour.exitCode); return; } + if (behaviour.listen !== false) proc.stdout.emit("data", Buffer.from(`server listening on ${url}\n`)); + }); + return proc; + }); } -function mockFetchNotOk() { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 500, - text: async () => "Internal Server Error", - } as unknown as Response); +/** Route fetches by URL: spawned, still-running fakes answer /api/info. */ +let route: (url: string, init: RequestInit) => Response | Promise; +function defaultRoute(url: string, init: RequestInit): Response | Promise { + const target = new URL(url); + const server = spawned.find((s) => !s.proc.killed && new URL(s.url).host === target.host); + if (!server) return refused(); + if (target.pathname !== "/api/info") return spaFallback(); + const auth = (init.headers as Record | undefined)?.Authorization; + // Like the real binary: the username comes from OPENCODE_SERVER_USERNAME, default "opencode". + const user = server.options.env.OPENCODE_SERVER_USERNAME ?? "opencode"; + return auth === basic(user, server.password) ? json(serverInfo("2.1.2", server.url)) : unauthorized(); +} + +let fetchMock: ReturnType; +let consoleErrorSpy: ReturnType; + +function headersOf(call: number): Record { + return ((fetchMock.mock.calls[call][1] as RequestInit).headers ?? {}) as Record; } beforeEach(() => { - fetchMock = vi.fn(); - globalThis.fetch = fetchMock as unknown as typeof fetch; + spawned = []; + route = defaultRoute; + fetchMock = vi.fn(async (url: string, init: RequestInit) => route(String(url), init ?? {})); + vi.stubGlobal("fetch", fetchMock); consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - createOpencodeServerMock.mockReset(); + spawnMock.mockReset(); + fakeOpencode(); resetShutdownRegisteredForTests(); // Capture lifecycle registration without installing handlers on the test runner. vi.spyOn(process, "on").mockReturnValue(process); @@ -78,179 +110,220 @@ beforeEach(() => { }); afterEach(() => { - vi.restoreAllMocks(); stopServer(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); // ─── isServerRunning ───────────────────────────────────────────────────── describe("isServerRunning", () => { - it("returns healthy=true with version when server responds", async () => { - mockFetchHealthy("1.14.46"); + it("returns healthy=true with version from GET /api/info", async () => { + route = () => json(serverInfo("2.1.2")); const result = await isServerRunning("http://127.0.0.1:4096"); - expect(result).toEqual({ healthy: true, version: "1.14.46" }); - expect(fetchMock).toHaveBeenCalledWith( - "http://127.0.0.1:4096/global/health", - expect.objectContaining({ method: "GET" }), - ); + expect(result).toEqual({ healthy: true, version: "2.1.2" }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:4096/api/info", expect.objectContaining({ method: "GET" })); }); - it("returns healthy=false when server is down", async () => { - mockFetchDown(); - const result = await isServerRunning("http://127.0.0.1:4096"); - expect(result).toEqual({ healthy: false }); + it("strips trailing slash from base URL", async () => { + route = () => json(serverInfo()); + await isServerRunning("http://127.0.0.1:4096/"); + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:4096/api/info", expect.anything()); }); - it("returns healthy=false when response is not ok", async () => { - mockFetchNotOk(); - const result = await isServerRunning("http://127.0.0.1:4096"); - expect(result).toEqual({ healthy: false }); + it("handles version missing from response", async () => { + route = () => json({ pid: 1, urls: [], paths: { tmp: "/tmp" } }); + expect(await isServerRunning("http://127.0.0.1:4096")).toEqual({ healthy: true, version: undefined }); + }); + + it("reports authFailed on 401 without probing the legacy endpoint", async () => { + route = () => unauthorized(); + const result = await isServerRunning("http://127.0.0.1:4096", "opencode", "wrong"); + expect(result).toEqual({ healthy: false, authFailed: true }); + expect(fetchMock).toHaveBeenCalledOnce(); }); - it("returns healthy=false when body says not healthy", async () => { - mockFetchUnhealthy(); + it("detects a legacy 1.x server when /api/info is 404 and /global/health answers", async () => { + route = (url) => url.endsWith("/api/info") ? new Response("Not Found", { status: 404 }) : legacyHealth(); const result = await isServerRunning("http://127.0.0.1:4096"); - expect(result).toEqual({ healthy: false, version: undefined }); + expect(result).toEqual({ healthy: false, legacy: true }); + expect(fetchMock).toHaveBeenLastCalledWith("http://127.0.0.1:4096/global/health", expect.anything()); }); - it("strips trailing slash from base URL", async () => { - mockFetchHealthy(); - await isServerRunning("http://127.0.0.1:4096/"); - expect(fetchMock).toHaveBeenCalledWith( - "http://127.0.0.1:4096/global/health", - expect.anything(), - ); + it("detects a legacy 1.x server whose /api/info falls through to HTML", async () => { + route = (url) => url.endsWith("/api/info") ? spaFallback() : legacyHealth(); + expect(await isServerRunning("http://127.0.0.1:4096")).toEqual({ healthy: false, legacy: true }); }); - it("handles version missing from response", async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ healthy: true }), - } as unknown as Response); - const result = await isServerRunning("http://127.0.0.1:4096"); - expect(result).toEqual({ healthy: true, version: undefined }); + it("does not mistake the 2.x web UI fallback for a legacy health endpoint", async () => { + route = (url) => url.endsWith("/api/info") ? new Response("", { status: 404 }) : spaFallback(); + expect(await isServerRunning("http://127.0.0.1:4096")).toEqual({ healthy: false }); + }); + + it("returns healthy=false when the server is down", async () => { + route = () => refused(); + expect(await isServerRunning("http://127.0.0.1:4096")).toEqual({ healthy: false }); + }); + + it("returns healthy=false on a non-401/404 error status", async () => { + route = () => new Response("Internal Server Error", { status: 500 }); + expect(await isServerRunning("http://127.0.0.1:4096")).toEqual({ healthy: false }); + expect(fetchMock).toHaveBeenCalledOnce(); }); it("times out cleanly (returns unhealthy on AbortError)", async () => { - fetchMock.mockRejectedValueOnce( - Object.assign(new Error("aborted"), { name: "AbortError" }), - ); - const result = await isServerRunning("http://127.0.0.1:4096"); - expect(result).toEqual({ healthy: false }); + route = () => Promise.reject(Object.assign(new Error("aborted"), { name: "AbortError" })); + expect(await isServerRunning("http://127.0.0.1:4096")).toEqual({ healthy: false }); }); it("does not send an Authorization header when no password is given", async () => { - mockFetchHealthy(); + route = () => json(serverInfo()); await isServerRunning("http://127.0.0.1:4096"); - const init = fetchMock.mock.calls[0][1] as RequestInit; - const headers = (init.headers ?? {}) as Record; - expect(headers.Authorization).toBeUndefined(); + expect(headersOf(0).Authorization).toBeUndefined(); }); it("sends Basic auth header when password is provided", async () => { - mockFetchHealthy(); + route = () => json(serverInfo()); await isServerRunning("http://127.0.0.1:4096", "admin", "secret123"); - const init = fetchMock.mock.calls[0][1] as RequestInit; - const headers = (init.headers ?? {}) as Record; - expect(headers.Authorization).toBe("Basic YWRtaW46c2VjcmV0MTIz"); + expect(headersOf(0).Authorization).toBe("Basic YWRtaW46c2VjcmV0MTIz"); }); it("defaults username to 'opencode' when only password is provided", async () => { - mockFetchHealthy(); + route = () => json(serverInfo()); await isServerRunning("http://127.0.0.1:4096", undefined, "secret123"); - const init = fetchMock.mock.calls[0][1] as RequestInit; - const headers = (init.headers ?? {}) as Record; // base64("opencode:secret123") === "b3BlbmNvZGU6c2VjcmV0MTIz" - expect(headers.Authorization).toBe("Basic b3BlbmNvZGU6c2VjcmV0MTIz"); + expect(headersOf(0).Authorization).toBe("Basic b3BlbmNvZGU6c2VjcmV0MTIz"); + }); + + it("never sends credentials to the legacy probe", async () => { + route = (url) => url.endsWith("/api/info") ? new Response("", { status: 404 }) : legacyHealth(); + await isServerRunning("http://127.0.0.1:4096", "opencode", "secret123"); + expect(headersOf(1).Authorization).toBeUndefined(); }); it("prefers an explicit Authorization header over Basic credentials", async () => { - mockFetchHealthy(); + route = () => json(serverInfo()); await isServerRunning("http://127.0.0.1:4096", "admin", "secret123", "Bearer oc_client_x"); - const init = fetchMock.mock.calls[0][1] as RequestInit; - const headers = (init.headers ?? {}) as Record; - expect(headers.Authorization).toBe("Bearer oc_client_x"); + expect(headersOf(0).Authorization).toBe("Bearer oc_client_x"); }); }); // ─── startServer ───────────────────────────────────────────────────────── describe("startServer", () => { - it("calls createOpencodeServer with parsed hostname and port", async () => { - createOpencodeServerMock.mockResolvedValueOnce({ - url: "http://127.0.0.1:4096", - close: vi.fn(), - }); - // Post-start health check - mockFetchHealthy("1.14.46"); - + it("spawns `opencode serve` with a generated password and probes /api/info with it", async () => { const result = await startServer("http://127.0.0.1:4096", 5000); - expect(createOpencodeServerMock).toHaveBeenCalledWith( - expect.objectContaining({ - hostname: "127.0.0.1", - port: 4096, - timeout: 5000, - }), - ); - expect(result.url).toBe("http://127.0.0.1:4096"); - expect(result.version).toBe("1.14.46"); + expect(spawnMock).toHaveBeenCalledOnce(); + const [command, args, options] = spawnMock.mock.calls[0]; + expect(command).toBe("opencode"); + expect(args).toEqual(["serve", "--hostname=127.0.0.1", "--port=4096"]); + expect(options.stdio).toEqual(["ignore", "pipe", "pipe"]); + const password = options.env.OPENCODE_SERVER_PASSWORD; + expect(password).toMatch(/^[A-Za-z0-9_-]{32}$/); + // The child inherits the rest of the environment. + expect(options.env.PATH).toBe(process.env.PATH); + + expect(result).toEqual({ url: "http://127.0.0.1:4096", version: "2.1.2", password }); + const probe = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/api/info"))!; + expect((probe[1] as RequestInit).headers).toMatchObject({ Authorization: basic("opencode", password) }); + }); + + it("generates a fresh password for every start", async () => { + const first = await startServer("http://127.0.0.1:4096", 5000); + stopServer(); + const second = await startServer("http://127.0.0.1:4096", 5000); + expect(first.password).not.toBe(second.password); }); it("parses custom hostname and port from baseUrl", async () => { - createOpencodeServerMock.mockResolvedValueOnce({ - url: "http://localhost:5000", - close: vi.fn(), - }); - mockFetchHealthy("1.14.46"); + const result = await startServer("http://localhost:5000", 5000); + expect(spawnMock.mock.calls[0][1]).toEqual(["serve", "--hostname=localhost", "--port=5000"]); + expect(result.url).toBe("http://localhost:5000"); + }); - await startServer("http://localhost:5000", 5000); + it("falls back to port 4096 when baseUrl omits port", async () => { + await startServer("http://localhost", 5000); + expect(spawnMock.mock.calls[0][1]).toContain("--port=4096"); + }); - expect(createOpencodeServerMock).toHaveBeenCalledWith( - expect.objectContaining({ - hostname: "localhost", - port: 5000, - }), - ); + it("uses the URL reported on stdout rather than the requested one", async () => { + spawnMock.mockImplementation((_c: string, _a: string[], options: any) => { + const proc = new FakeProcess(); + spawned.push({ proc, password: options.env.OPENCODE_SERVER_PASSWORD, url: "http://127.0.0.1:4555", args: _a, options }); + setImmediate(() => proc.stdout.emit("data", Buffer.from("server listening on http://127.0.0.1:4555\n"))); + return proc; + }); + expect((await startServer("http://localhost:4555", 5000)).url).toBe("http://127.0.0.1:4555"); }); - it("falls back to port 4096 when baseUrl omits port", async () => { - createOpencodeServerMock.mockResolvedValueOnce({ - url: "http://localhost:4096", - close: vi.fn(), + it("detects the listening line on stderr too", async () => { + spawnMock.mockImplementation((_c: string, args: string[], options: any) => { + const proc = new FakeProcess(); + spawned.push({ proc, password: options.env.OPENCODE_SERVER_PASSWORD, url: "http://127.0.0.1:4096", args, options }); + setImmediate(() => proc.stderr.emit("data", Buffer.from("server listening on http://127.0.0.1:4096\r\n"))); + return proc; }); - mockFetchHealthy("1.14.46"); + expect((await startServer("http://127.0.0.1:4096", 5000)).url).toBe("http://127.0.0.1:4096"); + }); - await startServer("http://localhost", 5000); + it("keeps its own password even if the server prints a generated one", async () => { + fakeOpencode({ lines: ["server password not-ours"] }); + const result = await startServer("http://127.0.0.1:4096", 5000); + expect(result.password).toBe(spawned[0].password); + expect(result.password).not.toBe("not-ours"); + }); - expect(createOpencodeServerMock).toHaveBeenCalledWith( - expect.objectContaining({ port: 4096 }), - ); + it("rejects and kills the child on a listen error line", async () => { + fakeOpencode({ lines: ["Error: listen EADDRINUSE: address already in use 127.0.0.1:4096"], listen: false }); + await expect(startServer("http://127.0.0.1:4096", 5000)).rejects.toThrow("OpenCode server failed to start: Error: listen EADDRINUSE"); + expect(spawned[0].proc.kill).toHaveBeenCalled(); }); - it("propagates createOpencodeServer rejection", async () => { - createOpencodeServerMock.mockRejectedValueOnce( - new Error("port already in use"), - ); + it("rejects when the child exits before listening, including its output", async () => { + fakeOpencode({ lines: ["fatal: bad config"], exitCode: 1 }); + await expect(startServer("http://127.0.0.1:4096", 5000)).rejects.toThrow(/exited before listening \(code 1\)\nfatal: bad config/); + }); - await expect(startServer("http://127.0.0.1:4096", 5000)).rejects.toThrow( - "port already in use", - ); + it("rejects when the binary cannot be spawned", async () => { + fakeOpencode({ error: Object.assign(new Error("spawn opencode ENOENT"), { code: "ENOENT" }) }); + await expect(startServer("http://127.0.0.1:4096", 5000)).rejects.toThrow("spawn opencode ENOENT"); }); - it("returns version=undefined when post-start health check fails", async () => { - createOpencodeServerMock.mockResolvedValueOnce({ - url: "http://127.0.0.1:4096", - close: vi.fn(), - }); - mockFetchDown(); // health check after start fails + it("times out and kills the child when it never reports listening", async () => { + fakeOpencode({ listen: false }); + await expect(startServer("http://127.0.0.1:4096", 50)).rejects.toThrow("Timeout waiting for the OpenCode server to start after 50ms"); + expect(spawned[0].proc.kill).toHaveBeenCalled(); + }); - const result = await startServer("http://127.0.0.1:4096", 5000); + it("kills the child and throws when it never becomes healthy", async () => { + route = () => unauthorized(); // e.g. the child ignored our password + await expect(startServer("http://127.0.0.1:4096", 300)).rejects.toThrow("did not become healthy"); + expect(spawned[0].proc.kill).toHaveBeenCalledOnce(); + }); - expect(result.url).toBe("http://127.0.0.1:4096"); - expect(result.version).toBeUndefined(); + it("detects the listening line when it arrives split across stdout chunks", async () => { + spawnMock.mockImplementation((_cmd: string, args: string[], options: any) => { + const proc = new FakeProcess(); + spawned.push({ proc, password: options.env.OPENCODE_SERVER_PASSWORD, url: "http://127.0.0.1:4096", args, options }); + setImmediate(() => { + proc.stdout.emit("data", Buffer.from("server listen")); + proc.stdout.emit("data", Buffer.from("ing on http://127.0.0.1:4096\n")); + }); + return proc; + }); + expect((await startServer("http://127.0.0.1:4096", 5000)).url).toBe("http://127.0.0.1:4096"); }); + + it.each(["http://remote.example:4096", "https://localhost:4096", "http://localhost:4096/proxy", "http://0.0.0.0:4096"])( + "refuses to spawn for non-loopback or prefixed URL %s", + async (baseUrl) => { + await expect(startServer(baseUrl, 5000)).rejects.toThrow("local loopback HTTP URL"); + expect(spawnMock).not.toHaveBeenCalled(); + }, + ); }); // ─── stopServer ────────────────────────────────────────────────────────── @@ -260,33 +333,23 @@ describe("stopServer", () => { expect(() => stopServer()).not.toThrow(); }); - it("calls close() on the managed server when one exists", async () => { - const closeMock = vi.fn(); - createOpencodeServerMock.mockResolvedValueOnce({ - url: "http://127.0.0.1:4096", - close: closeMock, - }); - mockFetchHealthy(); - + it("kills the managed process when one exists", async () => { await startServer("http://127.0.0.1:4096", 5000); stopServer(); - - expect(closeMock).toHaveBeenCalledOnce(); + expect(spawned[0].proc.kill).toHaveBeenCalledOnce(); }); it("is idempotent (subsequent calls are no-ops)", async () => { - const closeMock = vi.fn(); - createOpencodeServerMock.mockResolvedValueOnce({ - url: "http://127.0.0.1:4096", - close: closeMock, - }); - mockFetchHealthy(); - await startServer("http://127.0.0.1:4096", 5000); stopServer(); stopServer(); + expect(spawned[0].proc.kill).toHaveBeenCalledOnce(); + }); - expect(closeMock).toHaveBeenCalledOnce(); + it("tolerates a process that already exited", async () => { + await startServer("http://127.0.0.1:4096", 5000); + spawned[0].proc.kill.mockImplementation(() => { throw new Error("ESRCH"); }); + expect(() => stopServer()).not.toThrow(); }); }); @@ -294,35 +357,38 @@ describe("stopServer", () => { describe("registerShutdownHandlers", () => { it("registers listeners for SIGINT, SIGTERM, SIGHUP and STDIN events", () => { - resetShutdownRegisteredForTests(); - const processOnSpy = vi.spyOn(process, "on"); - const stdinOnSpy = vi.spyOn(process.stdin, "on"); - registerShutdownHandlers(); - expect(processOnSpy).toHaveBeenCalledWith("exit", expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith("SIGTERM", expect.any(Function)); - expect(processOnSpy).toHaveBeenCalledWith("SIGHUP", expect.any(Function)); - expect(stdinOnSpy).toHaveBeenCalledWith("end", expect.any(Function)); - expect(stdinOnSpy).toHaveBeenCalledWith("close", expect.any(Function)); + expect(process.on).toHaveBeenCalledWith("exit", expect.any(Function)); + expect(process.on).toHaveBeenCalledWith("SIGINT", expect.any(Function)); + expect(process.on).toHaveBeenCalledWith("SIGTERM", expect.any(Function)); + expect(process.on).toHaveBeenCalledWith("SIGHUP", expect.any(Function)); + expect(process.stdin.on).toHaveBeenCalledWith("end", expect.any(Function)); + expect(process.stdin.on).toHaveBeenCalledWith("close", expect.any(Function)); }); }); describe("shutdown behavior", () => { - it.each(["SIGINT", "SIGTERM", "SIGHUP", "end", "close"])("%s closes the owned server and exits", async (event) => { - const close = vi.fn(); - createOpencodeServerMock.mockResolvedValueOnce({ url: "http://127.0.0.1:4096", close }); - mockFetchHealthy(); + it.each(["SIGINT", "SIGTERM", "SIGHUP", "end", "close"])("%s kills the owned server and exits", async (event) => { await startServer("http://127.0.0.1:4096"); const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); const source = event === "end" || event === "close" ? process.stdin : process; const handler = vi.mocked(source.on).mock.calls.find(([name]) => name === event)![1] as () => void; handler(); stopServer(); - expect(close).toHaveBeenCalledOnce(); + expect(spawned[0].proc.kill).toHaveBeenCalledOnce(); expect(exit).toHaveBeenCalledWith(0); }); + + it("exit cleanup kills the owned server without calling process.exit", async () => { + await startServer("http://127.0.0.1:4096"); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const handler = vi.mocked(process.on).mock.calls.find(([name]) => name === "exit")![1] as () => void; + handler(); + expect(spawned[0].proc.kill).toHaveBeenCalledOnce(); + expect(exit).not.toHaveBeenCalled(); + }); + it("registers shutdown handlers only once", () => { registerShutdownHandlers(); registerShutdownHandlers(); @@ -334,159 +400,117 @@ describe("shutdown behavior", () => { describe("ensureServer", () => { it("returns immediately when server is already running", async () => { - mockFetchHealthy("1.14.46"); + route = () => json(serverInfo("2.1.2")); const result = await ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true }); - expect(result).toEqual({ - running: true, - version: "1.14.46", - managedByUs: false, - url: "http://127.0.0.1:4096", - }); - expect(createOpencodeServerMock).not.toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("already running"), - ); + expect(result).toEqual({ running: true, version: "2.1.2", managedByUs: false, url: "http://127.0.0.1:4096" }); + expect(spawnMock).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("already running")); }); - it("starts the server when not running and autoServe is explicitly true", async () => { - mockFetchDown(); // initial probe fails - createOpencodeServerMock.mockResolvedValueOnce({ - url: "http://127.0.0.1:4096", - close: vi.fn(), - }); - mockFetchHealthy("1.14.46"); // post-start probe + it("starts the child with the configured username and probes with it", async () => { + vi.stubEnv("OPENCODE_SERVER_USERNAME", "inherited"); + await ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true, username: "alice" }); + expect(spawned[0].options.env.OPENCODE_SERVER_USERNAME).toBe("alice"); + const probe = fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/info")).at(-1)!; + expect((probe[1] as RequestInit).headers).toMatchObject({ Authorization: basic("alice", spawned[0].password) }); + }); + it("starts the server when not running and autoServe is explicitly true, returning its password", async () => { const result = await ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true }); + expect(spawnMock).toHaveBeenCalledOnce(); expect(result).toEqual({ running: true, - version: "1.14.46", + version: "2.1.2", managedByUs: true, url: "http://127.0.0.1:4096", + password: spawned[0].password, }); - expect(createOpencodeServerMock).toHaveBeenCalledOnce(); }); it("throws when autoServe is false and server is not running", async () => { - mockFetchDown(); + route = () => refused(); + await expect(ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: false })).rejects.toThrow("OPENCODE_AUTO_SERVE=false"); + expect(spawnMock).not.toHaveBeenCalled(); + }); - await expect( - ensureServer({ - baseUrl: "http://127.0.0.1:4096", - autoServe: false, - }), - ).rejects.toThrow("OPENCODE_AUTO_SERVE=false"); - expect(createOpencodeServerMock).not.toHaveBeenCalled(); + it("reports a credential mismatch instead of spawning a second server", async () => { + route = () => unauthorized(); + await expect(ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true, password: "stale" })) + .rejects.toThrow("rejected the configured credentials (HTTP 401)"); + expect(spawnMock).not.toHaveBeenCalled(); }); - it("propagates startServer errors", async () => { - mockFetchDown(); - createOpencodeServerMock.mockRejectedValueOnce(new Error("EADDRINUSE")); + it("refuses a legacy 1.x server instead of spawning over it", async () => { + route = (url) => url.endsWith("/api/info") ? new Response("", { status: 404 }) : legacyHealth(); + await expect(ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true })) + .rejects.toThrow("OpenCode 1.x server detected"); + expect(spawnMock).not.toHaveBeenCalled(); + }); - await expect( - ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true }), - ).rejects.toThrow("EADDRINUSE"); + it("propagates startServer errors", async () => { + fakeOpencode({ lines: ["Error: listen EADDRINUSE: address already in use"], listen: false }); + await expect(ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true })).rejects.toThrow("EADDRINUSE"); }); it("forwards Basic auth credentials to the health probe", async () => { - mockFetchHealthy("1.14.46"); - - await ensureServer({ - baseUrl: "http://127.0.0.1:4096", - username: "admin", - password: "secret123", - }); - - const init = fetchMock.mock.calls[0][1] as RequestInit; - const headers = (init.headers ?? {}) as Record; - expect(headers.Authorization).toBe("Basic YWRtaW46c2VjcmV0MTIz"); + route = () => json(serverInfo()); + await ensureServer({ baseUrl: "http://127.0.0.1:4096", username: "admin", password: "secret123" }); + expect(headersOf(0).Authorization).toBe("Basic YWRtaW46c2VjcmV0MTIz"); }); - it("serializes concurrent startups onto one createOpencodeServer call", async () => { - // Both initial probes report unhealthy → both callers reach the - // startServer branch. Without the in-flight lock, this would race - // `createOpencodeServer` twice (EADDRINUSE / leaked handle). - mockFetchDown(); - mockFetchDown(); - - // Single createOpencodeServer resolution shared by both callers. - let resolveStart: (value: { url: string; close(): void }) => void; - const startPromise = new Promise<{ url: string; close(): void }>((r) => { - resolveStart = r; - }); - createOpencodeServerMock.mockReturnValueOnce(startPromise); - - // Post-start health probe for both callers. - mockFetchHealthy("1.14.46"); - mockFetchHealthy("1.14.46"); - + it("serializes concurrent startups onto one spawn", async () => { + // Both initial probes observe a dead port. Without the in-flight lock + // both callers would spawn (EADDRINUSE / leaked child). const [a, b] = await Promise.all([ - (async () => { - const p = ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true }); - // Resolve after both callers have queued, so both observe the - // in-flight promise rather than racing into a second start. - resolveStart({ url: "http://127.0.0.1:4096", close: vi.fn() }); - return p; - })(), ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true }), + ensureServer({ baseUrl: "http://127.0.0.1:4096/", autoServe: true }), ]); - expect(createOpencodeServerMock).toHaveBeenCalledOnce(); + expect(spawnMock).toHaveBeenCalledOnce(); expect(a.running).toBe(true); expect(b.running).toBe(true); + expect(a.password).toBe(spawned[0].password); + expect(b.password).toBe(spawned[0].password); }); it("does NOT coalesce concurrent startups across different baseUrls", async () => { - // Two concurrent callers targeting different URLs must each invoke - // their own `createOpencodeServer`. Before the per-baseUrl keying, - // the second caller would await the first caller's in-flight promise - // and receive the wrong endpoint. - // - // The mock returns whichever URL was passed in, so each caller gets - // back its own startup result regardless of which one races to call - // the mock first. - mockFetchDown(); - mockFetchDown(); - - createOpencodeServerMock.mockImplementation( - async (opts: { hostname: string; port: number }) => ({ - url: `http://${opts.hostname}:${opts.port}`, - close: vi.fn(), - }), - ); - - // Post-start health probes for both. - mockFetchHealthy("1.14.46"); - mockFetchHealthy("1.14.46"); - const [a, b] = await Promise.all([ ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true }), ensureServer({ baseUrl: "http://127.0.0.1:5000", autoServe: true }), ]); - expect(createOpencodeServerMock).toHaveBeenCalledTimes(2); + expect(spawnMock).toHaveBeenCalledTimes(2); expect(a.url).toBe("http://127.0.0.1:4096"); expect(b.url).toBe("http://127.0.0.1:5000"); + expect(a.password).not.toBe(b.password); }); -}); + it("allows a new startup after a failed one", async () => { + fakeOpencode({ exitCode: 1 }); + await expect(ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true })).rejects.toThrow("exited before listening"); + fakeOpencode(); + await expect(ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true })).resolves.toMatchObject({ managedByUs: true }); + expect(spawnMock).toHaveBeenCalledTimes(2); + }); +}); describe("Issue #18: explicit server ownership", () => { it("does not spawn a second server by default", async () => { - mockFetchDown(); + route = () => refused(); await expect(ensureServer({ baseUrl: "http://127.0.0.1:4096" })).rejects.toThrow("OPENCODE_AUTO_SERVE=false"); - expect(createOpencodeServerMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("attaches to an existing TUI server without opting into startup", async () => { - mockFetchHealthy(); + route = () => json(serverInfo()); expect((await ensureServer({ baseUrl: "http://127.0.0.1:4096" })).managedByUs).toBe(false); - expect(createOpencodeServerMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it.each(["http://remote.example:4096", "https://localhost:4096", "http://localhost:4096/proxy"])("never tries to spawn for %s", async (baseUrl) => { - mockFetchDown(); + route = () => refused(); await expect(ensureServer({ baseUrl, autoServe: true })).rejects.toThrow("local loopback HTTP URL"); - expect(createOpencodeServerMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/smoke-runner.test.ts b/tests/smoke-runner.test.ts index 51d2506..51a760b 100644 --- a/tests/smoke-runner.test.ts +++ b/tests/smoke-runner.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { FIXTURE_MARKER, runSmoke, smokeEnvironment, smokeOptions } from "../scripts/mcp-smoke-test.mjs"; +import { stat } from "node:fs/promises"; +import { join } from "node:path"; +import { createFixture, FIXTURE_MARKER, runSmoke, smokeEnvironment, smokeOptions } from "../scripts/mcp-smoke-test.mjs"; const result = (text = "fixture response", structuredContent?: Record) => ({ content: [{ type: "text", text }], ...(structuredContent ? { structuredContent } : {}), @@ -124,6 +126,19 @@ describe("isolated smoke runner", () => { }); }); +describe("disposable smoke fixture", () => { + it("creates a git-backed fixture directory and removes it on cleanup", async () => { + const fixture = await createFixture(); + try { + expect(await stat(join(fixture.directory, "README.md"))).toBeTruthy(); + expect(await stat(join(fixture.directory, "opencode.json"))).toBeTruthy(); + } finally { + await fixture.cleanup(); + } + await expect(stat(fixture.directory)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + describe("smoke options", () => { it("requires explicit inference opt-in even when model defaults exist", () => { expect(smokeOptions([], { OPENCODE_TEST_PROVIDER: "fixture", OPENCODE_TEST_MODEL: "model" }).inference).toBe(false); diff --git a/tests/stdio-integration.test.ts b/tests/stdio-integration.test.ts index c3657d0..5269793 100644 --- a/tests/stdio-integration.test.ts +++ b/tests/stdio-integration.test.ts @@ -19,14 +19,20 @@ describe("MCP process lifecycle", () => { }); it.each([true, false])("exits on stdin EOF with healthy server = %s", async (healthy) => { - http = createServer((_req, res) => { + const authorization = "Basic " + Buffer.from("opencode:fixture-password").toString("base64"); + http = createServer((req, res) => { res.setHeader("content-type", "application/json"); - res.end(JSON.stringify({ healthy, version: "fixture" })); + if (req.headers.authorization !== authorization) { res.statusCode = 401; res.end('{"_tag":"UnauthorizedError"}'); return; } + if (new URL(req.url!, "http://fixture").pathname !== "/api/info") { res.statusCode = 404; res.end("{}"); return; } + // OpenCode 2.x ServerInfo; an unhealthy server answers with a 5xx. + if (!healthy) { res.statusCode = 503; res.end('{"_tag":"Unavailable"}'); return; } + res.end(JSON.stringify({ version: "2.1.2", pid: process.pid, urls: [], paths: { tmp: "/tmp" } })); }); await new Promise((resolve) => http!.listen(0, "127.0.0.1", resolve)); const baseUrl = `http://127.0.0.1:${(http.address() as { port: number }).port}`; child = spawn(process.execPath, [fileURLToPath(new URL("../dist/index.js", import.meta.url))], { - env: { ...process.env, OPENCODE_BASE_URL: baseUrl, OPENCODE_AUTO_SERVE: "false" }, + env: { ...process.env, OPENCODE_BASE_URL: baseUrl, OPENCODE_AUTO_SERVE: "false", + OPENCODE_SERVER_USERNAME: "opencode", OPENCODE_SERVER_PASSWORD: "fixture-password" }, stdio: ["pipe", "pipe", "pipe"], }); const exited = once(child, "exit"); @@ -34,7 +40,7 @@ describe("MCP process lifecycle", () => { await new Promise((resolve, reject) => { child!.stderr!.on("data", (chunk) => { log += chunk; - if (log.includes("opencode-mcp v3.1.0 started (")) resolve(); + if (log.includes("opencode-mcp v4.0.0 started (")) resolve(); }); child!.once("error", reject); child!.once("exit", () => reject(new Error(`Premature exit: ${log}`))); @@ -43,6 +49,7 @@ describe("MCP process lifecycle", () => { const [code] = await exited; expect(code).toBe(0); // Disconnect must leave externally owned servers alive. - expect((await fetch(baseUrl + "/global/health")).ok).toBe(true); + const probe = await fetch(baseUrl + "/api/info", { headers: { authorization } }); + expect(probe.status).toBe(healthy ? 200 : 503); }, 5000); }); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 80b0ba2..b3b2267 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -13,7 +13,7 @@ import { registerProviderTools } from "../src/tools/provider.js"; // ─── Mock client factory ───────────────────────────────────────────────── function createMockClient(overrides: Record = {}) { - return { + const client: Record = { get: vi.fn().mockResolvedValue({}), post: vi.fn().mockResolvedValue({}), patch: vi.fn().mockResolvedValue({}), @@ -21,8 +21,17 @@ function createMockClient(overrides: Record = {}) { delete: vi.fn().mockResolvedValue(undefined), subscribeSSE: vi.fn(), getBaseUrl: vi.fn().mockReturnValue("http://localhost:4096"), - ...overrides, - } as unknown as OpenCodeClient; + }; + // The paged message reader goes through getWithCursor; delegate to get so + // per-path stubs keep serving both entry points. + client.getWithCursor = vi.fn(async (path: string, ...args: unknown[]) => ({ data: await (client.get as any)(path, ...args) })); + Object.assign(client, overrides); + if (!("getWithCursor" in overrides)) { + // Overriding get must keep the delegation honest. + const overridden = client.get as any; + (client.getWithCursor as any).mockImplementation(async (path: string, ...args: unknown[]) => ({ data: await overridden(path, ...args) })); + } + return client as unknown as OpenCodeClient; } // ─── Tool registration capture ─────────────────────────────────────────── @@ -45,6 +54,157 @@ function captureTools(registerFn: (server: McpServer, client: OpenCodeClient) => return { tools, server: mockServer, client: mockClient }; } +/** Register tools against a given client and return name → handler. */ +function handlersFor( + registerFn: (server: McpServer, client: OpenCodeClient) => void, + client: OpenCodeClient, +) { + const tools = new Map(); + const mockServer = { + tool: vi.fn((...args: unknown[]) => { + tools.set(args[0] as string, args[args.length - 1] as Function); + }), + } as unknown as McpServer; + registerFn(mockServer, client); + return tools; +} + +// ─── OpenCode 2.x fixtures ─────────────────────────────────────────────── +// Shapes follow the OpenCode 2.1.2 OpenAPI spec (components/schemas). The +// real client unwraps `{ data, location? }` envelopes, so the mocked client +// returns the inner payloads. + +const T0 = 1_792_670_525_000; + +/** Session.Message.User */ +function userMessage(id: string, text = "prompt", created = T0) { + return { id, time: { created }, type: "user", text }; +} + +/** Session.Message.Assistant */ +function assistantMessage( + id: string, + content: unknown[], + created = T0 + 100, + extra: Record = {}, +) { + return { + id, + time: { created, completed: created + 50 }, + type: "assistant", + agent: "build", + model: { id: "claude-sonnet-4", providerID: "anthropic" }, + content, + finish: "stop", + ...extra, + }; +} + +/** Session.Message.Idle — closes a turn. */ +function idleMessage( + id: string, + outcome: "succeeded" | "failed" | "interrupted" = "succeeded", + created = T0 + 200, + error?: { type: string; message: string }, +) { + return { id, time: { created }, type: "idle", outcome, ...(error ? { error } : {}) }; +} + +/** Session.Inbox.User — the response of POST /api/session/{id}/prompt. */ +function promptAck(id: string, sessionID: string, text = "prompt") { + return { id, sessionID, time: { created: T0 }, type: "user", payload: { text }, delivery: "queue" }; +} + +/** Session.Info */ +function sessionInfo(id: string, extra: Record = {}) { + return { + id, + projectID: "prj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: T0, updated: T0 }, + location: { directory: "/home/user/project" }, + ...extra, + }; +} + +/** Provider.Info */ +function providerInfo(id: string, extra: Record = {}) { + return { + id, + integrationID: id, + name: id.charAt(0).toUpperCase() + id.slice(1), + activation: "auto", + package: "@opencode/ai/providers/openai-compatible", + ...extra, + }; +} + +/** Credential.Entry */ +function credentialEntry(integrationID: string, active = true, value: Record = { type: "key", key: "test-key" }) { + return { id: `cred_${integrationID}`, integrationID, label: "API key", active, value }; +} + +/** Model.Info (trimmed to the fields the tools read). */ +function modelInfo(providerID: string, id: string, extra: Record = {}) { + return { id, modelID: id, providerID, name: id, status: "active", enabled: true, variants: [], ...extra }; +} + +const textPart = (text: string) => ({ type: "text", text }); + +/** GET /api/session/{id}/message returns messages newest-first. */ +const newestFirst = (messages: unknown[]) => [...messages].reverse(); + +/** + * GET mock routed by exact path. Unknown paths reject so a tool reading the + * wrong endpoint fails loudly (tools that tolerate failures `.catch()` it). + */ +function routes(table: Record) { + return vi.fn(async (path: string) => { + if (Object.prototype.hasOwnProperty.call(table, path)) { + const value = table[path]; + if (value instanceof Error) throw value; + return value; + } + throw new Error(`unexpected GET ${path}`); + }); +} + +/** + * Client for the v2 prompt flow used by ask/reply/provider_test/message_send: + * POST /api/session → POST /prompt (queued user message) → POST wait → + * GET /message (newest-first). + */ +function promptFlowClient(opts: { + sessionId?: string; + ack?: unknown; + turn?: unknown[]; + get?: (path: string) => unknown; + delete?: ReturnType; +} = {}) { + const sessionId = opts.sessionId ?? "ses_1"; + const ack = opts.ack === undefined ? promptAck("msg_user", sessionId) : opts.ack; + const turn = opts.turn ?? [ + userMessage("msg_user"), + assistantMessage("msg_asst", [textPart("Here is the answer")]), + idleMessage("msg_idle"), + ]; + return createMockClient({ + post: vi.fn(async (path: string) => { + if (path === "/api/session") return sessionInfo(sessionId); + if (path.endsWith("/prompt")) return ack; + return undefined; // wait, model switch + }), + get: vi.fn(async (path: string) => { + const custom = opts.get?.(path); + if (custom !== undefined) return custom; + if (path.endsWith("/message")) return newestFirst(turn); + throw new Error(`unexpected GET ${path}`); + }), + ...(opts.delete ? { delete: opts.delete } : {}), + }); +} + // ─── Tool registration tests ───────────────────────────────────────────── describe("Tool registration", () => { @@ -91,37 +251,48 @@ describe("Tool registration", () => { }); describe("registerSessionTools", () => { - it("registers 20 session tools", () => { + it("registers 16 session tools", () => { const { tools } = captureTools(registerSessionTools); - expect(tools.size).toBe(20); + expect(tools.size).toBe(16); expect(tools.has("opencode_session_list")).toBe(true); expect(tools.has("opencode_session_create")).toBe(true); expect(tools.has("opencode_session_delete")).toBe(true); expect(tools.has("opencode_session_diff")).toBe(true); expect(tools.has("opencode_session_fork")).toBe(true); + expect(tools.has("opencode_session_abort")).toBe(true); expect(tools.has("opencode_permission_list")).toBe(true); expect(tools.has("opencode_session_permission")).toBe(true); + // OpenCode 2.x has no share/unshare/init routes. + expect(tools.has("opencode_session_share")).toBe(false); + expect(tools.has("opencode_session_unshare")).toBe(false); + expect(tools.has("opencode_session_init")).toBe(false); }); }); describe("registerMessageTools", () => { - it("does not expose unsupported variant parameter on shell execution", () => { + it("does not expose model or variant parameters on shell execution", () => { + // v2 POST /api/session/{id}/shell accepts only { id?, command }. const { tools } = captureTools(registerMessageTools); const shell = tools.get("opencode_shell_execute")!; const schema = shell.schema as Record; - expect(schema).toHaveProperty("providerID"); - expect(schema).toHaveProperty("modelID"); + expect(schema).toHaveProperty("command"); + expect(schema).not.toHaveProperty("providerID"); + expect(schema).not.toHaveProperty("modelID"); expect(schema).not.toHaveProperty("variant"); }); }); describe("registerFileTools", () => { - it("registers 6 file tools", () => { + it("registers 5 file tools", () => { const { tools } = captureTools(registerFileTools); - expect(tools.size).toBe(6); + expect(tools.size).toBe(5); expect(tools.has("opencode_find_text")).toBe(true); expect(tools.has("opencode_find_file")).toBe(true); + expect(tools.has("opencode_file_list")).toBe(true); expect(tools.has("opencode_file_read")).toBe(true); + expect(tools.has("opencode_file_status")).toBe(true); + // OpenCode 2.x has no symbol search route. + expect(tools.has("opencode_find_symbol")).toBe(false); }); }); @@ -155,7 +326,7 @@ describe("Tool handlers", () => { describe("opencode_health", () => { it("returns health data from client", async () => { const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue({ version: "1.0.0", status: "healthy" }), + get: vi.fn().mockResolvedValue({ version: "2.1.2", pid: 42, urls: ["http://127.0.0.1:4096"], paths: { tmp: "/tmp" } }), }); const tools = new Map(); const mockServer = { @@ -168,7 +339,8 @@ describe("Tool handlers", () => { const handler = tools.get("opencode_health")!; const result = await handler({}); expect(result.content[0].text).toContain("healthy"); - expect(result.content[0].text).toContain("1.0.0"); + expect(result.content[0].text).toContain("2.1.2"); + expect(result.content[0].text).toContain("http://127.0.0.1:4096"); }); it("returns error on failure", async () => { @@ -195,29 +367,21 @@ describe("Tool handlers", () => { let handler: Function; beforeEach(() => { - mockClient = createMockClient({ - post: vi.fn() - .mockResolvedValueOnce({ id: "session-1" }) // create session - .mockResolvedValueOnce({ // send message - info: { id: "msg-1", role: "assistant" }, - parts: [{ type: "text", text: "Here is the answer" }], - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - handler = tools.get("opencode_ask")!; + mockClient = promptFlowClient(); + handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_ask")!; }); - it("creates session and sends message", async () => { + it("creates session, sends prompt, waits, and returns the answer", async () => { const result = await handler({ prompt: "What is this project?" }); - expect(result.content[0].text).toContain("session-1"); + expect(result.content[0].text).toContain("ses_1"); expect(result.content[0].text).toContain("Here is the answer"); - expect((mockClient.post as ReturnType)).toHaveBeenCalledTimes(2); + const paths = (mockClient.post as ReturnType).mock.calls.map((c: unknown[]) => c[0]); + expect(paths).toEqual([ + "/api/session", + "/api/session/ses_1/prompt", + "/api/experimental/session/ses_1/wait", + ]); + expect(mockClient.get).toHaveBeenCalledWith("/api/session/ses_1/message", { limit: "100" }, undefined, {}); }); it("uses prompt as title when none provided", async () => { @@ -232,33 +396,38 @@ describe("Tool handlers", () => { expect(body.title).toBe("My Title"); }); - it("includes model when providerID and modelID are set", async () => { + it("binds the model to the new session when providerID and modelID are set", async () => { await handler({ prompt: "test", providerID: "anthropic", modelID: "claude-3", }); - const [, body] = (mockClient.post as ReturnType).mock.calls[1]; - expect(body.model).toEqual({ providerID: "anthropic", modelID: "claude-3" }); + const calls = (mockClient.post as ReturnType).mock.calls; + expect(calls[0][1].model).toEqual({ id: "claude-3", providerID: "anthropic" }); + // v2 prompts do not carry a model. + expect(calls[1][1]).not.toHaveProperty("model"); }); - it("sends variant as a top-level prompt field", async () => { + it("sends variant inside the session model ref", async () => { await handler({ prompt: "test", providerID: "openai", modelID: "gpt-5.4", variant: "xhigh", }); - const [, body] = (mockClient.post as ReturnType).mock.calls[1]; - expect(body.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" }); - expect(body.model).not.toHaveProperty("variant"); - expect(body.variant).toBe("xhigh"); + const calls = (mockClient.post as ReturnType).mock.calls; + expect(calls[0][1].model).toEqual({ id: "gpt-5.4", providerID: "openai", variant: "xhigh" }); + expect(calls[1][1]).not.toHaveProperty("variant"); + expect(calls[1][1]).not.toHaveProperty("model"); }); it("includes agent when set", async () => { await handler({ prompt: "test", agent: "build" }); - const [, body] = (mockClient.post as ReturnType).mock.calls[1]; - expect(body.agent).toBe("build"); + const calls = (mockClient.post as ReturnType).mock.calls; + // v2: the agent rides on session creation; prompts never carry it. + expect(calls[0][0]).toBe("/api/session"); + expect(calls[0][1].agent).toBe("build"); + expect(calls[1]).toEqual(["/api/session/ses_1/prompt", { text: "test", delivery: "queue" }, expect.anything()]); }); it("returns error on failure", async () => { @@ -279,103 +448,141 @@ describe("Tool handlers", () => { }); it("warns when response is empty (auth issue)", async () => { - const emptyClient = createMockClient({ - post: vi.fn() - .mockResolvedValueOnce({ id: "session-2" }) // create session - .mockResolvedValueOnce(null), // empty response from provider - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, emptyClient); - const askHandler = tools.get("opencode_ask")!; + // The turn produced neither an assistant message nor an idle marker. + const emptyClient = promptFlowClient({ sessionId: "ses_2", turn: [userMessage("msg_user")] }); + const askHandler = handlersFor(registerWorkflowTools, emptyClient).get("opencode_ask")!; const result = await askHandler({ prompt: "test" }); expect(result.content[0].text).toContain("WARNING"); expect(result.content[0].text).toContain("empty response"); }); it("warns when response has no text content", async () => { - const noTextClient = createMockClient({ - post: vi.fn() - .mockResolvedValueOnce({ id: "session-3" }) - .mockResolvedValueOnce({ parts: [] }), // empty parts + const noTextClient = promptFlowClient({ + sessionId: "ses_3", + turn: [userMessage("msg_user"), assistantMessage("msg_asst", []), idleMessage("msg_idle")], }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, noTextClient); - const askHandler = tools.get("opencode_ask")!; + const askHandler = handlersFor(registerWorkflowTools, noTextClient).get("opencode_ask")!; const result = await askHandler({ prompt: "test" }); expect(result.content[0].text).toContain("WARNING"); expect(result.content[0].text).toContain("no text content"); }); + it("flags a failed run from the idle marker", async () => { + const failedClient = promptFlowClient({ + turn: [ + userMessage("msg_user"), + idleMessage("msg_idle", "failed", T0 + 200, { type: "ProviderAuthError", message: "Invalid API key" }), + ], + }); + const askHandler = handlersFor(registerWorkflowTools, failedClient).get("opencode_ask")!; + const result = await askHandler({ prompt: "test" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("WARNING"); + expect(result.content[0].text).toContain("Invalid API key"); + }); + + it("marks a timed-out ask as a partial answer instead of a final one", async () => { + vi.useFakeTimers(); + try { + const mockClient = promptFlowClient({ + sessionId: "s1", + // The turn produced an assistant message but never its idle marker. + turn: [userMessage("msg_user"), assistantMessage("msg_asst", [textPart("Half an answer")])], + }); + const post = mockClient.post as ReturnType; + const original = post.getMockImplementation()!; + post.mockImplementation((path: string, body?: unknown, opts?: { signal?: AbortSignal }) => { + // The real transport rejects in-flight requests when the deadline + // aborts; emulate that so the stalled wait is bounded. + if (path.endsWith("/wait")) { + return new Promise((_resolve, reject) => { + const signal = opts?.signal; + if (signal?.aborted) return reject(signal.reason ?? new Error("aborted")); + signal?.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), { once: true }); + }); + } + return original(path, body, opts); + }); + const askHandler = handlersFor(registerWorkflowTools, mockClient).get("opencode_ask")!; + const pending = askHandler({ prompt: "test" }); + await vi.advanceTimersByTimeAsync(600_000); + const result = await pending; + expect(result.isError).toBe(true); + expect(result.structuredContent.timedOut).toBe(true); + expect(result.content[0].text).toContain("TIMEOUT"); + expect(result.content[0].text).toContain("partial answer"); + // The partial state stays visible with a resumable handle. + expect(result.content[0].text).toContain("Half an answer"); + expect(result.content[0].text).toContain('opencode_wait({sessionId: "s1"})'); + } finally { + vi.useRealTimers(); + } + }); + + it("surfaces wait endpoint failures instead of returning partial answers", async () => { + const mockClient = promptFlowClient({ sessionId: "s1" }); + const post = mockClient.post as ReturnType; + const original = post.getMockImplementation()!; + post.mockImplementation(async (path: string, ...args: unknown[]) => { + if (path.endsWith("/wait")) throw new Error("wait endpoint failed"); + return original(path, ...args); + }); + const askHandler = handlersFor(registerWorkflowTools, mockClient).get("opencode_ask")!; + const result = await askHandler({ prompt: "test" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("wait endpoint failed"); + }); + + it("ignores messages from earlier turns", async () => { + const client = promptFlowClient({ + turn: [ + userMessage("msg_old", "old", T0 - 1000), + assistantMessage("msg_old_asst", [textPart("Old answer")], T0 - 900), + idleMessage("msg_old_idle", "succeeded", T0 - 800), + userMessage("msg_user"), + assistantMessage("msg_asst", [textPart("New answer")]), + idleMessage("msg_idle"), + ], + }); + const askHandler = handlersFor(registerWorkflowTools, client).get("opencode_ask")!; + const result = await askHandler({ prompt: "test" }); + expect(result.content[0].text).toContain("New answer"); + expect(result.content[0].text).not.toContain("Old answer"); + }); + it("does not warn for valid response", async () => { - // already tested above, but explicitly verify no WARNING const result = await handler({ prompt: "What is this project?" }); expect(result.content[0].text).not.toContain("WARNING"); + expect(result.isError).toBeFalsy(); }); }); describe("opencode_reply", () => { it("warns when reply response is empty", async () => { - const emptyClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce(null), // empty response - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, emptyClient); - const handler = tools.get("opencode_reply")!; + const emptyClient = promptFlowClient({ ack: null, turn: [] }); + const handler = handlersFor(registerWorkflowTools, emptyClient).get("opencode_reply")!; const result = await handler({ sessionId: "s1", prompt: "follow up" }); expect(result.content[0].text).toContain("WARNING"); expect(result.content[0].text).toContain("empty response"); }); it("does not warn for valid reply", async () => { - const goodClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce({ - info: { id: "m2", role: "assistant" }, - parts: [{ type: "text", text: "Sure, here you go" }], - }), + const goodClient = promptFlowClient({ + sessionId: "s1", + turn: [userMessage("msg_user"), assistantMessage("m2", [textPart("Sure, here you go")]), idleMessage("msg_idle")], }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, goodClient); - const handler = tools.get("opencode_reply")!; + const handler = handlersFor(registerWorkflowTools, goodClient).get("opencode_reply")!; const result = await handler({ sessionId: "s1", prompt: "follow up" }); expect(result.content[0].text).toContain("Sure, here you go"); expect(result.content[0].text).not.toContain("WARNING"); + // Replies must not create a session. + const paths = (goodClient.post as ReturnType).mock.calls.map((c: unknown[]) => c[0]); + expect(paths).not.toContain("/api/session"); }); - it("sends variant as a top-level field", async () => { - const mockClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce({ - info: { id: "m2", role: "assistant" }, - parts: [{ type: "text", text: "Sure" }], - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - const handler = tools.get("opencode_reply")!; + it("switches the session model (with variant) before prompting", async () => { + const mockClient = promptFlowClient({ sessionId: "s1" }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_reply")!; await handler({ sessionId: "s1", prompt: "follow up", @@ -383,224 +590,192 @@ describe("Tool handlers", () => { modelID: "gpt-5.4", variant: "low", }); - const [, body] = (mockClient.post as ReturnType).mock.calls[0]; - expect(body.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" }); - expect(body.model).not.toHaveProperty("variant"); - expect(body.variant).toBe("low"); + const calls = (mockClient.post as ReturnType).mock.calls; + expect(calls[0][0]).toBe("/api/session/s1/model"); + expect(calls[0][1]).toEqual({ model: { id: "gpt-5.4", providerID: "openai", variant: "low" } }); + expect(calls[1][0]).toBe("/api/session/s1/prompt"); + expect(calls[1][1]).toEqual({ text: "follow up", delivery: "queue" }); + }); + + it("applies a variant to the session's current model when no model is given", async () => { + const mockClient = promptFlowClient({ + sessionId: "s1", + get: (path) => path === "/api/session/s1" + ? sessionInfo("s1", { model: { id: "gpt-5.4", providerID: "openai" } }) + : undefined, + }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_reply")!; + await handler({ sessionId: "s1", prompt: "follow up", variant: "high" }); + expect(mockClient.post).toHaveBeenCalledWith( + "/api/session/s1/model", + { model: { id: "gpt-5.4", providerID: "openai", variant: "high" } }, + { directory: undefined }, + ); }); }); describe("opencode_context", () => { it("fetches all context data in parallel", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/project/current") return Promise.resolve({ name: "my-project" }); - if (path === "/path") return Promise.resolve({ cwd: "/home/dev" }); - if (path === "/vcs") return Promise.resolve({ branch: "main" }); - if (path === "/config") return Promise.resolve({ theme: "dark" }); - if (path === "/agent") return Promise.resolve([{ name: "build", description: "Build agent", mode: "auto" }]); - return Promise.resolve({}); + get: routes({ + "/api/location": { + directory: "/home/dev/my-project", + project: { id: "prj_abc123", directory: "/home/dev/my-project", canonical: "/home/dev/my-project" }, + }, + "/api/vcs": { provider: "git", branch: { current: "main", default: "main" } }, + "/api/config": [{ type: "document", path: "/home/dev/my-project/opencode.json", info: { theme: "dark" } }], + "/api/agent": [ + { id: "build", name: "build", description: "Build agent", mode: "primary", hidden: false, request: {}, permissions: [] }, + { id: "title", name: "title", description: "Title generator", mode: "subagent", hidden: true, request: {}, permissions: [] }, + ], }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_context")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_context")!; const result = await handler({}); - expect(result.content[0].text).toContain("my-project"); - expect(result.content[0].text).toContain("/home/dev"); - expect(result.content[0].text).toContain("main"); - expect(result.content[0].text).toContain("build"); + const text = result.content[0].text; + expect(text).toContain("prj_abc123"); + expect(text).toContain("/home/dev/my-project"); + expect(text).toContain("Branch: main"); + expect(text).toContain("opencode.json"); + expect(text).toContain("Agents (1)"); + expect(text).toContain("build: Build agent [primary]"); + expect(text).not.toContain("Title generator"); }); it("handles partial failures gracefully", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/project/current") return Promise.resolve({ name: "proj" }); - return Promise.reject(new Error("not available")); + get: routes({ + "/api/location": { directory: "/srv/proj", project: { id: "prj_1", directory: "/srv/proj", canonical: "/srv/proj" } }, }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_context")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_context")!; const result = await handler({}); // Should not error out — partial results are ok expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain("proj"); + expect(result.content[0].text).toContain("/srv/proj"); + expect(result.content[0].text).not.toContain("## VCS"); }); }); describe("opencode_setup", () => { - it("shows 'Ready to use' for providers with source=env and context-dependent next steps", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/global/health") return Promise.resolve({ version: "0.2.0" }); - if (path === "/provider") return Promise.resolve({ all: [ - { id: "huggingface", name: "Hugging Face", source: "env", env: ["HF_TOKEN"], models: { "meta-llama": { id: "meta-llama" } } }, - // openai and anthropic: source=custom with empty options = NOT configured (listed under Quick setup) - { id: "openai", name: "OpenAI", source: "custom", options: {}, env: ["OPENAI_API_KEY"], models: {} }, - { id: "anthropic", name: "Anthropic", source: "custom", options: {}, env: ["ANTHROPIC_API_KEY"], models: { "claude-sonnet-4-20250514": { id: "claude-sonnet-4-20250514" } } }, - ]}); - if (path === "/provider/auth") return Promise.resolve({ - openai: [{ type: "api", label: "API Key" }, { type: "oauth", label: "OAuth" }], - anthropic: [{ type: "api", label: "API Key" }], - }); - if (path === "/project/current") return Promise.resolve({ name: "my-app", worktree: "/home/user/my-app", vcs: "git" }); - return Promise.resolve({}); - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_setup")!; + it("shows 'Ready to use' for providers with an active credential and context-dependent next steps", async () => { + const mockClient = createMockClient({ + get: routes({ + "/api/info": { version: "2.1.2", pid: 1, urls: [], paths: { tmp: "/tmp" } }, + "/api/provider": [ + providerInfo("huggingface", { name: "Hugging Face" }), + providerInfo("openai", { name: "OpenAI" }), + providerInfo("anthropic", { name: "Anthropic" }), + ], + "/api/credential": [credentialEntry("huggingface")], + "/api/integration": [ + { + id: "openai", name: "OpenAI", connections: [], + methods: [{ type: "key" }, { type: "env", names: ["OPENAI_API_KEY"] }, { id: "chatgpt-browser", type: "oauth", label: "Sign in with ChatGPT" }], + }, + { id: "anthropic", name: "Anthropic", connections: [], methods: [{ type: "key" }, { type: "env", names: ["ANTHROPIC_API_KEY"] }] }, + ], + "/api/model": [modelInfo("huggingface", "meta-llama"), modelInfo("anthropic", "claude-sonnet-4")], + "/api/location": { directory: "/home/user/my-app", project: { id: "my-app", directory: "/home/user/my-app", canonical: "/home/user/my-app" } }, + }), + }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_setup")!; const result = await handler({}); const text = result.content[0].text; expect(text).toContain("healthy"); - expect(text).toContain("0.2.0"); + expect(text).toContain("2.1.2"); expect(text).toContain("Ready to use"); - expect(text).toContain("huggingface"); - expect(text).toContain("HF_TOKEN"); + expect(text).toContain("huggingface (Hugging Face): 1 models"); expect(text).toContain("Quick setup options"); - expect(text).toContain("anthropic"); - expect(text).toContain("openai"); + expect(text).toContain("Sign in with ChatGPT"); + expect(text).toContain("OPENAI_API_KEY"); + expect(text).toContain("ANTHROPIC_API_KEY"); expect(text).toContain("my-app"); // Has a ready provider (huggingface), so next steps should say "ready to go" expect(text).toContain("ready to go"); expect(text).toContain("Next Steps"); }); - it("shows 'No providers configured' and setup guidance when none have any source", async () => { + it("shows 'No providers configured' and setup guidance when none have credentials", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/global/health") return Promise.resolve({ version: "0.2.0" }); - if (path === "/provider") return Promise.resolve({ all: [ - // No source field = not configured - { id: "google", name: "Google", models: { "gemini-2.5-flash": { id: "gemini-2.5-flash" } } }, - ]}); - if (path === "/provider/auth") return Promise.resolve({}); - if (path === "/project/current") return Promise.resolve({ name: "proj" }); - return Promise.resolve({}); + get: routes({ + "/api/info": { version: "2.1.2" }, + "/api/provider": [providerInfo("google", { name: "Google" })], + "/api/credential": [], + "/api/integration": [], + "/api/model": [modelInfo("google", "gemini-2.5-flash")], + "/api/location": { directory: "/srv/proj", project: { id: "proj", directory: "/srv/proj", canonical: "/srv/proj" } }, }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_setup")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_setup")!; const result = await handler({}); const text = result.content[0].text; - expect(text).toContain("google"); expect(text).toContain("No providers configured yet"); + expect(text).toContain("+1 more providers available"); // Next steps should guide to configure a provider expect(text).toContain("You need to configure a provider first"); expect(text).toContain("opencode_auth_set"); }); - it("detects source=custom with OAuth headers as configured — anthropic heuristic (N1 fix)", async () => { + it("detects an active OAuth credential as configured", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/global/health") return Promise.resolve({ version: "0.2.0" }); - if (path === "/provider") return Promise.resolve({ all: [ - // anthropic with OAuth: source=custom, empty apiKey, but has headers - { id: "anthropic", name: "Anthropic", source: "custom", options: { apiKey: "", headers: { "anthropic-beta": "test" } }, models: { "claude-3": { id: "claude-3" } } }, - ]}); - if (path === "/provider/auth") return Promise.resolve({}); - if (path === "/project/current") return Promise.resolve({ name: "proj" }); - return Promise.resolve({}); + get: routes({ + "/api/info": { version: "2.1.2" }, + "/api/provider": [providerInfo("anthropic", { name: "Anthropic" })], + "/api/credential": [credentialEntry("anthropic", true, { type: "oauth", methodID: "claude-pro", refresh: "r", access: "a", expires: 0 })], + "/api/integration": [], + "/api/model": [modelInfo("anthropic", "claude-3")], + "/api/location": { directory: "/srv/proj", project: { id: "proj", directory: "/srv/proj", canonical: "/srv/proj" } }, }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_setup")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_setup")!; const result = await handler({}); const text = result.content[0].text; - // anthropic with OAuth headers should be detected as configured expect(text).toContain("Ready to use"); - expect(text).toContain("anthropic"); + expect(text).toContain("anthropic (Anthropic): 1 models"); expect(text).toContain("ready to go"); }); - it("does NOT detect source=custom with empty options as configured", async () => { + it("does NOT treat an inactive credential or empty apiKey as configured", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/global/health") return Promise.resolve({ version: "0.2.0" }); - if (path === "/provider") return Promise.resolve({ all: [ - // Generic provider with source=custom but no real credentials — NOT configured - { id: "groq", name: "Groq", source: "custom", options: {}, models: { "llama-3": { id: "llama-3" } } }, - ]}); - if (path === "/provider/auth") return Promise.resolve({}); - if (path === "/project/current") return Promise.resolve({ name: "proj" }); - return Promise.resolve({}); + get: routes({ + "/api/info": { version: "2.1.2" }, + "/api/provider": [ + providerInfo("groq", { name: "Groq", settings: { apiKey: "" } }), + providerInfo("mistral", { name: "Mistral" }), + ], + "/api/credential": [credentialEntry("mistral", false)], + "/api/integration": [], + "/api/model": [], + "/api/location": { directory: "/srv/proj", project: { id: "proj", directory: "/srv/proj", canonical: "/srv/proj" } }, }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_setup")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_setup")!; const result = await handler({}); const text = result.content[0].text; - // source=custom with empty options = NOT configured expect(text).toContain("No providers configured yet"); expect(text).toContain("You need to configure a provider first"); }); - it("handles providers returned as { all: [...] } object format", async () => { + it("treats a provider with settings.apiKey as configured", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/global/health") return Promise.resolve({ version: "0.2.0" }); - if (path === "/provider") return Promise.resolve({ all: [ - { id: "anthropic", name: "Anthropic", source: "custom", options: { apiKey: "", headers: { "anthropic-beta": "test" } }, models: { "claude-3": { id: "claude-3" } } }, - ]}); - if (path === "/provider/auth") return Promise.resolve({}); - if (path === "/project/current") return Promise.resolve({ name: "proj" }); - return Promise.resolve({}); + get: routes({ + "/api/info": { version: "2.1.2" }, + "/api/provider": [providerInfo("opencode", { name: "OpenCode", activation: "enabled", settings: { apiKey: "public" } })], + "/api/credential": [], + "/api/integration": [], + "/api/model": [modelInfo("opencode", "big-pickle")], + "/api/location": new Error("no location"), }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_setup")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_setup")!; const result = await handler({}); const text = result.content[0].text; - // Should parse { all: [...] } format correctly expect(text).toContain("1 available"); - expect(text).toContain("anthropic"); + expect(text).toContain("opencode (OpenCode): 1 models"); + expect(text).toContain("No project context available"); + // No project → suggest passing a directory + expect(text).toContain("Pass a `directory` parameter"); }); it("reports unreachable server", async () => { @@ -628,35 +803,30 @@ describe("Tool handlers", () => { describe("opencode_provider_list (compact)", () => { it("returns compact provider summary without models", async () => { const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([ - { id: "anthropic", source: "env", models: [{ id: "claude-3" }, { id: "claude-4" }] }, - { id: "opencode", source: "custom", options: { apiKey: "public" }, models: [{ id: "gpt-4" }] }, - { id: "google", source: "config", models: [] }, - { id: "groq", source: "custom", options: {}, models: [] }, // source=custom with empty options = NOT configured - ]), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); + get: routes({ + "/api/provider": [ + providerInfo("anthropic"), + providerInfo("opencode", { activation: "enabled", settings: { apiKey: "public" } }), + providerInfo("google"), + providerInfo("groq"), // no credential, no apiKey = NOT configured + ], + "/api/credential": [credentialEntry("anthropic"), credentialEntry("google")], }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_list")!; + }); + const handler = handlersFor(registerProviderTools, mockClient).get("opencode_provider_list")!; const result = await handler({}); const text = result.content[0].text; // Should be compact with configured first — no model IDs dumped expect(text).toContain("**Configured:**"); - expect(text).toContain("anthropic: 2 models"); - expect(text).toContain("opencode: 1 model"); // source=custom with apiKey="public" IS configured - expect(text).toContain("google: 0 models"); - expect(text).toContain("3 configured"); + expect(text).toContain("- anthropic [auto]"); + expect(text).toContain("- opencode [enabled]"); // settings.apiKey="public" IS configured + expect(text).toContain("- google [auto]"); + expect(text).toContain("3 configured / 4 total"); expect(text).toContain("**Not configured:**"); + expect(text).toContain("- groq"); expect(text).toContain("opencode_provider_models"); - // Must NOT contain raw model IDs - expect(text).not.toContain("claude-3"); - expect(text).not.toContain("gpt-4"); + // Must NOT fetch or dump models + expect(mockClient.get).not.toHaveBeenCalledWith("/api/model", undefined, undefined); }); it("returns message when no providers", async () => { @@ -678,298 +848,115 @@ describe("Tool handlers", () => { }); describe("opencode_provider_models", () => { - const providerData = [ - { - id: "anthropic", - source: "env", - models: [ - { id: "claude-3", name: "Claude 3" }, - { id: "claude-4", name: "Claude 4" }, - ], - }, - { id: "openai", source: "config", models: [{ id: "gpt-4" }] }, + const providers = [providerInfo("anthropic"), providerInfo("openai")]; + const models = [ + modelInfo("anthropic", "claude-3", { name: "Claude 3" }), + modelInfo("anthropic", "claude-4", { name: "Claude 4" }), + modelInfo("openai", "gpt-4"), ]; - it("lists models for a configured provider", async () => { + it("lists models for a provider", async () => { const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue(providerData), + get: routes({ "/api/model": models, "/api/provider": providers }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_models")!; + const handler = handlersFor(registerProviderTools, mockClient).get("opencode_provider_models")!; const result = await handler({ providerId: "anthropic" }); const text = result.content[0].text; - expect(text).toContain("anthropic"); - expect(text).toContain("configured"); - expect(text).toContain("claude-3"); - expect(text).toContain("Claude 3"); - expect(text).toContain("claude-4"); + expect(text).toContain("## anthropic — 2 models"); + expect(text).toContain("claude-3 — Claude 3"); + expect(text).toContain("claude-4 — Claude 4"); // Should NOT contain other provider models expect(text).not.toContain("gpt-4"); }); it("returns error for unknown provider", async () => { const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue(providerData), + get: routes({ "/api/model": models, "/api/provider": providers }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_models")!; + const handler = handlersFor(registerProviderTools, mockClient).get("opencode_provider_models")!; const result = await handler({ providerId: "nonexistent" }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("not found"); expect(result.content[0].text).toContain("anthropic"); }); - it("shows configured for source=custom with apiKey (N4 fix)", async () => { + it("truncates long model lists to the limit", async () => { const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([ - { id: "opencode", source: "custom", options: { apiKey: "public" }, models: [{ id: "model-1" }] }, - ]), + get: routes({ "/api/model": models, "/api/provider": providers }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); + const handler = handlersFor(registerProviderTools, mockClient).get("opencode_provider_models")!; + const result = await handler({ providerId: "anthropic", limit: 1 }); + const text = result.content[0].text; + expect(text).toContain("claude-3"); + expect(text).not.toContain("claude-4"); + expect(text).toContain("... and 1 more"); + }); + + it("reports a known provider without models", async () => { + const mockClient = createMockClient({ + get: routes({ "/api/model": [], "/api/provider": providers }), + }); + const handler = handlersFor(registerProviderTools, mockClient).get("opencode_provider_models")!; + const result = await handler({ providerId: "openai" }); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('Provider "openai" has no models available.'); + }); + }); + + describe("opencode_file_list", () => { + it("omits path when not provided (server defaults to the location directory)", async () => { + const getMock = vi.fn().mockResolvedValue([ + { path: "src", type: "directory" }, + { path: "package.json", type: "file" }, + ]); + const mockClient = createMockClient({ get: getMock }); + const handler = handlersFor(registerFileTools, mockClient).get("opencode_file_list")!; + const result = await handler({}); + expect(getMock).toHaveBeenCalledWith("/api/fs/list", {}, undefined); + expect(result.content[0].text).toContain("[DIR] src"); + expect(result.content[0].text).toContain("package.json"); + }); + + it("uses provided path", async () => { + const getMock = vi.fn().mockResolvedValue([]); + const mockClient = createMockClient({ get: getMock }); + const handler = handlersFor(registerFileTools, mockClient).get("opencode_file_list")!; + const result = await handler({ path: "src" }); + expect(getMock).toHaveBeenCalledWith("/api/fs/list", { path: "src" }, undefined); + expect(result.content[0].text).toBe("Empty directory."); + }); + }); - const handler = tools.get("opencode_provider_models")!; - const result = await handler({ providerId: "opencode" }); + describe("opencode_find_text", () => { + it("formats FileSystem.Entry results without [object Object]", async () => { + const getMock = vi.fn().mockResolvedValue([ + { path: "src/index.ts", type: "file" }, + { path: "src/tools", type: "directory" }, + ]); + const mockClient = createMockClient({ get: getMock }); + const handler = handlersFor(registerFileTools, mockClient).get("opencode_find_text")!; + const result = await handler({ pattern: "src" }); const text = result.content[0].text; - // source=custom with apiKey="public" IS configured - expect(text).toContain("configured"); - expect(text).not.toContain("NOT CONFIGURED"); + expect(getMock).toHaveBeenCalledWith("/api/fs/find", { query: "src" }, undefined); + expect(text).not.toContain("[object Object]"); + expect(text).toContain("2 match(es)"); + expect(text).toContain("src/index.ts"); + expect(text).toContain("[DIR] src/tools"); + }); + + it("returns friendly message for empty results", async () => { + const mockClient = createMockClient({ get: vi.fn().mockResolvedValue([]) }); + const handler = handlersFor(registerFileTools, mockClient).get("opencode_find_text")!; + const result = await handler({ pattern: "nothing" }); + expect(result.content[0].text).toBe("No matches found for pattern: nothing"); + expect(result.content[0].text).not.toContain("[]"); }); + }); - it("shows NOT CONFIGURED for source=custom with empty options", async () => { + describe("opencode_session_status (N3 fix)", () => { + it("returns 'All sessions idle' for empty status object", async () => { const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([ - { id: "groq", source: "custom", options: {}, models: [{ id: "llama-3" }] }, - ]), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_models")!; - const result = await handler({ providerId: "groq" }); - const text = result.content[0].text; - expect(text).toContain("NOT CONFIGURED"); - }); - - it("shows configured for anthropic with OAuth headers heuristic", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([ - { id: "anthropic", source: "custom", options: { apiKey: "", headers: { "anthropic-beta": "test" } }, models: [{ id: "claude-3" }] }, - ]), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_models")!; - const result = await handler({ providerId: "anthropic" }); - const text = result.content[0].text; - // anthropic with OAuth headers heuristic = configured - expect(text).toContain("configured"); - expect(text).not.toContain("NOT CONFIGURED"); - }); - - it("shows NOT CONFIGURED status for provider with no source", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([ - { id: "groq", models: [{ id: "llama-3" }] }, - ]), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_models")!; - const result = await handler({ providerId: "groq" }); - const text = result.content[0].text; - expect(text).toContain("NOT CONFIGURED"); - }); - - it("detects source=config as configured", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([ - { id: "google", source: "config", models: { "gemini-2.5-flash": { id: "gemini-2.5-flash" } } }, - ]), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_models")!; - const result = await handler({ providerId: "google" }); - const text = result.content[0].text; - expect(text).toContain("configured"); - expect(text).not.toContain("NOT CONFIGURED"); - expect(text).toContain("gemini-2.5-flash"); - }); - }); - - describe("opencode_file_list", () => { - it("defaults path to '.' when not provided", async () => { - const getMock = vi.fn().mockResolvedValue([ - { name: "src", type: "directory" }, - { name: "package.json", type: "file" }, - ]); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerFileTools(mockServer, mockClient); - - const handler = tools.get("opencode_file_list")!; - await handler({}); - // Should pass path="." even when path is not provided - expect(getMock).toHaveBeenCalledWith("/file", { path: "." }, undefined); - }); - - it("uses provided path", async () => { - const getMock = vi.fn().mockResolvedValue([]); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerFileTools(mockServer, mockClient); - - const handler = tools.get("opencode_file_list")!; - await handler({ path: "src" }); - expect(getMock).toHaveBeenCalledWith("/file", { path: "src" }, undefined); - }); - }); - - describe("opencode_find_text", () => { - it("handles path as object without [object Object]", async () => { - const getMock = vi.fn().mockResolvedValue([ - { path: { path: "src/index.ts", name: "index.ts" }, line_number: 10, lines: "const x = 1;" }, - ]); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerFileTools(mockServer, mockClient); - - const handler = tools.get("opencode_find_text")!; - const result = await handler({ pattern: "const x" }); - const text = result.content[0].text; - expect(text).not.toContain("[object Object]"); - expect(text).toContain("src/index.ts"); - expect(text).toContain(":10"); - }); - - it("handles path as string normally", async () => { - const getMock = vi.fn().mockResolvedValue([ - { path: "src/main.ts", line_number: 5, lines: "import { foo } from './bar';" }, - ]); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerFileTools(mockServer, mockClient); - - const handler = tools.get("opencode_find_text")!; - const result = await handler({ pattern: "import" }); - const text = result.content[0].text; - expect(text).toContain("src/main.ts:5"); - }); - }); - - describe("opencode_find_symbol (N2 fix)", () => { - it("returns friendly message for empty results instead of raw []", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([]), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerFileTools(mockServer, mockClient); - - const handler = tools.get("opencode_find_symbol")!; - const result = await handler({ query: "nonexistent" }); - const text = result.content[0].text; - expect(text).toContain("No symbols found matching: nonexistent"); - expect(text).not.toContain("[]"); - }); - - it("formats symbol results as a list", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([ - { name: "MyClass", kind: "class", location: "src/main.ts", line: 10 }, - { name: "myFunction", kind: "function", path: "src/utils.ts", lineNumber: 25 }, - ]), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerFileTools(mockServer, mockClient); - - const handler = tools.get("opencode_find_symbol")!; - const result = await handler({ query: "my" }); - const text = result.content[0].text; - expect(text).toContain("2 symbol(s)"); - expect(text).toContain("MyClass (class)"); - expect(text).toContain("src/main.ts:10"); - expect(text).toContain("myFunction (function)"); - expect(text).toContain("src/utils.ts:25"); - }); - }); - - describe("opencode_session_status (N3 fix)", () => { - it("returns 'All sessions idle' for empty status object", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue({}), + get: vi.fn().mockResolvedValue({}), }); const tools = new Map(); const mockServer = { @@ -986,47 +973,36 @@ describe("Tool handlers", () => { expect(text).not.toContain("{}"); }); - it("formats non-empty status as a list", async () => { + it("formats running sessions and last outcomes as a list", async () => { const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue({ "session-1": "running", "session-2": "idle" }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); + get: routes({ + "/api/session/active": { "ses_1": { type: "running" } }, + "/api/session": [ + sessionInfo("ses_1", { title: "Building" }), + sessionInfo("ses_2", { title: "Done task", outcome: "succeeded" }), + sessionInfo("ses_3"), // never ran: no outcome, not listed + ], }), - } as unknown as McpServer; - registerSessionTools(mockServer, mockClient); - - const handler = tools.get("opencode_session_status")!; + }); + const handler = handlersFor(registerSessionTools, mockClient).get("opencode_session_status")!; const result = await handler({}); const text = result.content[0].text; - expect(text).toContain("Session Status (2)"); - expect(text).toContain("session-1: running"); - expect(text).toContain("session-2: idle"); + expect(text).toContain("## Session Status"); + expect(text).toContain("ses_1: running (Building)"); + expect(text).toContain("ses_2: succeeded (Done task)"); + expect(text).not.toContain("ses_3"); + expect(result.structuredContent.data.running).toEqual(["ses_1"]); }); }); describe("opencode_provider_models error (N6 fix)", () => { it("truncates provider list in error message to first 10", async () => { // Create 20 providers - const providers = Array.from({ length: 20 }, (_, i) => ({ - id: `provider-${i}`, - source: "env", - models: [], - })); + const providers = Array.from({ length: 20 }, (_, i) => providerInfo(`provider-${i}`)); const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue(providers), + get: routes({ "/api/model": [], "/api/provider": providers }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_models")!; + const handler = handlersFor(registerProviderTools, mockClient).get("opencode_provider_models")!; const result = await handler({ providerId: "nonexistent" }); const text = result.content[0].text; expect(text).toContain("not found"); @@ -1038,68 +1014,64 @@ describe("Tool handlers", () => { }); }); - describe("opencode_provider_list (N5 fix — source detection)", () => { - it("detects source=api as configured and source=custom with credentials", async () => { + describe("opencode_provider_list (N5 fix — credential detection)", () => { + it("matches credentials by integrationID and ignores inactive ones", async () => { const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([ - // anthropic with OAuth headers = configured (heuristic) - { id: "anthropic", source: "custom", options: { apiKey: "", headers: { "anthropic-beta": "test" } }, models: [{ id: "claude-3" }] }, - { id: "zai-coding", source: "api", models: [] }, - { id: "groq", source: "custom", options: {}, models: [] }, // source=custom, empty options = NOT configured - ]), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); + get: routes({ + "/api/provider": [ + providerInfo("anthropic"), + // provider ID differs from its integration ID + providerInfo("zai-coding", { integrationID: "zai" }), + providerInfo("groq"), + ], + "/api/credential": [ + credentialEntry("anthropic", true, { type: "oauth", methodID: "claude-pro", refresh: "r", access: "a", expires: 0 }), + credentialEntry("zai"), + credentialEntry("groq", false), // inactive = NOT configured + ], }), - } as unknown as McpServer; - registerProviderTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_list")!; + }); + const handler = handlersFor(registerProviderTools, mockClient).get("opencode_provider_list")!; const result = await handler({}); const text = result.content[0].text; - expect(text).toContain("2 configured"); + expect(text).toContain("2 configured / 3 total"); expect(text).toContain("**Configured:**"); - expect(text).toContain("anthropic: 1 model"); - expect(text).toContain("zai-coding: 0 models"); + expect(text).toContain("- anthropic"); + expect(text).toContain("- zai-coding"); + expect(text).toContain("**Not configured:** 1 more providers available"); }); }); describe("opencode_config_providers (compact)", () => { it("returns compact summary instead of full model specs", async () => { - // API returns { providers: [...], default: { providerId: modelId } } - const getMock = vi.fn().mockResolvedValue({ - providers: [ - { id: "anthropic", name: "Anthropic", models: { "claude-3": {}, "claude-4": {} } }, - { id: "openai", name: "OpenAI", models: { "gpt-4": {} } }, - ], - default: { anthropic: "claude-3", openai: "gpt-4" }, - }); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); + const mockClient = createMockClient({ + get: routes({ + "/api/provider": [ + providerInfo("anthropic", { name: "Anthropic", activation: "enabled" }), + providerInfo("openai", { name: "OpenAI" }), + ], + "/api/model": [ + modelInfo("anthropic", "claude-3"), + modelInfo("anthropic", "claude-4"), + modelInfo("openai", "gpt-4"), + ], + "/api/model/default": modelInfo("anthropic", "claude-3"), }), - } as unknown as McpServer; - registerConfigTools(mockServer, mockClient); - - const handler = tools.get("opencode_config_providers")!; + }); + const handler = handlersFor(registerConfigTools, mockClient).get("opencode_config_providers")!; const result = await handler({}); const text = result.content[0].text; - expect(text).toContain("anthropic"); - expect(text).toContain("2 models"); - expect(text).toContain("default: claude-3"); - expect(text).toContain("openai"); - expect(text).toContain("1 model"); + expect(text).toContain("Configured Providers (2)"); + expect(text).toContain("anthropic (Anthropic [enabled]): 2 models"); + expect(text).toContain("openai (OpenAI [auto]): 1 model"); + expect(text).toContain("Default model: anthropic/claude-3"); expect(text).toContain("opencode_provider_models"); }); }); describe("directory parameter propagation", () => { it("passes directory to client.get in opencode_health", async () => { - const getMock = vi.fn().mockResolvedValue({ status: "ok" }); + const getMock = vi.fn().mockResolvedValue({ version: "2.1.2" }); const mockClient = createMockClient({ get: getMock }); const tools = new Map(); const mockServer = { @@ -1111,31 +1083,25 @@ describe("Tool handlers", () => { const handler = tools.get("opencode_health")!; await handler({ directory: "/home/user/project-a" }); - expect(getMock).toHaveBeenCalledWith("/global/health", undefined, "/home/user/project-a"); + expect(getMock).toHaveBeenCalledWith("/api/info", undefined, "/home/user/project-a"); }); - it("passes directory to client.post in opencode_ask", async () => { - const postMock = vi.fn() - .mockResolvedValueOnce({ id: "s1" }) - .mockResolvedValueOnce({ info: { id: "m1", role: "assistant" }, parts: [{ type: "text", text: "ok" }] }); - const mockClient = createMockClient({ post: postMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_ask")!; + it("binds the session location and scopes the turn requests in opencode_ask", async () => { + const mockClient = promptFlowClient({ sessionId: "s1" }); + const postMock = mockClient.post as ReturnType; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_ask")!; await handler({ prompt: "hello", directory: "/srv/web-app" }); - // Both calls should include directory - expect(postMock.mock.calls[0][2]).toEqual({ directory: "/srv/web-app" }); + // v2 binds a new session to its location through the request body. + expect(postMock.mock.calls[0][0]).toBe("/api/session"); + expect(postMock.mock.calls[0][1].location).toEqual({ directory: "/srv/web-app" }); + // Prompt and wait are scoped with the x-opencode-directory header. expect(postMock.mock.calls[1][2]).toEqual({ directory: "/srv/web-app" }); + expect(postMock.mock.calls[2][2]).toEqual(expect.objectContaining({ directory: "/srv/web-app" })); + expect(mockClient.getWithCursor).toHaveBeenCalledWith("/api/session/s1/message", { limit: "100" }, "/srv/web-app", {}); }); it("passes undefined directory when not provided", async () => { - const getMock = vi.fn().mockResolvedValue({ status: "ok" }); + const getMock = vi.fn().mockResolvedValue({ version: "2.1.2" }); const mockClient = createMockClient({ get: getMock }); const tools = new Map(); const mockServer = { @@ -1147,84 +1113,76 @@ describe("Tool handlers", () => { const handler = tools.get("opencode_health")!; await handler({}); - expect(getMock).toHaveBeenCalledWith("/global/health", undefined, undefined); + expect(getMock).toHaveBeenCalledWith("/api/info", undefined, undefined); }); }); describe("opencode_message_send (R5-B1 fix)", () => { it("warns when response is empty (null)", async () => { - const mockClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce(null), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerMessageTools(mockServer, mockClient); - - const handler = tools.get("opencode_message_send")!; + const mockClient = promptFlowClient({ sessionId: "s1", ack: null, turn: [] }); + const handler = handlersFor(registerMessageTools, mockClient).get("opencode_message_send")!; const result = await handler({ sessionId: "s1", text: "hello" }); expect(result.content[0].text).toContain("WARNING"); expect(result.content[0].text).toContain("empty response"); }); it("warns when response has no text content", async () => { - const mockClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce({ parts: [] }), + const mockClient = promptFlowClient({ + sessionId: "s1", + turn: [userMessage("msg_user"), assistantMessage("msg_asst", []), idleMessage("msg_idle")], }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerMessageTools(mockServer, mockClient); - - const handler = tools.get("opencode_message_send")!; + const handler = handlersFor(registerMessageTools, mockClient).get("opencode_message_send")!; const result = await handler({ sessionId: "s1", text: "hello" }); expect(result.content[0].text).toContain("WARNING"); expect(result.content[0].text).toContain("no text content"); }); it("does not warn for valid response with text", async () => { - const mockClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce({ - info: { id: "m1", role: "assistant" }, - parts: [{ type: "text", text: "Here is the answer" }], - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerMessageTools(mockServer, mockClient); - - const handler = tools.get("opencode_message_send")!; + const mockClient = promptFlowClient({ sessionId: "s1" }); + const handler = handlersFor(registerMessageTools, mockClient).get("opencode_message_send")!; const result = await handler({ sessionId: "s1", text: "hello" }); expect(result.content[0].text).toContain("Here is the answer"); expect(result.content[0].text).not.toContain("WARNING"); + const paths = (mockClient.post as ReturnType).mock.calls.map((c: unknown[]) => c[0]); + expect(paths).toEqual(["/api/session/s1/prompt", "/api/experimental/session/s1/wait"]); }); - it("sends variant as a top-level field", async () => { - const mockClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce({ - info: { id: "m1", role: "assistant" }, - parts: [{ type: "text", text: "Here is the answer" }], - }), + it("flags a failed run from the idle marker", async () => { + const mockClient = promptFlowClient({ + sessionId: "s1", + turn: [ + userMessage("msg_user"), + idleMessage("msg_idle", "failed", T0 + 200, { type: "ProviderAuthError", message: "Invalid API key" }), + ], }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerMessageTools(mockServer, mockClient); + const handler = handlersFor(registerMessageTools, mockClient).get("opencode_message_send")!; + const result = await handler({ sessionId: "s1", text: "hello" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("The run failed"); + expect(result.content[0].text).toContain("Invalid API key"); + }); + + it("uses the idle marker of the current turn, not an earlier failed turn", async () => { + const mockClient = promptFlowClient({ + sessionId: "s1", + turn: [ + userMessage("msg_old", undefined, T0 - 200), + idleMessage("msg_old_idle", "failed", T0 - 100, { type: "ProviderAuthError", message: "Invalid API key" }), + userMessage("msg_user"), + assistantMessage("msg_asst", [textPart("Here is the answer")]), + idleMessage("msg_idle"), + ], + }); + const handler = handlersFor(registerMessageTools, mockClient).get("opencode_message_send")!; + const result = await handler({ sessionId: "s1", text: "hello" }); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("Here is the answer"); + expect(result.content[0].text).not.toContain("The run failed"); + }); - const handler = tools.get("opencode_message_send")!; + it("switches the session model (with variant) before prompting", async () => { + const mockClient = promptFlowClient({ sessionId: "s1" }); + const handler = handlersFor(registerMessageTools, mockClient).get("opencode_message_send")!; await handler({ sessionId: "s1", text: "hello", @@ -1232,180 +1190,72 @@ describe("Tool handlers", () => { modelID: "gpt-5.4", variant: "xhigh", }); - const [, body] = (mockClient.post as ReturnType).mock.calls[0]; - expect(body.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" }); - expect(body.model).not.toHaveProperty("variant"); - expect(body.variant).toBe("xhigh"); + const calls = (mockClient.post as ReturnType).mock.calls; + expect(calls[0]).toEqual([ + "/api/session/s1/model", + { model: { id: "gpt-5.4", providerID: "openai", variant: "xhigh" } }, + { directory: undefined }, + ]); + expect(calls[1][0]).toBe("/api/session/s1/prompt"); + expect(calls[1][1]).toEqual({ text: "hello", delivery: "queue" }); }); it("returns 'Empty response.' when formatted output is empty string", async () => { - const mockClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce(null), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerMessageTools(mockServer, mockClient); - - const handler = tools.get("opencode_message_send")!; + const mockClient = promptFlowClient({ sessionId: "s1", ack: null, turn: [] }); + const handler = handlersFor(registerMessageTools, mockClient).get("opencode_message_send")!; const result = await handler({ sessionId: "s1", text: "hello" }); // Should have the WARNING but also not be completely empty expect(result.content[0].text.length).toBeGreaterThan(0); }); }); - describe("opencode_session_share (R5-B2 fix)", () => { - it("formats output with share URL instead of raw JSON", async () => { - const mockClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce({ - id: "session-1", - title: "My Session", - shareUrl: "https://opncd.ai/share/abc123", - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerSessionTools(mockServer, mockClient); - - const handler = tools.get("opencode_session_share")!; - const result = await handler({ id: "session-1" }); - const text = result.content[0].text; - expect(text).toContain("Session shared."); - expect(text).toContain("https://opncd.ai/share/abc123"); - expect(text).toContain("session-1"); - // Should NOT be raw JSON with curly braces - expect(text).not.toMatch(/^\{/); - }); - - it("handles share URL in nested share object", async () => { - const mockClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce({ - id: "session-2", - share: { url: "https://opncd.ai/share/xyz789" }, - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerSessionTools(mockServer, mockClient); - - const handler = tools.get("opencode_session_share")!; - const result = await handler({ id: "session-2" }); - expect(result.content[0].text).toContain("https://opncd.ai/share/xyz789"); - }); - - it("shows confirmation even without share URL", async () => { - const mockClient = createMockClient({ - post: vi.fn().mockResolvedValueOnce({ id: "session-3" }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerSessionTools(mockServer, mockClient); - - const handler = tools.get("opencode_session_share")!; - const result = await handler({ id: "session-3" }); - expect(result.content[0].text).toContain("Session shared."); - }); - }); - - describe("opencode_session_unshare (R5-B3 fix)", () => { - it("returns confirmation message instead of raw JSON", async () => { - const mockClient = createMockClient({ - delete: vi.fn().mockResolvedValueOnce(undefined), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerSessionTools(mockServer, mockClient); - - const handler = tools.get("opencode_session_unshare")!; - const result = await handler({ id: "session-1" }); - const text = result.content[0].text; - expect(text).toContain("Session session-1 unshared."); - // Should NOT be raw JSON - expect(text).not.toMatch(/^\{/); - expect(text).not.toContain("undefined"); - }); - }); - - describe("opencode_session_init (R5-B4 fix)", () => { - it("has timeout warning in description", () => { - const { tools } = captureTools(registerSessionTools); - const tool = tools.get("opencode_session_init")!; - expect(tool.description).toContain("long-running"); - }); - }); - describe("opencode_session_summarize (R5-B5 fix)", () => { it("has timeout warning in description", () => { const { tools } = captureTools(registerSessionTools); const tool = tools.get("opencode_session_summarize")!; expect(tool.description).toContain("long-running"); }); + + it("starts compaction via the v2 compact route", async () => { + const mockClient = createMockClient(); + const handler = handlersFor(registerSessionTools, mockClient).get("opencode_session_summarize")!; + const result = await handler({ id: "ses_1" }); + expect(mockClient.post).toHaveBeenCalledWith("/api/session/ses_1/compact", {}, { directory: undefined }); + expect(result.content[0].text).toContain("compaction started"); + }); }); // ─── New features ─────────────────────────────────────────────────── describe("opencode_provider_test", () => { it("reports success when provider returns valid response", async () => { - const postMock = vi.fn() - .mockResolvedValueOnce({ id: "test-session" }) // create session - .mockResolvedValueOnce({ // send message - info: { id: "m1", role: "assistant" }, - parts: [{ type: "text", text: "Hello" }], - }); const deleteMock = vi.fn().mockResolvedValue(undefined); - const mockClient = createMockClient({ post: postMock, delete: deleteMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_test")!; + const mockClient = promptFlowClient({ + sessionId: "test-session", + turn: [userMessage("msg_user"), assistantMessage("msg_asst", [textPart("Hello")]), idleMessage("msg_idle")], + delete: deleteMock, + }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_provider_test")!; const result = await handler({ providerId: "anthropic", modelID: "test-model" }); const text = result.content[0].text; expect(text).toContain("anthropic"); expect(text).toContain("is working"); expect(text).toContain("Hello"); + // The test session is created with the model under test + const [, body] = (mockClient.post as ReturnType).mock.calls[0]; + expect(body.model).toEqual({ id: "test-model", providerID: "anthropic" }); // Should clean up the test session - expect(deleteMock).toHaveBeenCalled(); + expect(deleteMock).toHaveBeenCalledWith("/api/session/test-session", undefined, undefined); }); it("reports failure when provider returns empty response", async () => { - const postMock = vi.fn() - .mockResolvedValueOnce({ id: "test-session" }) - .mockResolvedValueOnce(null); // empty response const deleteMock = vi.fn().mockResolvedValue(undefined); - const mockClient = createMockClient({ post: postMock, delete: deleteMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_provider_test")!; + const mockClient = promptFlowClient({ + sessionId: "test-session", + turn: [userMessage("msg_user")], + delete: deleteMock, + }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_provider_test")!; const result = await handler({ providerId: "badprovider", modelID: "test-model" }); const text = result.content[0].text; expect(text).toContain("FAILED"); @@ -1415,9 +1265,51 @@ describe("Tool handlers", () => { expect(deleteMock).toHaveBeenCalled(); }); + it("reports failure when the run fails", async () => { + const mockClient = promptFlowClient({ + sessionId: "test-session", + turn: [ + userMessage("msg_user"), + idleMessage("msg_idle", "failed", T0 + 200, { type: "ProviderAuthError", message: "Invalid API key" }), + ], + }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_provider_test")!; + const result = await handler({ providerId: "anthropic", modelID: "test-model" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("FAILED"); + expect(result.content[0].text).toContain("Invalid API key"); + }); + + it("discovers the provider's default model when modelID is omitted", async () => { + const mockClient = promptFlowClient({ + sessionId: "test-session", + get: (path) => { + if (path === "/api/model") return [modelInfo("anthropic", "claude-haiku"), modelInfo("anthropic", "claude-sonnet"), modelInfo("openai", "gpt-4")]; + if (path === "/api/model/default") return modelInfo("anthropic", "claude-sonnet"); + return undefined; + }, + }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_provider_test")!; + const result = await handler({ providerId: "anthropic" }); + expect(result.content[0].text).toContain("is working"); + const [, body] = (mockClient.post as ReturnType).mock.calls[0]; + expect(body.model).toEqual({ id: "claude-sonnet", providerID: "anthropic" }); + }); + + it("errors when no model can be discovered for the provider", async () => { + const mockClient = promptFlowClient({ + get: (path) => (path === "/api/model" ? [modelInfo("openai", "gpt-4")] : path === "/api/model/default" ? null : undefined), + }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_provider_test")!; + const result = await handler({ providerId: "anthropic" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('No model discovered for provider "anthropic"'); + expect(mockClient.post).not.toHaveBeenCalled(); + }); + it("cleans up test session even on API error", async () => { const postMock = vi.fn() - .mockResolvedValueOnce({ id: "test-session" }) + .mockResolvedValueOnce(sessionInfo("test-session")) .mockRejectedValueOnce(new Error("API timeout")); const deleteMock = vi.fn().mockResolvedValue(undefined); const mockClient = createMockClient({ post: postMock, delete: deleteMock }); @@ -1434,16 +1326,16 @@ describe("Tool handlers", () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain("API timeout"); // Should attempt cleanup - expect(deleteMock).toHaveBeenCalledWith("/session/test-session", undefined, undefined); + expect(deleteMock).toHaveBeenCalledWith("/api/session/test-session", undefined, undefined); }); }); describe("opencode_session_search", () => { const sessions = [ - { id: "s1", title: "Fix login bug" }, - { id: "s2", title: "Add dark mode feature" }, - { id: "s3", title: "Refactor login component" }, - { id: "s4", title: "Update README" }, + sessionInfo("s1", { title: "Fix login bug" }), + sessionInfo("s2", { title: "Add dark mode feature" }), + sessionInfo("s3", { title: "Refactor login component" }), + sessionInfo("s4", { title: "Update README" }), ]; it("finds sessions matching keyword in title", async () => { @@ -1461,6 +1353,7 @@ describe("Tool handlers", () => { const handler = tools.get("opencode_session_search")!; const result = await handler({ query: "login" }); const text = result.content[0].text; + expect(mockClient.get).toHaveBeenCalledWith("/api/session", undefined, undefined); expect(text).toContain("2/4"); expect(text).toContain("Fix login bug"); expect(text).toContain("Refactor login component"); @@ -1546,44 +1439,34 @@ describe("Tool handlers", () => { // ─── Permission tools ──────────────────────────────────────────────── describe("opencode_permission_list", () => { it("returns pending permission requests with details", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue([ - { - id: "perm_1", - sessionID: "ses_abc", - permission: "bash", - patterns: ["npm test"], - always: ["npm *"], - tool: { name: "bash" }, - }, - { - id: "perm_2", - sessionID: "ses_def", - permission: "edit", - patterns: ["/etc/hosts"], - always: [], - }, - ]), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerSessionTools(mockServer, mockClient); - - const handler = tools.get("opencode_permission_list")!; + // Permission.Request: { id, sessionID, action, resources, save?, message? } + const getMock = vi.fn().mockResolvedValue([ + { + id: "per_1", + sessionID: "ses_abc", + action: "bash", + resources: ["npm test"], + save: ["npm *"], + message: "Run the test suite", + }, + { + id: "per_2", + sessionID: "ses_def", + action: "edit", + resources: ["/etc/hosts", "/etc/resolv.conf"], + }, + ]); + const mockClient = createMockClient({ get: getMock }); + const handler = handlersFor(registerSessionTools, mockClient).get("opencode_permission_list")!; const result = await handler({}); const text = result.content[0].text; + expect(getMock).toHaveBeenCalledWith("/api/permission/request", undefined, undefined); expect(text).toContain("Pending Permission Requests (2)"); - expect(text).toContain("bash"); - expect(text).toContain("perm_1"); - expect(text).toContain("ses_abc"); - expect(text).toContain("npm test"); - expect(text).toContain("npm *"); - expect(text).toContain("edit"); - expect(text).toContain("perm_2"); + expect(text).toContain("**bash** [per_1] (session: ses_abc)"); + expect(text).toContain("Resources: npm test"); + expect(text).toContain("Run the test suite"); + expect(text).toContain("**edit** [per_2] (session: ses_def)"); + expect(text).toContain("Resources: /etc/hosts, /etc/resolv.conf"); expect(result.isError).toBeUndefined(); }); @@ -1606,34 +1489,38 @@ describe("Tool handlers", () => { }); describe("opencode_session_permission", () => { - it("sends reply via new API endpoint", async () => { + it("sends the decision to the session-scoped reply endpoint", async () => { const mockClient = createMockClient({ - post: vi.fn().mockResolvedValue({}), + post: vi.fn().mockResolvedValue(undefined), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerSessionTools(mockServer, mockClient); - - const handler = tools.get("opencode_session_permission")!; + const handler = handlersFor(registerSessionTools, mockClient).get("opencode_session_permission")!; const result = await handler({ id: "ses_abc", - permissionID: "perm_1", + permissionID: "per_1", reply: "always", }); expect(result.content[0].text).toContain("approved"); expect(result.content[0].text).toContain("always"); - // Should call the new endpoint first expect((mockClient as any).post).toHaveBeenCalledWith( - "/permission/perm_1/reply", - { reply: "always" }, + "/api/session/ses_abc/permission/per_1/reply", + { decision: "always" }, { directory: undefined }, ); }); + it("forwards an optional message with the decision", async () => { + const mockClient = createMockClient({ + post: vi.fn().mockResolvedValue(undefined), + }); + const handler = handlersFor(registerSessionTools, mockClient).get("opencode_session_permission")!; + await handler({ id: "ses_abc", permissionID: "per_1", reply: "reject", message: "Not allowed", directory: "/srv/app" }); + expect((mockClient as any).post).toHaveBeenCalledWith( + "/api/session/ses_abc/permission/per_1/reply", + { decision: "reject", message: "Not allowed" }, + { directory: "/srv/app" }, + ); + }); + it("shows rejected for reject reply", async () => { const mockClient = createMockClient({ post: vi.fn().mockResolvedValue({}), @@ -1649,299 +1536,194 @@ describe("Tool handlers", () => { const handler = tools.get("opencode_session_permission")!; const result = await handler({ id: "ses_abc", - permissionID: "perm_1", + permissionID: "per_1", reply: "reject", }); expect(result.content[0].text).toContain("rejected"); }); - it("falls back to deprecated endpoint when new API fails", async () => { - let callCount = 0; + it("returns an error (no legacy fallback) when the reply fails", async () => { const mockClient = createMockClient({ - post: vi.fn().mockImplementation((path: string) => { - callCount++; - if (path.startsWith("/permission/")) { - return Promise.reject(new Error("404 not found")); - } - return Promise.resolve({}); - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerSessionTools(mockServer, mockClient); - - const handler = tools.get("opencode_session_permission")!; - const result = await handler({ - id: "ses_abc", - permissionID: "perm_1", - reply: "once", + post: vi.fn().mockRejectedValue(new OpenCodeError("POST failed (404)", 404, "POST", "/api/session/ses_abc/permission/per_1/reply", "{}")), }); - expect(result.content[0].text).toContain("approved"); - // Should have tried new endpoint first, then deprecated - expect((mockClient as any).post).toHaveBeenCalledTimes(2); + const handler = handlersFor(registerSessionTools, mockClient).get("opencode_session_permission")!; + const result = await handler({ id: "ses_abc", permissionID: "per_1", reply: "once" }); + expect(result.isError).toBe(true); + expect((mockClient as any).post).toHaveBeenCalledTimes(1); }); }); - describe("opencode_sessions_overview (v1.6.0 — resolveSessionStatus)", () => { - it("resolves status objects instead of showing [object Object]", async () => { + describe("opencode_sessions_overview (resolveSessionStatus)", () => { + it("marks sessions in the active map as running and others as idle", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session") return Promise.resolve([ - { id: "s1", title: "Fix bug" }, - { id: "s2", title: "Add feature" }, - ]); - if (path === "/session/status") return Promise.resolve({ - s1: { state: "running" }, - s2: { state: "idle" }, - }); - return Promise.resolve({}); + get: routes({ + "/api/session": [ + sessionInfo("s1", { title: "Fix bug" }), + sessionInfo("s2", { title: "Add feature" }), + ], + // SessionActive values are objects: { type: "running" } + "/api/session/active": { s1: { type: "running" } }, }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_sessions_overview")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_sessions_overview")!; const result = await handler({}); const text = result.content[0].text; + expect(text).toContain("## Sessions (2)"); expect(text).toContain("[running] Fix bug"); expect(text).toContain("[idle] Add feature"); expect(text).not.toContain("[object Object]"); }); - it("handles string status values normally", async () => { + it("falls back to idle when the active endpoint fails", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session") return Promise.resolve([ - { id: "s1", title: "Session A" }, - ]); - if (path === "/session/status") return Promise.resolve({ - s1: "running", - }); - return Promise.resolve({}); + get: routes({ + "/api/session": [sessionInfo("s1", { title: "Session A" })], + "/api/session/active": new Error("not available"), }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_sessions_overview")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_sessions_overview")!; const result = await handler({}); - expect(result.content[0].text).toContain("[running] Session A"); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("[idle] Session A"); }); - it("falls back to idle for null/undefined status", async () => { + it("falls back to idle for sessions missing from the active map", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session") return Promise.resolve([ - { id: "s1", title: "Orphan session" }, - ]); - if (path === "/session/status") return Promise.resolve({}); - return Promise.resolve({}); + get: routes({ + "/api/session": [sessionInfo("s1", { title: "Orphan session" })], + "/api/session/active": {}, }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_sessions_overview")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_sessions_overview")!; const result = await handler({}); expect(result.content[0].text).toContain("[idle] Orphan session"); }); it("returns 'No sessions found.' when empty", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session") return Promise.resolve([]); - if (path === "/session/status") return Promise.resolve({}); - return Promise.resolve({}); - }), + get: routes({ "/api/session": [], "/api/session/active": {} }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_sessions_overview")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_sessions_overview")!; const result = await handler({}); expect(result.content[0].text).toContain("No sessions found."); }); it("shows parent tag for child sessions", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session") return Promise.resolve([ - { id: "child-1", title: "Sub-task", parentID: "parent-1" }, - ]); - if (path === "/session/status") return Promise.resolve({ "child-1": "idle" }); - return Promise.resolve({}); + get: routes({ + "/api/session": [sessionInfo("child-1", { title: "Sub-task", parentID: "parent-1" })], + "/api/session/active": {}, }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_sessions_overview")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_sessions_overview")!; const result = await handler({}); expect(result.content[0].text).toContain("(child of parent-1)"); }); }); - describe("opencode_session_status (v1.6.0 — object status resolution)", () => { - it("resolves object statuses with { state } field", async () => { + describe("opencode_session_status (last outcome)", () => { + it("reports failed and interrupted outcomes for idle sessions", async () => { const mockClient = createMockClient({ - get: vi.fn().mockResolvedValue({ - "session-1": { state: "running" }, - "session-2": { state: "idle" }, + get: routes({ + "/api/session/active": {}, + "/api/session": [ + sessionInfo("session-1", { outcome: "failed" }), + sessionInfo("session-2", { outcome: "interrupted" }), + ], }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerSessionTools(mockServer, mockClient); - - const handler = tools.get("opencode_session_status")!; + const handler = handlersFor(registerSessionTools, mockClient).get("opencode_session_status")!; const result = await handler({}); const text = result.content[0].text; - expect(text).toContain("session-1: running"); - expect(text).toContain("session-2: idle"); + expect(text).toContain("session-1: failed"); + expect(text).toContain("session-2: interrupted"); expect(text).not.toContain("[object Object]"); }); }); - describe("opencode_wait (v1.6.0 — status resolution + timeout message)", () => { - it("detects completion from object status { state: 'idle' }", async () => { - const getMock = vi.fn().mockImplementation((path: string) => { - if (path === "/session/status") return Promise.resolve({ - "s1": { state: "idle" }, - }); - if (path.includes("/message")) return Promise.resolve([ - { info: { id: "m1", role: "assistant", time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Done!" }] }, - ]); - if (path === "/question" || path === "/permission") return Promise.resolve([]); - return Promise.resolve({ id: "s1" }); - }); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); + describe("opencode_wait (status resolution + timeout message)", () => { + /** GET routes for observeSession on session s1. */ + function waitClient(messages: unknown[], active: Record = {}) { + return createMockClient({ + get: routes({ + "/api/session/active": active, + "/api/session/s1": sessionInfo("s1"), + "/api/session/s1/message": newestFirst(messages), + "/api/session/s1/form": [], + "/api/session/s1/permission": [], }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); + }); + } - const handler = tools.get("opencode_wait")!; + it("detects completion from the idle marker closing the turn", async () => { + const mockClient = waitClient([ + userMessage("m0"), + assistantMessage("m1", [textPart("Done!")]), + idleMessage("m2"), + ]); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_wait")!; const result = await handler({ sessionId: "s1", timeoutSeconds: 5, pollIntervalMs: 50 }); const text = result.content[0].text; expect(text).toContain("Session completed"); expect(text).toContain("Done!"); + expect(result.structuredContent.status).toBe("completed"); expect(result.isError).toBeUndefined(); }); - it("detects error from object status { state: 'error' }", async () => { - const getMock = vi.fn().mockImplementation((path: string) => Promise.resolve(path === "/session/status" ? { s1: { state: "error" } } : [])); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_wait")!; + it("detects error from an idle marker with outcome 'failed'", async () => { + const mockClient = waitClient([ + userMessage("m0"), + idleMessage("m1", "failed", T0 + 200, { type: "ProviderAuthError", message: "Invalid API key" }), + ]); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_wait")!; const result = await handler({ sessionId: "s1", timeoutSeconds: 5, pollIntervalMs: 50 }); expect(result.structuredContent.status).toBe("failed"); expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Invalid API key"); }); - it("times out with actionable suggestions", async () => { - const getMock = vi.fn().mockImplementation((path: string) => Promise.resolve(path === "/session/status" ? { s1: { state: "running" } } : [])); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); + it("reports an interrupted turn as cancelled", async () => { + const mockClient = waitClient([userMessage("m0"), idleMessage("m1", "interrupted")]); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_wait")!; + const result = await handler({ sessionId: "s1", timeoutSeconds: 5, pollIntervalMs: 50 }); + expect(result.structuredContent.status).toBe("cancelled"); + }); - const handler = tools.get("opencode_wait")!; + it("times out with actionable suggestions", async () => { + const mockClient = waitClient( + [userMessage("m0"), assistantMessage("m1", [textPart("partial")])], + { s1: { type: "running" } }, + ); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_wait")!; const result = await handler({ sessionId: "s1", timeoutSeconds: 0.05, pollIntervalMs: 10 }); const text = result.content[0].text; expect(text).toContain("Timeout"); expect(text).toContain("opencode_conversation"); expect(text).toContain("opencode_session_abort"); expect(result.isError).toBeUndefined(); + expect(result.structuredContent.status).toBe("running"); expect(result.structuredContent.timedOut).toBe(true); }); - it("completes when string status is 'idle'", async () => { - const getMock = vi.fn().mockImplementation((path: string) => { - if (path === "/session/status") return Promise.resolve({ "s1": "idle" }); - if (path.includes("/message")) return Promise.resolve([ - { info: { id: "m1", role: "assistant", time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Result" }] }, - ]); - if (path === "/question" || path === "/permission") return Promise.resolve([]); - return Promise.resolve({ id: "s1" }); - }); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); + it("returns input_required when a form is pending for the session", async () => { + const mockClient = createMockClient({ + get: routes({ + "/api/session/active": { s1: { type: "running" } }, + "/api/session/s1": sessionInfo("s1"), + "/api/session/s1/message": newestFirst([userMessage("m0")]), + "/api/session/s1/form": [{ id: "frm_1", sessionID: "s1", title: "Pick one", fields: [] }, { id: "frm_2", sessionID: "other", fields: [] }], + "/api/session/s1/permission": [], }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_wait")!; + }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_wait")!; const result = await handler({ sessionId: "s1", timeoutSeconds: 5, pollIntervalMs: 50 }); - expect(result.content[0].text).toContain("Session completed"); + expect(result.structuredContent.status).toBe("input_required"); + expect(result.structuredContent.inputs.map((i: { id: string }) => i.id)).toEqual(["frm_1"]); }); - it("does not claim completion when no completed assistant message exists", async () => { - const getMock = vi.fn().mockImplementation((path: string) => { - if (path === "/session/status") return Promise.resolve({ "s1": "completed" }); - if (path.includes("/message")) return Promise.resolve([]); - if (path === "/question" || path === "/permission") return Promise.resolve([]); - return Promise.resolve({ id: "s1" }); - }); - const mockClient = createMockClient({ get: getMock }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_wait")!; + it("does not claim completion when no idle marker exists", async () => { + const mockClient = waitClient([]); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_wait")!; const result = await handler({ sessionId: "s1", timeoutSeconds: 0.05, pollIntervalMs: 10 }); expect(result.structuredContent.status).not.toBe("completed"); expect(result.structuredContent.timedOut).toBe(true); @@ -1951,119 +1733,135 @@ describe("Tool handlers", () => { describe("opencode_run", () => { it("creates session, sends prompt, and polls until idle", async () => { - let pollCount = 0; + let polls = 0; let submittedMessageId = ""; const mockClient = createMockClient({ - post: vi.fn().mockImplementation((path: string, body: any) => { - if (path === "/session") return Promise.resolve({ id: "ses-run-1" }); - submittedMessageId = body.messageID; - return Promise.resolve({ parts: [{ type: "text", text: "Done building!" }] }); + post: vi.fn().mockImplementation(async (path: string, body: any) => { + if (path === "/api/session") return sessionInfo("ses-run-1"); + if (path === "/api/session/ses-run-1/prompt") { + submittedMessageId = body.id; + return promptAck(body.id, "ses-run-1", body.text); + } + throw new Error(`unexpected POST ${path}`); }), - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session/status") { - pollCount++; + get: vi.fn().mockImplementation(async (path: string) => { + if (path === "/api/session/active") { + polls++; // First poll: running, second poll: idle - return Promise.resolve({ "ses-run-1": pollCount >= 2 ? "idle" : "running" }); + return polls >= 2 ? {} : { "ses-run-1": { type: "running" } }; } - if (path.includes("/message")) return Promise.resolve([{ info: { id: "answer", role: "assistant", parentID: submittedMessageId, time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "All tasks completed." }] }]); - if (path.includes("/todo")) return Promise.resolve([ - { status: "completed", content: "Set up project" }, - { status: "completed", content: "Write tests" }, - ]); - return Promise.resolve([]); + if (path === "/api/session/ses-run-1") return sessionInfo("ses-run-1"); + if (path === "/api/session/ses-run-1/message") { + const turn: unknown[] = [userMessage(submittedMessageId, "Build app")]; + if (polls >= 2) { + turn.push(assistantMessage("msg_answer", [textPart("All tasks completed.")]), idleMessage("msg_idle")); + } + return newestFirst(turn); + } + if (path === "/api/session/ses-run-1/form" || path === "/api/session/ses-run-1/permission") return []; + throw new Error(`unexpected GET ${path}`); }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_run")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_run")!; const result = await handler({ prompt: "Build app", providerID: "anthropic", modelID: "claude-opus-4-6" }); expect(result.content[0].text).toContain("ses-run-1"); expect(result.content[0].text).toContain("completed"); + expect(result.content[0].text).toContain("All tasks completed."); expect(result.structuredContent.status).toBe("completed"); expect(result.structuredContent.messageId).toBe(submittedMessageId); + expect(submittedMessageId).toMatch(/^msg_/); + expect(polls).toBeGreaterThanOrEqual(2); + // v2 binds the model to the session at creation time. + const createCall = (mockClient.post as ReturnType).mock.calls[0]; + expect(createCall[1].model).toEqual({ id: "claude-opus-4-6", providerID: "anthropic" }); }); it("returns error status when session errors", async () => { + let submittedMessageId = ""; const mockClient = createMockClient({ - post: vi.fn().mockImplementation((path: string) => { - if (path === "/session") return Promise.resolve({ id: "ses-err" }); - return Promise.resolve([]); - }), - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session/status") return Promise.resolve({ "ses-err": "error" }); - return Promise.resolve([]); + post: vi.fn().mockImplementation(async (path: string, body: any) => { + if (path === "/api/session") return sessionInfo("ses-err"); + submittedMessageId = body.id; + return promptAck(body.id, "ses-err"); + }), + get: vi.fn().mockImplementation(async (path: string) => { + if (path === "/api/session/active") return {}; + if (path === "/api/session/ses-err") return sessionInfo("ses-err", { outcome: "failed" }); + if (path === "/api/session/ses-err/message") { + return newestFirst([ + userMessage(submittedMessageId), + idleMessage("msg_idle", "failed", T0 + 200, { type: "ProviderAuthError", message: "Invalid API key" }), + ]); + } + return []; }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_run")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_run")!; const result = await handler({ prompt: "Bad task", maxDurationSeconds: 1 }); expect(result.isError).toBe(true); expect(result.structuredContent.status).toBe("failed"); + expect(result.content[0].text).toContain("Invalid API key"); }); it("reuses existing session when sessionId provided", async () => { let submittedMessageId = ""; const mockClient = createMockClient({ - post: vi.fn().mockImplementation(async (_path, body) => { submittedMessageId = body.messageID; return undefined; }), - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session/status") return Promise.resolve({ "existing-ses": "idle" }); - if (path.includes("/message")) return Promise.resolve([{ info: { id: "answer", role: "assistant", parentID: submittedMessageId, time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Done" }] }]); - if (path.includes("/todo")) return Promise.resolve([]); - return Promise.resolve([]); + post: vi.fn().mockImplementation(async (path: string, body: any) => { + if (path.endsWith("/prompt")) submittedMessageId = body.id; + return undefined; + }), + get: vi.fn().mockImplementation(async (path: string) => { + if (path === "/api/session/active") return {}; + if (path === "/api/session/existing-ses") return sessionInfo("existing-ses"); + if (path === "/api/session/existing-ses/message") { + return newestFirst([ + userMessage(submittedMessageId), + assistantMessage("msg_answer", [textPart("Done")]), + idleMessage("msg_idle"), + ]); + } + return []; }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_run")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_run")!; const result = await handler({ prompt: "Continue work", sessionId: "existing-ses" }); - // Should NOT call POST /session to create a new one + // Should NOT call POST /api/session to create a new one const postMock = mockClient.post as ReturnType; - const sessionCreateCalls = postMock.mock.calls.filter((c: unknown[]) => c[0] === "/session"); + const sessionCreateCalls = postMock.mock.calls.filter((c: unknown[]) => c[0] === "/api/session"); expect(sessionCreateCalls.length).toBe(0); + expect(postMock.mock.calls.map((c: unknown[]) => c[0])).toEqual(["/api/session/existing-ses/prompt"]); expect(result.content[0].text).toContain("existing-ses"); + expect(result.structuredContent.status).toBe("completed"); + }); + + it("switches the model of a reused session instead of sending it with the prompt", async () => { + const mockClient = createMockClient({ post: vi.fn().mockResolvedValue(undefined) }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_run")!; + await handler({ prompt: "Continue", sessionId: "existing-ses", providerID: "openai", modelID: "gpt-5.4", variant: "low", maxDurationSeconds: 0 }); + const calls = (mockClient.post as ReturnType).mock.calls; + expect(calls[0][0]).toBe("/api/session/existing-ses/model"); + expect(calls[0][1]).toEqual({ model: { id: "gpt-5.4", providerID: "openai", variant: "low" } }); + expect(calls[1][0]).toBe("/api/session/existing-ses/prompt"); + expect(calls[1][1]).not.toHaveProperty("model"); }); }); describe("opencode_fire", () => { it("creates session and returns immediately with monitoring instructions", async () => { const mockClient = createMockClient({ - post: vi.fn().mockImplementation((path: string) => { - if (path === "/session") return Promise.resolve({ id: "ses-fire-1" }); - return Promise.resolve({}); + post: vi.fn().mockImplementation(async (path: string) => { + if (path === "/api/session") return sessionInfo("ses-fire-1"); + return undefined; }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_fire")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_fire")!; const result = await handler({ prompt: "Build everything", providerID: "anthropic", modelID: "claude-opus-4-6" }); expect(result.content[0].text).toContain("ses-fire-1"); expect(result.content[0].text).toContain("dispatched"); expect(result.content[0].text).toContain("opencode_check"); + expect(result.structuredContent.status).toBe("accepted"); + // Dispatch does not observe the session. + expect(mockClient.get).not.toHaveBeenCalled(); }); it("reuses existing session when sessionId provided", async () => { @@ -2081,66 +1879,50 @@ describe("Tool handlers", () => { const handler = tools.get("opencode_fire")!; const result = await handler({ prompt: "More work", sessionId: "ses-existing" }); const postMock = mockClient.post as ReturnType; - const sessionCreateCalls = postMock.mock.calls.filter((c: unknown[]) => c[0] === "/session"); + const sessionCreateCalls = postMock.mock.calls.filter((c: unknown[]) => c[0] === "/api/session"); expect(sessionCreateCalls.length).toBe(0); expect(result.content[0].text).toContain("ses-existing"); }); }); describe("opencode_check", () => { - it("returns compact progress report with status and todos", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session/status") return Promise.resolve({ "ses-chk": { state: "running" } }); - if (path.includes("/todo")) return Promise.resolve([ - { status: "completed", content: "Setup" }, - { status: "in_progress", content: "Build UI" }, - { status: "pending", content: "Write tests" }, - ]); - if (path.includes("/diff")) return Promise.resolve([{ path: "src/App.tsx" }, { path: "src/index.ts" }]); - if (path === "/session/ses-chk") return Promise.resolve({ title: "Build App", id: "ses-chk", summary: { files: 2 } }); - return Promise.resolve([]); + function checkClient(sessionId: string, title: string, messages: unknown[], active: Record = {}) { + return createMockClient({ + get: routes({ + "/api/session/active": active, + [`/api/session/${sessionId}`]: sessionInfo(sessionId, { title }), + [`/api/session/${sessionId}/message`]: newestFirst(messages), + [`/api/session/${sessionId}/form`]: [], + [`/api/session/${sessionId}/permission`]: [], }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); + } - const handler = tools.get("opencode_check")!; + it("returns compact progress report with status and changed files", async () => { + const mockClient = checkClient("ses-chk", "Build App", [ + userMessage("m0"), + assistantMessage("m1", [textPart("Working on it")], T0 + 100, { + snapshot: { start: "a", end: "b", files: ["src/App.tsx", "src/index.ts"] }, + }), + ], { "ses-chk": { type: "running" } }); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_check")!; const result = await handler({ sessionId: "ses-chk" }); const text = result.content[0].text; expect(text).toContain("Build App"); - expect(text).toContain("running"); - expect(text).toContain("1/3 completed"); - expect(text).toContain("1 in progress"); - expect(text).toContain("Build UI"); // current task + expect(text).toContain("Status: running"); expect(text).toContain("Files changed: 2"); + // Compact mode does not echo the response text or patches + expect(text).not.toContain("Working on it"); + expect(mockClient.get).not.toHaveBeenCalledWith(expect.stringContaining("/diff"), expect.anything(), expect.anything()); }); it("shows completion message when session is idle", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session/status") return Promise.resolve({ "ses-done": "idle" }); - if (path.includes("/todo")) return Promise.resolve([]); - if (path.includes("/diff")) return Promise.resolve([]); - if (path.includes("/message")) return Promise.resolve([{ info: { id: "answer", role: "assistant", time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Done" }] }]); - if (path === "/session/ses-done") return Promise.resolve({ title: "Finished", id: "ses-done" }); - return Promise.resolve([]); - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_check")!; + const mockClient = checkClient("ses-done", "Finished", [ + userMessage("m0"), + assistantMessage("m1", [textPart("Done")]), + idleMessage("m2"), + ]); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_check")!; const result = await handler({ sessionId: "ses-done" }); const text = result.content[0].text; expect(result.structuredContent.status).toBe("completed"); @@ -2149,25 +1931,12 @@ describe("Tool handlers", () => { }); it("includes last message when detailed=true", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/session/status") return Promise.resolve({ "ses-d": "idle" }); - if (path.includes("/todo")) return Promise.resolve([]); - if (path.includes("/message")) return Promise.resolve([{ info: { id: "answer", role: "assistant", time: { created: 1, completed: 2 } }, parts: [{ type: "text", text: "Build complete! All tests pass." }] }]); - if (path.includes("/diff")) return Promise.resolve([]); - if (path === "/session/ses-d") return Promise.resolve({ title: "Detail test", id: "ses-d" }); - return Promise.resolve([]); - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_check")!; + const mockClient = checkClient("ses-d", "Detail test", [ + userMessage("m0"), + assistantMessage("m1", [textPart("Build complete! All tests pass.")]), + idleMessage("m2"), + ]); + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_check")!; const result = await handler({ sessionId: "ses-d", detailed: true }); const text = result.content[0].text; expect(text).toContain("Build complete!"); @@ -2178,56 +1947,36 @@ describe("Tool handlers", () => { describe("opencode_status", () => { it("returns combined dashboard with all sections", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/global/health") return Promise.resolve({ version: "1.1.53" }); - if (path === "/provider") return Promise.resolve({ all: [ - { id: "anthropic", source: "env", models: [] }, - { id: "groq", source: "custom", options: {}, models: [] }, - ]}); - if (path === "/session") return Promise.resolve([{ id: "s1" }, { id: "s2" }, { id: "s3" }]); - if (path === "/vcs") return Promise.resolve({ branch: "main", dirty: false }); - return Promise.resolve({}); + get: routes({ + "/api/info": { version: "2.1.2" }, + "/api/provider": [providerInfo("anthropic"), providerInfo("groq")], + "/api/credential": [credentialEntry("anthropic")], + "/api/session": [sessionInfo("s1"), sessionInfo("s2"), sessionInfo("s3")], + "/api/vcs": { provider: "git", branch: { current: "main", default: "main" } }, }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_status")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_status")!; const result = await handler({}); const text = result.content[0].text; expect(text).toContain("## Status"); expect(text).toContain("healthy"); - expect(text).toContain("1.1.53"); + expect(text).toContain("2.1.2"); expect(text).toContain("1 configured / 2 total"); expect(text).toContain("Sessions: 3"); - expect(text).toContain("Branch: main (clean)"); + expect(text).toContain("Branch: main"); }); it("handles partial failures gracefully", async () => { const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/global/health") return Promise.resolve({ version: "1.0.0" }); - return Promise.reject(new Error("not available")); - }), + get: routes({ "/api/info": { version: "2.1.2" } }), }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_status")!; + const handler = handlersFor(registerWorkflowTools, mockClient).get("opencode_status")!; const result = await handler({}); const text = result.content[0].text; expect(text).toContain("healthy"); // Other sections should just be missing, not cause errors + expect(text).not.toContain("Providers:"); + expect(text).not.toContain("Branch:"); expect(result.isError).toBeUndefined(); }); @@ -2248,27 +1997,6 @@ describe("Tool handlers", () => { const text = result.content[0].text; expect(text).toContain("UNREACHABLE"); }); - - it("shows dirty branch status", async () => { - const mockClient = createMockClient({ - get: vi.fn().mockImplementation((path: string) => { - if (path === "/global/health") return Promise.resolve({ version: "1.0.0" }); - if (path === "/vcs") return Promise.resolve({ branch: "feature-x", dirty: true }); - return Promise.reject(new Error("skip")); - }), - }); - const tools = new Map(); - const mockServer = { - tool: vi.fn((...args: unknown[]) => { - tools.set(args[0] as string, args[args.length - 1] as Function); - }), - } as unknown as McpServer; - registerWorkflowTools(mockServer, mockClient); - - const handler = tools.get("opencode_status")!; - const result = await handler({}); - expect(result.content[0].text).toContain("Branch: feature-x (dirty)"); - }); }); // ─── Directory validation: early fail before .catch(() => null) ────── @@ -2355,8 +2083,8 @@ describe("Issue #20: asynchronous workflow dispatch", () => { it.each(["opencode_fire", "opencode_run"])("%s dispatches without waiting for inference", async (name) => { const { tools, client } = captureTools(registerWorkflowTools); vi.mocked(client.post).mockImplementation(async (path) => { - if (path === "/session") return { id: "async-session" }; - if (path === "/session/async-session/prompt_async") return undefined; + if (path === "/api/session") return sessionInfo("async-session"); + if (path === "/api/session/async-session/prompt") return promptAck("msg_x", "async-session", "Long task"); throw new Error(`Unexpected blocking request: ${path}`); }); const result = await tools.get(name)!.handler({ @@ -2366,15 +2094,23 @@ describe("Issue #20: asynchronous workflow dispatch", () => { expect(result.isError).toBeUndefined(); expect(result.content[0].text).toContain(name === "opencode_run" ? "still running" : "Task dispatched"); expect(result.content[0].text).toContain("async-session"); - expect(client.post).toHaveBeenLastCalledWith("/session/async-session/prompt_async", { - parts: [{ type: "text", text: "Long task" }], messageID: expect.stringMatching(/^msg_/), - model: { providerID: "test", modelID: "model" }, variant: "high", agent: "build", + // The session is created with its agent, model and location in the body. + expect(client.post).toHaveBeenCalledWith("/api/session", { + title: "MCP delegated task", + agent: "build", + model: { id: "model", providerID: "test", variant: "high" }, + location: { directory: "/tmp" }, + }, expect.objectContaining({ directory: "/tmp" })); + // v2 prompts take no agent; they are queued as their own turn and return + // immediately; no wait endpoint is used. + expect(client.post).toHaveBeenLastCalledWith("/api/session/async-session/prompt", { + id: expect.stringMatching(/^msg_/), text: "Long task", delivery: "queue", }, expect.objectContaining({ directory: "/tmp", signal: expect.any(AbortSignal), deadline: expect.any(Number) })); }); it("reports dispatch failures rather than claiming a task is running", async () => { const { tools, client } = captureTools(registerWorkflowTools); - vi.mocked(client.post).mockRejectedValue(new OpenCodeError("dispatch rejected", 400, "POST", "/session/existing/prompt_async", "invalid")); + vi.mocked(client.post).mockRejectedValue(new OpenCodeError("dispatch rejected", 400, "POST", "/api/session/existing/prompt", "invalid")); const result = await tools.get("opencode_fire")!.handler({ prompt: "Task", sessionId: "existing" }); expect(result.isError).toBe(true); expect(result.structuredContent.status).toBe("failed"); @@ -2387,7 +2123,8 @@ describe("Issue #14: remote project directories", () => { it.each(["opencode_status", "opencode_context", "opencode_check"])("%s accepts a server-only path", async (name) => { const { tools, client } = captureTools(registerWorkflowTools); const directory = "/remote-only/project-issue-14"; - vi.mocked(client.get).mockImplementation(async (path) => path === "/agent" || path === "/question" || path === "/permission" || path.endsWith("/message") ? [] : {}); + vi.mocked(client.get).mockImplementation(async (path) => + path === "/api/agent" || path.endsWith("/message") || path.endsWith("/form") || path.endsWith("/permission") ? [] : {}); const result = await tools.get(name)!.handler({ directory, sessionId: "remote-session" }); expect(result.isError).toBeUndefined(); expect(vi.mocked(client.get).mock.calls.some(call => call[2] === directory)).toBe(true); diff --git a/tests/transport-reliability.test.ts b/tests/transport-reliability.test.ts index 61497ce..bc94e2a 100644 --- a/tests/transport-reliability.test.ts +++ b/tests/transport-reliability.test.ts @@ -27,8 +27,8 @@ describe("bounded HTTP requests and safe retries", () => { request.resume(); request.on("end", () => { accepted++; request.socket.destroy(); }); }); - await expect(client[method]("/session/fixture")).rejects.toMatchObject({ - name: "OpenCodeSubmissionError", submissionOutcome: "unknown", method: method.toUpperCase(), path: "/session/fixture", + await expect(client[method]("/api/session/fixture")).rejects.toMatchObject({ + name: "OpenCodeSubmissionError", submissionOutcome: "unknown", method: method.toUpperCase(), path: "/api/session/fixture", }); expect(accepted).toBe(1); }); @@ -37,9 +37,9 @@ describe("bounded HTTP requests and safe retries", () => { let attempts = 0; const client = await fixture((_req, res) => { attempts++; - res.writeHead(503, { "content-type": "application/json" }).end('{"error":"temporarily unavailable"}'); + res.writeHead(503, { "content-type": "application/json" }).end('{"_tag":"ServiceUnavailable","message":"temporarily unavailable"}'); }); - await expect(client.post("/session")).rejects.toBeInstanceOf(OpenCodeError); + await expect(client.post("/api/session")).rejects.toBeInstanceOf(OpenCodeError); expect(attempts).toBe(1); }); @@ -47,9 +47,9 @@ describe("bounded HTTP requests and safe retries", () => { let attempts = 0; const client = await fixture((req, res) => { if (++attempts === 1) { req.socket.destroy(); return; } - res.writeHead(200, { "content-type": "application/json" }).end('{"ok":true}'); + res.writeHead(200, { "content-type": "application/json" }).end('{"data":{"ses_1":{"type":"busy"}}}'); }); - await expect(client.get("/session/status")).resolves.toEqual({ ok: true }); + await expect(client.get("/api/session/active")).resolves.toEqual({ ses_1: { type: "busy" } }); expect(attempts).toBe(2); }); @@ -57,9 +57,9 @@ describe("bounded HTTP requests and safe retries", () => { let attempts = 0; const client = await fixture((_req, res) => { const status = ++attempts === 1 ? 503 : 200; - res.writeHead(status, { "content-type": "application/json" }).end('{"ok":true}'); + res.writeHead(status, { "content-type": "application/json" }).end('{"data":{"ses_1":{"type":"busy"}}}'); }); - await expect(client.get("/session/status")).resolves.toEqual({ ok: true }); + await expect(client.get("/api/session/active")).resolves.toEqual({ ses_1: { type: "busy" } }); expect(attempts).toBe(2); }); @@ -71,7 +71,7 @@ describe("bounded HTTP requests and safe retries", () => { res.writeHead(503, { "content-type": "application/json" }).end('{}'); setTimeout(() => controller.abort(), 40); }); - await expect(client.get("/session/status", undefined, undefined, { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" }); + await expect(client.get("/api/session/active", undefined, undefined, { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" }); expect(attempts).toBe(1); }); @@ -80,8 +80,8 @@ describe("bounded HTTP requests and safe retries", () => { const client = await fixture((req) => { attempts++; req.resume(); }); const options = { deadline: Date.now() + 100 }; const started = Date.now(); - const request = method === "post" ? client.post("/session", {}, options) - : client[method]("/session", undefined, undefined, options); + const request = method === "post" ? client.post("/api/session", {}, options) + : client[method]("/api/session", undefined, undefined, options); await expect(request).rejects.toSatisfy((error: Error) => method === "get" ? error.name === "TimeoutError" : error instanceof OpenCodeSubmissionError && (error.cause as Error).name === "TimeoutError"); expect(Date.now() - started).toBeLessThan(1000); @@ -93,7 +93,7 @@ describe("bounded HTTP requests and safe retries", () => { const client = await fixture((_req, res) => { attempts++; res.end(); }); const controller = new AbortController(); controller.abort(); - await expect(client.post("/session", {}, { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" }); + await expect(client.post("/api/session", {}, { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" }); expect(attempts).toBe(0); }); @@ -104,7 +104,7 @@ describe("bounded HTTP requests and safe retries", () => { res.writeHead(503, { "content-type": "application/json" }).end('{}'); }); const started = Date.now(); - await expect(client.get("/session/status", undefined, undefined, { timeout: 200 })).rejects.toMatchObject({ name: "TimeoutError" }); + await expect(client.get("/api/session/active", undefined, undefined, { timeout: 200 })).rejects.toMatchObject({ name: "TimeoutError" }); expect(Date.now() - started).toBeLessThan(1000); expect(attempts).toBe(1); }); @@ -124,11 +124,11 @@ describe("SSE lifetime and framing", () => { const client = await fixture((req, res) => { directory = req.headers["x-opencode-directory"] as string; res.writeHead(200, { "content-type": "text/event-stream" }); - res.write('data: first\n\n'); + res.write('data: {"id":"evt_1","type":"server.connected","data":{}}\n\n'); res.on("close", closed); }); - for await (const event of client.subscribeSSE("/event", { directory: "/remote/project" })) { - expect(event.data).toBe("first"); + for await (const event of client.subscribeSSE("/api/event", { directory: "/remote/project" })) { + expect(event).toEqual({ event: "server.connected", data: '{"id":"evt_1","type":"server.connected","data":{}}' }); break; } await didClose; @@ -142,7 +142,7 @@ describe("SSE lifetime and framing", () => { }))); const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); const events = []; - for await (const event of client.subscribeSSE("/event")) events.push(event); + for await (const event of client.subscribeSSE("/api/event")) events.push(event); expect(events).toEqual([{ event: "ready", data: " first \nsecond" }, { event: "message", data: "" }]); }); @@ -152,7 +152,7 @@ describe("SSE lifetime and framing", () => { res.flushHeaders(); }); await expect((async () => { - for await (const _ of client.subscribeSSE("/event", { timeout: 30 })) { /* no data */ } + for await (const _ of client.subscribeSSE("/api/event", { timeout: 30 })) { /* no data */ } })()).rejects.toMatchObject({ name: "TimeoutError" }); }); }); @@ -165,7 +165,7 @@ function eventHandler(client: OpenCodeClient) { describe("event polling results", () => { it("reports authentication errors instead of an empty success", async () => { - const client = await fixture((_req, res) => res.writeHead(401).end("fixture unauthorized")); + const client = await fixture((_req, res) => res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="Secure Area"' }).end('{"_tag":"UnauthorizedError","message":"Authentication required"}')); const result = await eventHandler(client)({ durationMs: 200 }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("401"); @@ -174,7 +174,7 @@ describe("event polling results", () => { it("returns partial events alongside connection errors", async () => { const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); vi.spyOn(client, "subscribeSSE").mockImplementation(async function* () { - yield { event: "message", data: '{"type":"session.busy"}' }; + yield { event: "session.status", data: '{"id":"evt_2","type":"session.status","data":{"sessionID":"ses_1","status":{"type":"busy"}}}' }; throw new Error("connection lost"); }); const result = await eventHandler(client)({ durationMs: 200 }); @@ -200,12 +200,32 @@ describe("event polling results", () => { expect(result.isError).toBe(true); }); + it("polls the single v2 /api/event stream, scoping projects by directory header", async () => { + const seen: Array<{ path: string; directory?: string }> = []; + const client = await fixture((req, res) => { + seen.push({ path: req.url!, directory: req.headers["x-opencode-directory"] as string | undefined }); + res.writeHead(200, { "content-type": "text/event-stream" }) + .end('data: {"id":"evt_1","type":"server.connected","data":{}}\n\n: heartbeat\n\n'); + }); + const handler = eventHandler(client); + const project = await handler({ directory: "/remote/project" }); + expect(project.isError).not.toBe(true); + expect(project.structuredContent.events).toEqual([{ event: "server.connected", data: '{"id":"evt_1","type":"server.connected","data":{}}' }]); + expect(seen[0]).toEqual({ path: "/api/event", directory: "/remote/project" }); + }); + it("supports explicit global scope and rejects a misleading project directory", async () => { - let path = ""; - const client = await fixture((req, res) => { path = req.url!; res.writeHead(200, { "content-type": "text/event-stream" }).end('data: {}\n\n'); }); + const seen: Array<{ path: string; directory?: string }> = []; + const client = await fixture((req, res) => { + seen.push({ path: req.url!, directory: req.headers["x-opencode-directory"] as string | undefined }); + res.writeHead(200, { "content-type": "text/event-stream" }).end('data: {}\n\n'); + }); const handler = eventHandler(client); - expect((await handler({ scope: "global" })).isError).not.toBe(true); - expect(path).toBe("/global/event"); + const global = await handler({ scope: "global" }); + expect(global.isError).not.toBe(true); + expect(global.structuredContent.scope).toBe("global"); + expect(seen).toEqual([{ path: "/api/event", directory: undefined }]); expect((await handler({ scope: "global", directory: "/project" })).isError).toBe(true); + expect(seen).toHaveLength(1); }); }); diff --git a/tests/workflow-reliability.test.ts b/tests/workflow-reliability.test.ts index 3ababb2..5b99b79 100644 --- a/tests/workflow-reliability.test.ts +++ b/tests/workflow-reliability.test.ts @@ -4,11 +4,13 @@ import { McpServer, type ToolContext } from "../src/mcp-server.js"; import { OpenCodeClient, OpenCodeError } from "../src/client.js"; import { registerWorkflowTools } from "../src/tools/workflow.js"; -const user = (id: string, created = 1) => ({ info: { id, role: "user", time: { created } }, parts: [] }); -const answer = (parentID: string, text: string, error?: unknown) => ({ - info: { id: `answer_${parentID}`, role: "assistant", parentID, time: { created: 2, completed: 3 }, ...(error ? { error } : {}) }, - parts: [{ type: "text", text }], +// OpenCode 2.x flat messages (after envelope unwrapping), tagged by `type`. +const user = (id: string, created = 1) => ({ id, type: "user", text: "Task", time: { created } }); +const answer = (id: string, text: string, created = 2, extra: Record = {}) => ({ + id, type: "assistant", agent: "build", model: { id: "preferred", providerID: "test" }, + time: { created, completed: created }, content: [{ type: "text", text }], finish: "stop", ...extra, }); +const idle = (id: string, created = 3, outcome = "succeeded", extra: Record = {}) => ({ id, type: "idle", outcome, time: { created }, ...extra }); function setup() { const handlers = new Map(); @@ -16,24 +18,44 @@ function setup() { handlers.set(name, { schema, handler: rest.at(-1) as Function }); } } as unknown as McpServer; const state = { - status: "idle", messages: [] as unknown[], questions: [] as unknown[], permissions: [] as unknown[], - session: { id: "ses_test", title: "Test", summary: { files: 2 } }, submittedId: "", + running: false, messages: [] as any[], forms: [] as unknown[], permissions: [] as unknown[], + session: { id: "ses_test", title: "Test", time: { created: 1, updated: 1 } }, submittedId: "", + /** Reply appended when a synchronous prompt's run finishes (via /wait). */ + reply: "Fixed the error in validation.", outcome: "succeeded" as string, clock: 10, }; const client = { getBaseUrl: () => "http://127.0.0.1:4096", - get: vi.fn(async (path: string) => { - if (path === "/session/status") return { ses_test: { type: state.status } }; - if (path.endsWith("/message")) return state.messages; - if (path.endsWith("/todo")) return [{ status: "in_progress", content: "Implementing" }]; - if (path === "/question") return state.questions; - if (path === "/permission") return state.permissions; - if (path === "/provider") return { all: [{ id: "test", models: { first: {}, preferred: {} } }], default: { test: "preferred" } }; + get: vi.fn(async (path: string, ..._args: any[]): Promise => { + if (path === "/api/session/active") return state.running ? { ses_test: { type: "running" } } : {}; + // The real API lists messages newest-first. + if (path.endsWith("/message")) return [...state.messages].reverse(); + if (path === "/api/session/ses_test/form") return state.forms; + if (path === "/api/session/ses_test/permission") return state.permissions; + if (path === "/api/model") return [ + { id: "first", modelID: "first", providerID: "test", enabled: true }, + { id: "preferred", modelID: "preferred", providerID: "test", enabled: true }, + { id: "foreign", modelID: "foreign", providerID: "other", enabled: true }, + ]; + if (path === "/api/model/default") return { id: "preferred", modelID: "preferred", providerID: "test" }; return state.session; }), - post: vi.fn(async (path: string, body?: any) => { - if (path === "/session") return { id: "ses_test" }; - if (path.endsWith("/prompt_async")) { state.submittedId = body.messageID; return undefined; } - return answer("sync", "Fixed the error in validation."); + // The paged message reader goes through getWithCursor; delegate to get. + getWithCursor: vi.fn(async (path: string, ...args: any[]): Promise => ({ data: await client.get(path, ...args) })), + post: vi.fn(async (path: string, body?: any, ..._args: any[]): Promise => { + if (path === "/api/session") return { ...state.session }; + if (path.endsWith("/prompt")) { + const id = body.id ?? "msg_sync_user"; + state.submittedId = id; + const created = ++state.clock; + state.messages.push({ id, type: "user", text: body.text, time: { created } }); + return { id, sessionID: "ses_test", type: "user", time: { created }, payload: { text: body.text }, delivery: "queue" }; + } + if (path.endsWith("/wait")) { + state.messages.push(answer("msg_sync_answer", state.reply, ++state.clock)); + state.messages.push(idle("msg_sync_idle", ++state.clock, state.outcome, state.outcome === "failed" ? { error: { type: "ProviderError", message: "Provider unavailable" } } : {})); + return undefined; + } + throw new Error(`Unexpected POST ${path}`); }), delete: vi.fn(async () => undefined), }; @@ -50,11 +72,12 @@ describe("correlated asynchronous workflow results", () => { vi.useFakeTimers(); try { const { state, call } = setup(); - state.messages = [user("old"), answer("old", "Old result")]; + state.messages = [user("msg_old"), answer("msg_old_answer", "Old result"), idle("msg_old_idle")]; const waiting = call("opencode_run", { prompt: "New task", sessionId: "ses_test", maxDurationSeconds: 1 }); await vi.advanceTimersByTimeAsync(1001); const result = await waiting; expect(result.structuredContent).toMatchObject({ sessionId: "ses_test", messageId: state.submittedId, timedOut: true }); + expect(state.submittedId).toMatch(/^msg_/); expect(result.structuredContent.status).not.toBe("completed"); expect(result.content[0].text).not.toContain("Old result"); expect(result.isError).toBeUndefined(); @@ -63,47 +86,60 @@ describe("correlated asynchronous workflow results", () => { } }); - it("reports typed assistant failures even when server status is idle", async () => { + it("reports failed runs from the idle marker even when the session is no longer active", async () => { const { state, call } = setup(); - state.messages = [user("current"), answer("current", "", { name: "APIError", data: { message: "Provider unavailable" } })]; + state.messages = [user("msg_current"), answer("msg_current_answer", "", 2, { content: [], finish: "error" }), + idle("msg_current_idle", 3, "failed", { error: { type: "APIError", message: "Provider unavailable" } })]; const result = await call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 1 }); expect(result.structuredContent.status).toBe("failed"); + expect(result.structuredContent.error).toMatchObject({ type: "APIError" }); expect(result.isError).toBe(true); expect(result.content[0].text).not.toContain("Session completed"); }); + it("reports interrupted runs as cancelled, not completed", async () => { + const { state, call } = setup(); + state.messages = [user("msg_current"), idle("msg_current_idle", 3, "interrupted")]; + const result = await call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 1 }); + expect(result.structuredContent.status).toBe("cancelled"); + expect(result.content[0].text).not.toContain("Session completed"); + }); + it("ignores older failures once a newer user turn is pending", async () => { const { state, call } = setup(); - state.messages = [user("old"), answer("old", "", { name: "APIError" }), user("new", 4)]; + state.messages = [user("msg_old"), answer("msg_old_answer", ""), idle("msg_old_idle", 3, "failed", { error: { type: "APIError", message: "old" } }), user("msg_new", 4)]; const result = await call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 0.03, pollIntervalMs: 10 }); - expect(result.structuredContent).toMatchObject({ messageId: "new", timedOut: true }); + expect(result.structuredContent).toMatchObject({ messageId: "msg_new", timedOut: true }); expect(result.structuredContent.status).not.toBe("failed"); expect(result.isError).toBeUndefined(); }); it("returns actionable input requests without waiting for timeout", async () => { const { state, call } = setup(); - state.status = "busy"; - state.questions = [{ id: "question_1", sessionID: "ses_test", questions: [{ question: "Choose a target" }] }]; + state.running = true; + state.messages = [user("msg_current")]; + state.forms = [{ id: "frm_1", sessionID: "ses_test", title: "Target", fields: [{ key: "target", type: "string", title: "Choose a target" }] }]; const result = await call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 1 }); expect(result.structuredContent.status).toBe("input_required"); - expect(result.structuredContent.inputs[0]).toMatchObject({ id: "question_1", kind: "question" }); + expect(result.structuredContent.inputs[0]).toMatchObject({ id: "frm_1", kind: "question" }); expect(result.structuredContent.timedOut).toBeUndefined(); }); it("resumes exact job IDs returned by fire", async () => { - const { state, call } = setup(); + const { state, client, call } = setup(); const sent = await call("opencode_fire", { prompt: "Task", directory: "/remote/project" }); expect(sent.structuredContent.status).toBe("accepted"); - state.messages = [user(state.submittedId), answer(state.submittedId, "Done")]; + expect(client.post.mock.calls[0][1]).toMatchObject({ location: { directory: "/remote/project" } }); + state.messages.push(answer("msg_done", "Done", 20), idle("msg_done_idle", 21)); const result = await call("opencode_wait", { jobId: sent.structuredContent.jobId, timeoutSeconds: 1 }); expect(result.structuredContent).toMatchObject({ status: "completed", directory: "/remote/project", messageId: state.submittedId }); + expect(result.content[0].text).toContain("Done"); }); it("preserves correlation IDs when submission is rejected", async () => { const { client, call } = setup(); - client.post.mockImplementation(async path => { - if (path === "/session") return { id: "ses_test" }; + client.post.mockImplementation(async (path: string) => { + if (path === "/api/session") return { id: "ses_test" }; throw new OpenCodeError("Rejected", 400, "POST", path, "invalid"); }); const result = await call("opencode_fire", { prompt: "Task" }); @@ -113,9 +149,10 @@ describe("correlated asynchronous workflow results", () => { }); describe("observation lifecycle and cost", () => { - it("cancels sleeping observation promptly without aborting the remote job", async () => { + it("cancels sleeping observation promptly without interrupting the remote job", async () => { const { state, client, call } = setup(); - state.status = "busy"; + state.running = true; + state.messages = [user("msg_current")]; const controller = new AbortController(); const waiting = call("opencode_wait", { sessionId: "ses_test", timeoutSeconds: 5, pollIntervalMs: 5000 }, { signal: controller.signal }); await new Promise(resolve => setTimeout(resolve, 10)); @@ -136,30 +173,65 @@ describe("observation lifecycle and cost", () => { expect(client.get.mock.calls[0][3].signal.aborted).toBe(true); }); - it("uses summary file counts and excludes full diff bodies from checks", async () => { + it("uses the turn's snapshot file list and excludes full diff bodies from checks", async () => { const { state, client, call } = setup(); - state.status = "busy"; + state.running = true; + state.messages = [user("msg_current"), answer("msg_step", "Implementing", 2, { finish: "tool-calls", snapshot: { files: ["src/a.ts", "src/b.ts"] } })]; const result = await call("opencode_check", { sessionId: "ses_test" }); + expect(result.structuredContent.status).toBe("running"); + expect(result.content[0].text).toContain("Title: Test"); expect(result.content[0].text).toContain("Files changed: 2"); - expect(result.content[0].text).toContain("Implementing"); + // Compact monitoring omits the response body unless detailed is requested. + expect(result.content[0].text).not.toContain("Implementing"); expect(client.get.mock.calls.some(([path]) => path.endsWith("/diff"))).toBe(false); + const detailed = await call("opencode_check", { sessionId: "ses_test", detailed: true }); + expect(detailed.content[0].text).toContain("Implementing"); }); }); describe("synchronous workflow contracts", () => { - it("preserves session identifiers and format for successful debugging answers", async () => { + it("returns the answer of the submitted turn and preserves session identifiers", async () => { const { client, call } = setup(); - const format = { type: "json_schema", schema: { type: "object" } }; - const result = await call("opencode_ask", { prompt: "Fix validation", format }); + const result = await call("opencode_ask", { prompt: "Fix validation", directory: "/remote/project" }); expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toMatchObject({ sessionId: "ses_test", directory: "/remote/project", data: { id: "msg_sync_answer" } }); + expect(result.content[0].text).toContain("Fixed the error in validation."); + const paths = client.post.mock.calls.map(([path]) => path); + expect(paths).toEqual(["/api/session", "/api/session/ses_test/prompt", "/api/experimental/session/ses_test/wait"]); + expect(client.post.mock.calls[0][1]).toMatchObject({ location: { directory: "/remote/project" } }); + // v2 prompts are plain text queued as their own turn: no prompt-level + // output format, model or agent. + expect(client.post.mock.calls[1][1]).toEqual({ text: "Fix validation", delivery: "queue" }); + }); + + it("marks a synchronous answer failed when its run's idle marker failed", async () => { + const { state, call } = setup(); + state.reply = ""; + state.outcome = "failed"; + const result = await call("opencode_ask", { prompt: "Task" }); + expect(result.isError).toBe(true); expect(result.structuredContent.sessionId).toBe("ses_test"); - expect(client.post.mock.lastCall?.[1]).toMatchObject({ format }); + }); + + it("does not report an earlier turn's answer as the reply to a new prompt", async () => { + const { state, client, call } = setup(); + state.messages = [user("msg_old"), answer("msg_old_answer", "Old result"), idle("msg_old_idle")]; + // The run has not produced an answer for the new prompt by the time /wait returns. + client.post.mockImplementation(async (path: string, body?: any) => { + if (path.endsWith("/prompt")) { + state.messages.push({ id: "msg_new", type: "user", text: body.text, time: { created: 50 } }); + return { id: "msg_new", sessionID: "ses_test", type: "user", time: { created: 50 }, payload: { text: body.text }, delivery: "queue" }; + } + return undefined; + }); + const result = await call("opencode_reply", { sessionId: "ses_test", prompt: "New task" }); + expect(result.content[0].text).not.toContain("Old result"); }); it("preserves the created session if a synchronous prompt fails", async () => { const { client, call } = setup(); - client.post.mockImplementation(async path => { - if (path === "/session") return { id: "ses_test" }; + client.post.mockImplementation(async (path: string) => { + if (path === "/api/session") return { id: "ses_test" }; throw new Error("Request failed"); }); const result = await call("opencode_ask", { prompt: "Task" }); @@ -172,8 +244,20 @@ describe("synchronous workflow contracts", () => { const { client, call } = setup(); const result = await call("opencode_provider_test", { providerId: "test" }); expect(result.isError).toBeUndefined(); - expect(client.post.mock.lastCall?.[1]).toMatchObject({ model: { providerID: "test", modelID: "preferred" } }); - expect(client.post.mock.lastCall?.[1]).not.toHaveProperty("providerID"); - expect(client.delete).toHaveBeenCalledWith("/session/ses_test", undefined, undefined); + expect(result.structuredContent).toMatchObject({ providerId: "test", modelID: "preferred" }); + const create = client.post.mock.calls.find(([path]) => path === "/api/session")!; + expect(create[1]).toMatchObject({ model: { id: "preferred", providerID: "test" } }); + expect(create[1]).not.toHaveProperty("providerID"); + expect(client.post.mock.calls.find(([path]) => path.endsWith("/prompt"))![1]).not.toHaveProperty("model"); + expect(client.delete).toHaveBeenCalledWith("/api/session/ses_test", undefined, undefined); + }); + + it("falls back to the provider's first enabled model when the default belongs elsewhere", async () => { + const { client, call } = setup(); + const original = client.get.getMockImplementation()!; + client.get.mockImplementation(async (path: string, ...args: any[]) => + path === "/api/model/default" ? { id: "foreign", modelID: "foreign", providerID: "other" } : original(path, ...args)); + const result = await call("opencode_provider_test", { providerId: "test" }); + expect(result.structuredContent).toMatchObject({ modelID: "first" }); }); });