fix(opencode): align current contracts and fence native message settlement (#728)

Align the server/UI OpenCode client and bundled plugin at 2.0.11. Route canonical server.info through authenticated discovery, preserve legacy step timing, and honor hidden Forms protocol fields without rendering their inputs or dropping defaults.

Reconcile terminal tools into the bounded visible transcript and fence native reducer history reads against intervening events. Discard stale pages and coalesce trailing reads so a late settlement snapshot cannot remove a newer streaming assistant. Preserve lifecycle, generation, disposal, location and historical-data safeguards.

Qualify the latest stable runtime in CI and record the resolved version. Each CodeNomad release fixes its minimum supported OpenCode version to the latest stable available when that CodeNomad release ships. Keep one shared compatibility reference and detailed evidence in PR/CI history rather than per-version reports.

Validation includes isolated native runtime/proxy/pruning, automation, environment and fork acceptance; hidden Forms browser coverage; deferred settlement/new-execution regressions; 112 final targeted UI tests, typechecks and production builds. Gatekeeper findings were fixed and independently re-reviewed before merging.
This commit is contained in:
Pascal André 2026-09-20 13:48:53 +02:00 • committed by GitHub
parent adeb4b805a
commit ca94224b74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 510 additions and 100 deletions

View file

@ -91,7 +91,7 @@ jobs:
New-Item -ItemType Directory -Force $sandbox | Out-Null
$package = npm pack ./packages/server/src/opencode/session-pruning --pack-destination $sandbox --json | ConvertFrom-Json
if ($LASTEXITCODE) { exit $LASTEXITCODE }
npm install --prefix $sandbox (Join-Path $sandbox $package.filename) '@opencode/cli@2.0.4' --ignore-scripts --no-audit --no-fund
npm install --prefix $sandbox (Join-Path $sandbox $package.filename) '@opencode/cli@latest' --ignore-scripts --no-audit --no-fund
if ($LASTEXITCODE) { exit $LASTEXITCODE }
"PRUNING_NATIVE_INSTALL=$sandbox" >> $env:GITHUB_ENV
- name: Test the installed plugin with an isolated official runtime
@ -101,6 +101,8 @@ jobs:
$arch = node -p "process.arch"
$extension = node -p "process.platform === 'win32' ? '.exe' : ''"
$cli = Join-Path $env:PRUNING_NATIVE_INSTALL "node_modules/@opencode/cli-$platform-$arch/bin/opencode$extension"
& $cli --version
if ($LASTEXITCODE) { exit $LASTEXITCODE }
$plugin = Join-Path $env:PRUNING_NATIVE_INSTALL 'node_modules/@neuralnomads/codenomad-session-pruning'
$fixtures = node -p "require('node:path').join(require('node:fs').realpathSync(require('node:os').tmpdir()), 'opencode')"
"PRUNING_NATIVE_FIXTURES=$fixtures" >> $env:GITHUB_ENV
@ -137,22 +139,6 @@ jobs:
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
runs-on: windows-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- version: 0.0.0-beta-19271
scope: '@opencode-ai'
binary: opencode2
- version: 2.0.3
scope: '@opencode'
binary: opencode
- version: 2.0.5
scope: '@opencode'
binary: opencode
- version: 2.0.7
scope: '@opencode'
binary: opencode
steps:
- uses: actions/checkout@v4
with:
@ -164,24 +150,23 @@ jobs:
- run: npm ci --ignore-scripts
- run: npm run build:pruning --workspace @neuralnomads/codenomad
- run: npm run build:automation --workspace @neuralnomads/codenomad
- name: Cross the pinned client with an isolated runtime
- name: Qualify the pinned client against the latest stable runtime
shell: pwsh
env:
RUNTIME_VERSION: ${{ matrix.version }}
RUNTIME_SCOPE: ${{ matrix.scope }}
RUNTIME_BINARY: ${{ matrix.binary }}
run: |
$sandbox = Join-Path $env:RUNNER_TEMP 'opencode-contract-runtime'
npm install --prefix $sandbox "$env:RUNTIME_SCOPE/cli@$env:RUNTIME_VERSION" --ignore-scripts --no-audit --no-fund
npm install --prefix $sandbox '@opencode/cli@latest' --ignore-scripts --no-audit --no-fund
if ($LASTEXITCODE) { exit $LASTEXITCODE }
$cli = Join-Path $sandbox 'node_modules/@opencode/cli-windows-x64/bin/opencode.exe'
& $cli --version
if ($LASTEXITCODE) { exit $LASTEXITCODE }
$cli = Join-Path $sandbox "node_modules/$env:RUNTIME_SCOPE/cli-windows-x64/bin/$env:RUNTIME_BINARY.exe"
node scripts/test-automation-native.mjs $cli
if ($LASTEXITCODE) { exit $LASTEXITCODE }
node scripts/test-session-pruning-native.mjs $cli
if ($LASTEXITCODE) { exit $LASTEXITCODE }
if ($env:RUNTIME_VERSION -eq '0.0.0-beta-19271' -or $env:RUNTIME_VERSION -eq '2.0.3') {
node scripts/test-session-pruning-native.mjs $cli --legacy-pruning
}
node scripts/test-session-environment-native.mjs $cli
if ($LASTEXITCODE) { exit $LASTEXITCODE }
node scripts/test-session-fork-native.mjs $cli
if ($LASTEXITCODE) { exit $LASTEXITCODE }
tests:
needs: authorize
@ -260,6 +245,7 @@ jobs:
packages/ui/src/lib/clipboard.test.ts
packages/ui/src/lib/message-selection-position.test.ts
packages/ui/src/lib/model-visibility.test.ts
packages/ui/src/lib/provider-auth.test.ts
packages/ui/src/lib/native/browser.test.ts
packages/ui/src/lib/runtime-env.test.ts
packages/ui/src/lib/server-meta.test.ts
@ -308,6 +294,7 @@ jobs:
packages/ui/src/stores/forms.test.ts
packages/ui/src/stores/instances-restore-ownership.test.ts
packages/ui/src/stores/opencode-data.test.ts
packages/ui/src/stores/opencode-data-settlement.test.ts
packages/ui/src/stores/message-v2/empty-content.test.ts
packages/ui/src/stores/permission-lifecycle.test.ts
packages/ui/src/stores/shell-store-reactivity.test.ts
@ -316,6 +303,7 @@ jobs:
packages/ui/src/stores/session-pruning-projection.test.ts
packages/ui/src/stores/session-pruning-pagination.test.ts
packages/ui/src/stores/session-native-events.test.ts
packages/ui/src/stores/runtime-contract.test.ts
packages/ui/src/stores/session-move-restore.test.ts
packages/ui/src/stores/session-request-authority.test.ts
packages/ui/src/stores/session-send-lifecycle.test.ts

View file

@ -16,7 +16,7 @@ description: |
## Native OpenCode V2 Baseline
- Server and UI pin the official `@opencode/client@2.0.4` together; the pruning plugin pins `@opencode/plugin@2.0.4`. Review official V2 docs, installed declarations, generated wire paths and native integration tests when upgrading. The runtime CLI is managed independently and startup has no exact version gate.
- Server and UI pin the official `@opencode/client@2.0.11` together; the pruning plugin pins `@opencode/plugin@2.0.11`. Review official V2 docs, installed declarations, generated wire paths and native integration tests when upgrading. Qualify against the latest published stable runtime. Each CodeNomad release sets its minimum supported OpenCode version to the latest stable available at that CodeNomad release; the minimum stays fixed for that release. Record results in PR/CI logs and update `dev-docs/OPENCODE_V2_COMPATIBILITY.md` in place rather than adding per-version reports. The runtime CLI is managed independently and startup has no exact version gate.
- Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode/client` declarations.
- There is no legacy `packages/opencode-plugin/`. Do not restore the V1 compatibility runtime or add general plugin extension points. The narrow integrations are the bundled `codenomad.automation` plugin and bundled session-pruning RPC; see `dev-docs/DEVELOPER_MODE.md`, `dev-docs/BROWSER_AUTOMATION.md` and `dev-docs/SESSION_PRUNING_RPC.md`. All automation tools follow backend presence without a Developer Mode gate, sharing the authenticated native transport and execution-time session/window fences.
- The server uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global OpenCode daemon. It owns no private port/database/registration/PID and never stops the daemon on backend shutdown. WSL requires Windows localhost forwarding and uses no cross-namespace PID operations.

View file

@ -20,7 +20,7 @@ Client-state V3 is a per-window envelope over the V2 content-addressed partition
| Owner | Responsibilities | Main paths |
|---|---|---|
| OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive PTYs | pinned stable `@opencode/client@2.0.4` contract across server and UI |
| OpenCode V2 | Sessions, messages, permissions, Forms, files, session Shell/instructions, background Shells, interactive PTYs | pinned stable `@opencode/client@2.0.11` contract across server and UI |
| CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing, Developer Mode bridge | `packages/server/src/` |
| CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` |
| Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` |

View file

@ -2,7 +2,7 @@
## Package
CodeNomad server and UI pin `@opencode/client@2.0.4`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404 with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. The shared transport maps canonical `server.status()` to the discovered route. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading.
CodeNomad server and UI pin `@opencode/client@2.0.11`. The runtime CLI is managed independently; startup validates authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404 with the same endpoint, credentials and deadline. Each response has its own validated schema and a 64 KiB bound. The shared transport maps canonical `server.info()` to the discovered route. Older services do not expose `paths.tmp`; consumers may use only the metadata actually provided. Discovery does not prove compatibility for other APIs. Review official V2 docs, installed declarations, generated routes and native regression tests together when upgrading.
Cross-runtime adaptation lives in `packages/server/src/opencode/compatibility/`. The shared connection binds authenticated runtime identity, the canonical client and forwarding transport. Known published contracts select their serializer directly; unknown versions require authenticated bounded OpenAPI recognition before calls. Never add operation-specific retry fallbacks in UI stores or Yolo. See `dev-docs/OPENCODE_V2_COMPATIBILITY.md` for the evidence matrix and maintained issue register.

View file

@ -2,7 +2,7 @@
## Contract
- Server and UI pin `@opencode/client@2.0.4`. Manage the runtime CLI independently: startup checks authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404. All probes share the endpoint, credentials, 64 KiB response bound and absolute deadline; authentication, transport and malformed response failures do not trigger fallback. The shared transport maps canonical `server.status()` to the discovered route. Discovery alone does not prove client/API compatibility. Review documentation, installed declarations and proxy/API parity whenever the client contract changes.
- Server and UI pin `@opencode/client@2.0.11`. Manage the runtime CLI independently: startup checks authenticated loopback `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404. All probes share the endpoint, credentials, 64 KiB response bound and absolute deadline; authentication, transport and malformed response failures do not trigger fallback. The shared transport maps canonical `server.info()` to the discovered route. Older services do not provide `paths.tmp`; never infer that path from the backend host. Discovery alone does not prove client/API compatibility. Review documentation, installed declarations and proxy/API parity whenever the client contract changes.
- The package root is the generated zero-Effect Promise client. Use installed declarations, not current public `@opencode-ai/sdk` examples.
- Native routes are `/api/*`; CodeNomad exposes them only through the authorized `/workspaces/:id/instance` proxy.
- That proxy is an explicit method/path allowlist. Future upstream APIs are not exposed automatically.

View file

@ -6,7 +6,7 @@ This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspa
The work grew beyond an SDK swap. It also introduces location-based ownership, native Forms and Shell resources, project-wide session pagination, reconnect reconciliation, bounded virtualized timelines, multi-window desktop state, and a content-addressed restore format.
Server and UI pin the official `@opencode/client@2.0.4`; the bundled pruning plugin pins `@opencode/plugin@2.0.4`. Upgrade the client, plugin and lock together using official V2 documentation, installed declarations and generated wire paths. The runtime CLI is independently managed: production startup validates authenticated `/api/status` (`version`, `pid`, `urls`), falling back only on HTTP 404 to the earlier V2 `/api/health` contract (`healthy: true`, `version`, positive `pid`). Both probes share the endpoint, credentials, response-size bound and absolute deadline. There is no exact version gate. Discovery compatibility alone does not establish compatibility for the remaining client APIs. The older beta reviews below are historical.
Server and UI pin the official `@opencode/client@2.0.11`; the bundled pruning plugin pins `@opencode/plugin@2.0.11`. Upgrade the client, plugin and lock together using official V2 documentation, installed declarations and generated wire paths. The runtime CLI is independently managed: production startup validates authenticated `/api/status`, then `/api/health`, then `/api/info`, advancing only on HTTP 404. All probes share the endpoint, credentials, response-size bound and absolute deadline. Canonical `server.info()` follows the discovered route. There is no exact version gate. Discovery compatibility alone does not establish compatibility for the remaining client APIs. The older beta reviews below are historical.
The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md).
@ -14,6 +14,13 @@ The current cross-version issue register, published API-change timeline and conn
## Native V2 Adoption
Qualification targets the latest published stable runtime. Maintain the
[shared compatibility reference](dev-docs/OPENCODE_V2_COMPATIBILITY.md) in place;
keep detailed acceptance results in PRs and CI logs.
Each CodeNomad release sets its minimum supported OpenCode version to the latest
stable available when that CodeNomad release is published. Record that minimum
in the release notes and keep it fixed for that release.
### Stable 2.0.4 contract (PR #695)
The former `@opencode-ai/client` beta contract cannot load normal sessions from a fresh 2.0.4 daemon. Fixing discovery alone is insufficient. This migration updates the server, UI, pruning plugin and guarded proxy together:

View file

@ -1,5 +1,22 @@
# OpenCode V2 compatibility: audit and implementation roadmap
## Current qualification policy
Qualify against the latest published stable OpenCode runtime (`@opencode/cli@latest`).
For each CodeNomad release, the minimum supported OpenCode version is the latest
stable OpenCode version available at the time of that CodeNomad release. Record
the resolved version in the release notes; that minimum stays fixed for that
CodeNomad release and is reassessed at the next release.
CI records the resolved runtime version. Pin client/plugin dependencies together
to the release target and qualify them before publishing CodeNomad. Retained
compatibility code and historical-data handling do not imply support for older
runtimes. Keep detailed results in the change's PR and CI logs, not in per-version
reports. Update this reference in place.
The audit and implementation record below is historical context, not a runtime
qualification matrix to maintain.
**Audit date:** 2026-09-16. **CodeNomad baseline:** PR #695 at `bcfe4d24`.
**Status:** connection-scoped compatibility is implemented. Autonomous gatekeeper loops approved transport, identity, scripts/CI and rendered-fixture scopes with no remaining actionable findings. The original audit findings below remain as the change rationale; the implementation results section records their current disposition.

62
package-lock.json generated
View file

@ -4844,14 +4844,14 @@
}
},
"node_modules/@opencode/ai": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.4.tgz",
"integrity": "sha512-T/2wTuhy/I1Wxdy/GdhtMqugpl1OWKgVp+8A45mtS1jDrTSeHq2x5YhkComkJZ6BwdG/OvU6afTGDPA7/vd4RA==",
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.11.tgz",
"integrity": "sha512-u6tkH/aQeRaUMGwf0O501n8VHTvWssB7hszfpiVG3TBzJYIbe4LvD1jqSU0BmhA32tHTvYegd61wxyjzyv+EsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "2.0.4",
"@opencode/schema": "2.0.11",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
@ -4860,13 +4860,13 @@
}
},
"node_modules/@opencode/client": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.4.tgz",
"integrity": "sha512-GBFWT2os+gEtGXpHRvDUNkf0H9x9njDUVbUvvc4drH2CuAihVcHqdi9Wm4UiwkBrSLjrKPs4U1NJBh2Z7/x4tQ==",
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.11.tgz",
"integrity": "sha512-cx9/P1AlUm1wvEAk6Tjt1B7bILzFIw/sUncJRvyVpLkW6oooZBmgkmVi/9isHnr+LPcRkFnkqwmvqhlxlFsLFw==",
"license": "MIT",
"dependencies": {
"@opencode/protocol": "2.0.4",
"@opencode/schema": "2.0.4"
"@opencode/protocol": "2.0.11",
"@opencode/schema": "2.0.11"
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
@ -4882,24 +4882,24 @@
}
},
"node_modules/@opencode/plugin": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.4.tgz",
"integrity": "sha512-NsC1STARfXxe+ZoXjXzpxvtS9l4h9Aen8ZccaCd1d9+1eyYdp+q8DU3Rxh7Ch0jiwiQMybS2Z+KgAfZJBnTBbA==",
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.11.tgz",
"integrity": "sha512-LqagVqieE/HWW71fCKrXbeyi5xe4DW4xwW45VNRcc5zLVVmjqjFp102Jd6n0BMXr5f8f2NsR+gLNhdOvM9xM2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "2.0.4",
"@opencode/client": "2.0.4",
"@opencode/protocol": "2.0.4",
"@opencode/schema": "2.0.4",
"@opencode/util": "2.0.4",
"@opencode/ai": "2.0.11",
"@opencode/client": "2.0.11",
"@opencode/protocol": "2.0.11",
"@opencode/schema": "2.0.11",
"@opencode/util": "2.0.11",
"@standard-schema/spec": "1.1.0",
"effect": "4.0.0-rc.112",
"zod": "4.1.8"
},
"peerDependencies": {
"@opencode/theme": "2.0.4",
"@opencode/theme": "2.0.11",
"@opentui/core": ">=0.5.10",
"@opentui/solid": ">=0.5.10",
"solid-js": ">=1.9.0"
@ -4930,19 +4930,19 @@
}
},
"node_modules/@opencode/protocol": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.4.tgz",
"integrity": "sha512-uWe2dmMouprSkfBu29XLR6GCWckkhm0YOo9ve07kTRqJCSoJzgQDF/8pouWG0cDCJ6oWj3bRQajXriBQyWSpUw==",
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.11.tgz",
"integrity": "sha512-57lsPuOshTuEZz8SYvTJhgZQXCvvT5MtpQAjPA+dAG3Pxna8xuoijPFJNeZSNH6isi0AGYKfznoD8MRoqVhM8w==",
"license": "MIT",
"dependencies": {
"@opencode/schema": "2.0.4",
"@opencode/schema": "2.0.11",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opencode/schema": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.4.tgz",
"integrity": "sha512-UyGcCsRb2hTH98rnBqfJogFve8kJsIbsEreRxqhkBIEGrtDTqcUd5NnqB7qlJp2khesMvpOH0GmCHlgz44C+uQ==",
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.11.tgz",
"integrity": "sha512-AlJB2iyXDMuzxlEm2ies6vp1IIjhQxBpT6hJdWvqcuxKwFQMbbmvCpKFZU/j8iN1RW8qkaD0v9h7LwnalVqZGg==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "1.1.0",
@ -4950,9 +4950,9 @@
}
},
"node_modules/@opencode/util": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.4.tgz",
"integrity": "sha512-jo6zhWqJ4WaU+fUjM8/Ea4i06gxTQFEFgfq2XG9J8gSDUhfsj4l9wJ3cLtSuKFQGeUeWqJJwRgDhCW+rUbis6Q==",
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.11.tgz",
"integrity": "sha512-FO5umf45fiO4roXCAr2sbbIZI9e0WVQkcraWqJMWpzKNWo/HtAiEDnvgHWmJPF6CjDR5OPNbT3VkRVPFhJx5mQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -18560,7 +18560,7 @@
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"@opencode/client": "2.0.4",
"@opencode/client": "2.0.11",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
@ -18576,7 +18576,7 @@
"codenomad": "dist/bin.js"
},
"devDependencies": {
"@opencode/plugin": "2.0.4",
"@opencode/plugin": "2.0.11",
"@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
@ -18616,7 +18616,7 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode/client": "2.0.4",
"@opencode/client": "2.0.11",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View file

@ -29,7 +29,7 @@
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"@opencode/client": "2.0.4",
"@opencode/client": "2.0.11",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
@ -43,7 +43,7 @@
},
"devDependencies": {
"esbuild": "^0.25.12",
"@opencode/plugin": "2.0.4",
"@opencode/plugin": "2.0.11",
"@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",

View file

@ -6,5 +6,10 @@ export function normalizeRuntimeEvent(event: OpenCodeEvent): OpenCodeEvent {
if ((event.type as string) === "session.permissions.updated") {
return { ...event, type: "session.permissions" } as OpenCodeEvent
}
if (event.type === "session.step.started" && event.data.started === undefined) {
// Before 2.0.7 the native reducer used the durable event's creation time.
// Keep that meaning for old daemons; preserve precise start times when sent.
return { ...event, data: { ...event.data, started: event.created } }
}
return event
}

View file

@ -8,11 +8,12 @@ import { rememberRuntime } from "./runtime"
import { createRuntimeFetch } from "./transport"
import { modernContractFixture } from "./contract-fixture"
test("the pinned status client follows the discovered route across legacy, modern and info runtimes", async () => {
test("the pinned info client follows the discovered route across legacy, modern and info runtimes", async () => {
for (const [version, discovery, route] of [
["2.0.0", "health", "/api/health"],
["2.0.4", "status", "/api/status"],
["2.0.7", "info", "/api/info"],
["2.0.11", "info", "/api/info"],
["future-release", "info", "/api/info"],
] as const) {
const endpoint: Endpoint = { url: "http://127.0.0.1:4321", auth: { type: "basic", username: "opencode", password: "fixture" } }
@ -25,10 +26,12 @@ test("the pinned status client follows the discovered route across legacy, moder
assert.equal(new Headers(init?.headers).get("authorization"), `Basic ${Buffer.from("opencode:fixture").toString("base64")}`)
if (path === "/openapi.json") return Response.json(modernContractFixture)
assert.equal(path, route)
return Response.json(discovery === "health" ? { healthy: true, version, pid: 123 } : { version, pid: 123, urls: [endpoint.url] })
return Response.json(discovery === "health" ? { healthy: true, version, pid: 123 }
: { version, pid: 123, urls: [endpoint.url], ...(discovery === "info" ? { paths: { tmp: "/fixture/tmp" } } : {}) })
})
const client = OpenCode.make({ baseUrl: endpoint.url, fetch })
assert.deepEqual(await client.server.status(), { version, pid: 123, urls: [endpoint.url] })
assert.deepEqual(await client.server.info(), { version, pid: 123, urls: [endpoint.url],
...(discovery === "info" ? { paths: { tmp: "/fixture/tmp" } } : {}) })
assert.deepEqual(seen, discovery === "info" ? ["/openapi.json", route] : [route])
}
})
@ -43,9 +46,10 @@ test("info translation is GET-only and does not probe alternate routes on failur
return path === "/openapi.json" ? Response.json(modernContractFixture) : new Response(null, { status: 404 })
})
const client = OpenCode.make({ baseUrl: endpoint.url, fetch })
await assert.rejects(client.server.status())
await assert.rejects(client.server.info())
await fetch(`${endpoint.url}/api/info`, { method: "POST" })
await fetch(`${endpoint.url}/api/status`, { method: "POST" })
assert.deepEqual(seen, ["GET /openapi.json", "GET /api/info", "POST /api/status"])
assert.deepEqual(seen, ["GET /openapi.json", "GET /api/info", "POST /api/info", "POST /api/status"])
})
test("the pinned client uses the earlier wire contract and retains admission/list timestamps", async () => {

View file

@ -48,8 +48,12 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
// Credentialed native requests must never follow a redirect to another host.
const options: RequestInit = { method: request.method, headers, signal: AbortSignal.any([request.signal, lifetime]), redirect: "error" }
const originalPath = url.pathname
if (originalPath === "/api/status" && request.method === "GET" && identity?.discovery === "info") {
url.pathname = "/api/info"
const serverInfo = request.method === "GET" && (originalPath === "/api/info" || originalPath === "/api/status")
if (serverInfo) {
// Discovery already selected one authenticated route. The current client
// uses info; an older renderer may still request status. Never probe here.
const discovery = identity?.discovery ?? (profile === "legacy" ? "health" : "info")
url.pathname = `/api/${discovery}`
}
if (profile === "legacy" || headers.has(LOCATION_CONTEXT_HEADER)) {
const text = request.body ? await request.text() : undefined
@ -72,8 +76,10 @@ export function createRuntimeTransport(endpoint: Endpoint, fetcher: typeof fetch
status: response.status, profile, method: request.method, path: originalPath,
}, { status: response.status })
}
if (profile === "legacy" && response.ok && originalPath === "/api/status") {
if (serverInfo && response.ok && url.pathname === "/api/health") {
const health = await response.json() as { version: string; pid: number }
// Older services do not expose paths.tmp. Do not invent a filesystem path;
// our service consumers use only the authenticated version/PID/endpoint.
return Response.json({ version: health.version, pid: health.pid, urls: [endpoint.url] })
}
if (profile !== "legacy" || !response.ok || !/^\/api\/session\/[^/]+\/(?:inbox|prompt|synthetic|compact)$/.test(originalPath)

View file

@ -14,7 +14,7 @@
"transaction.ts", "tui.ts", "tui-reload.ts"
],
"dependencies": {
"@opencode/plugin": "2.0.4",
"@opencode/plugin": "2.0.11",
"zod": "^3.25.76"
}
}

View file

@ -14,7 +14,7 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode/client": "2.0.4",
"@opencode/client": "2.0.11",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View file

@ -1,7 +1,7 @@
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
import type { FormAnswer, FormField, FormInfo, FormValue } from "@opencode/client"
import { useI18n } from "../lib/i18n"
import { isFormFieldVisible, isHttpFormUrl } from "../lib/form-schema"
import { getFormAnswer, isFormFieldVisible, isHttpFormUrl } from "../lib/form-schema"
interface FormRequestProps {
form: FormInfo
@ -11,6 +11,7 @@ interface FormRequestProps {
export function getFormFieldDefaultValue(field: FormField): FormValue | undefined {
if (field.type === "external") return undefined
if (field.hidden) return field.default
if (field.type === "boolean") return field.default ?? false
return field.default
}
@ -79,11 +80,7 @@ const FormRequest: Component<FormRequestProps> = (props) => {
setSubmitting(true)
setError(null)
try {
const visibleKeys = new Set(visibleFields().map((field) => field.key))
const answer = Object.fromEntries(
Object.entries(values()).filter(([key, value]) => visibleKeys.has(key) && value !== undefined),
) as FormAnswer
await props.onReply(answer)
await props.onReply(getFormAnswer(props.form.fields, values()))
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("formRequest.errors.reply"))
} finally {

View file

@ -1,9 +1,13 @@
import type { FormField, FormValue } from "@opencode/client"
import type { FormAnswer, FormField, FormFields, FormValue } from "@opencode/client"
export function isFormFieldVisible(
field: FormField,
values: Record<string, FormValue | undefined>,
): boolean {
return (field.type === "external" || !field.hidden) && isFormFieldActive(field, values)
}
function isFormFieldActive(field: FormField, values: Record<string, FormValue | undefined>): boolean {
if (field.type === "external" || !field.when?.length) return true
return field.when.every((condition) => {
const value = values[condition.key]
@ -13,6 +17,16 @@ export function isFormFieldVisible(
})
}
// Hidden fields still carry protocol data. Conditional/external fields do not.
// Share answer construction between session Forms and provider authentication.
export function getFormAnswer(fields: FormFields, values: Record<string, FormValue | undefined>): FormAnswer {
return Object.fromEntries(fields.flatMap((field) => {
if (field.type === "external" || !isFormFieldActive(field, values)) return []
const value = values[field.key] ?? (field.hidden ? field.default : undefined)
return value === undefined ? [] : [[field.key, value]]
}))
}
export function isHttpFormUrl(value: string): boolean {
try {
const protocol = new URL(value).protocol

View file

@ -30,6 +30,27 @@ describe("native provider auth answers", () => {
assert.equal(isProviderAuthFieldComplete(fields[1], { ...answer, account: "123" }), true)
})
it("hides protocol fields while retaining defaults, explicit answers and conditional scope", () => {
const fields = [
{ key: "region", type: "string", default: "us" },
{ key: "tenant", type: "string", required: true, hidden: true, default: "native-default" },
{ key: "enabled", type: "boolean", hidden: true, default: true },
{ key: "count", type: "integer", hidden: true, default: 7 },
{ key: "scopes", type: "multiselect", hidden: true, default: ["read"], options: [] },
{ key: "inactive", type: "string", hidden: true, default: "omit", when: [{ key: "region", op: "eq", value: "eu" }] },
{ key: "unset", type: "boolean", hidden: true },
{ key: "external", type: "external", url: "https://example.com" },
] satisfies FormFields
const defaults = getProviderAuthInitialAnswer(fields)
assert.deepEqual(fields.filter(field => isFormFieldVisible(field, defaults)).map(field => field.key), ["region", "external"])
assert.deepEqual(getProviderAuthAnswer(fields, { region: "us" }), {
region: "us", tenant: "native-default", enabled: true, count: 7, scopes: ["read"],
})
assert.deepEqual(getProviderAuthAnswer(fields, { ...defaults, tenant: "supplied", enabled: false, count: 0, scopes: [], foreign: "omit" }), {
region: "us", tenant: "supplied", enabled: false, count: 0, scopes: [],
})
})
it("accepts only explicit HTTP authorization URLs", () => {
assert.equal(isHttpFormUrl("https://example.com/oauth"), true)
assert.equal(isHttpFormUrl("http://localhost:3000/oauth"), true)

View file

@ -1,5 +1,5 @@
import type { FormAnswer, FormField, FormFields, FormValue, IntegrationKeyMethod } from "@opencode/client"
import { isFormFieldVisible } from "./form-schema"
import type { FormAnswer, FormField, FormFields, IntegrationKeyMethod } from "@opencode/client"
import { getFormAnswer } from "./form-schema"
export type ProviderAuthAuthorization = {
url: string
@ -45,11 +45,7 @@ export function getProviderAuthInitialAnswer(fields?: FormFields): FormAnswer {
export function getProviderAuthAnswer(fields: FormFields | undefined, values: FormAnswer): FormAnswer | undefined {
if (!fields) return undefined
return Object.fromEntries(
fields
.filter((field) => field.type !== "external" && isFormFieldVisible(field, values))
.flatMap((field) => values[field.key] === undefined ? [] : [[field.key, values[field.key] as FormValue]]),
)
return getFormAnswer(fields, values)
}
export function isProviderAuthFieldComplete(field: FormField, answer: FormAnswer): boolean {

View file

@ -0,0 +1,134 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { normalizeRuntimeEvent } from "../../../server/src/opencode/compatibility/events.ts"
import { sdkManager } from "../lib/sdk-manager.ts"
import { sseManager } from "../lib/sse-manager.ts"
import { addInstance, handleInstanceInvalidation, removeInstance } from "./instances.ts"
import { messageStoreBus } from "./message-v2/bus.ts"
import { destroyOpenCodeData } from "./opencode-data.ts"
import { handleNativeSessionEvent } from "./session-events.ts"
import { setActiveSession, setSessions } from "./session-state.ts"
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => { resolve = done })
return { promise, resolve }
}
const model = { providerID: "fixture", id: "fixture" }
const completed = {
id: "m1", type: "assistant", agent: "build", model,
time: { created: 1, completed: 5 }, content: [{
type: "tool", id: "tool", name: "read", time: { created: 2, ran: 3, completed: 4 },
state: { status: "completed", input: {}, content: [{ type: "text", text: "settled" }], metadata: {} },
}],
}
type Page = { data: unknown[]; cursor: Record<string, string> }
function fixture(instanceId: string) {
const sessionId = "s"
const statuses = sseManager.getStatuses
sseManager.getStatuses = () => new Map([[instanceId, "connected"]])
const active = deferred<Record<string, unknown>>()
const reads: ReturnType<typeof deferred<Page>>[] = []
const client = {
session: {
active: () => active.promise,
get: async () => ({ id: sessionId, location: { directory: "/fixture" }, time: { created: 1, updated: 5 } }),
},
message: { list: (input: { limit?: number }) => {
assert.equal(input.limit, 20, "this race must exercise the native reducer's history read")
const read = deferred<Page>()
reads.push(read)
return read.promise
} },
} as any
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
setSessions(previous => new Map(previous).set(instanceId, new Map([[sessionId, {
id: sessionId, instanceId, title: "Fixture", parentId: null, location: { directory: "/fixture" },
status: "working", agent: "build", model: { providerId: "fixture", modelId: "fixture" }, time: { created: 1, updated: 1 },
} as any]])))
setActiveSession(instanceId, sessionId)
let sequence = 0
const emit = (type: string, data: Record<string, unknown>) => {
const created = ++sequence
const event = normalizeRuntimeEvent({ id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as any)
handleInstanceInvalidation(instanceId, event)
handleNativeSessionEvent(instanceId, event)
}
const next = { sessionID: sessionId, assistantMessageID: "m2" }
return {
reads, emit, next,
start() {
const base = { sessionID: sessionId, assistantMessageID: "m1" }
emit("session.step.started", { ...base, agent: "build", model, started: 1 })
emit("session.tool.input.started", { ...base, id: "tool", name: "read" })
emit("session.tool.called", { ...base, id: "tool", input: {} })
emit("session.execution.succeeded", { sessionID: sessionId })
assert.equal(reads.length, 1)
emit("session.execution.started", { sessionID: sessionId })
emit("session.step.started", { ...next, agent: "build", model, started: 6 })
emit("session.text.started", next)
emit("session.text.delta", { ...next, delta: "hello" })
active.resolve({})
},
text() {
const message = messageStoreBus.getOrCreate(instanceId).getMessage("m2")
return message?.partIds.map(id => (message.parts[id].data as any).text).join("")
},
dispose() { destroyOpenCodeData(instanceId) },
cleanup() {
destroyOpenCodeData(instanceId)
active.resolve({})
for (const read of reads) read.resolve({ data: [], cursor: {} })
sseManager.getStatuses = statuses
removeInstance(instanceId, { authoritative: false })
sdkManager.destroyClientsForInstance(instanceId)
if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId)
},
}
}
async function flush() {
await new Promise<void>(resolve => setImmediate(resolve))
}
test("native terminal history cannot replace a newer streaming assistant; trailing reads coalesce", async () => {
const f = fixture("native-settlement-race")
try {
f.start()
f.reads[0].resolve({ data: [completed], cursor: {} })
await flush()
assert.equal(f.reads.length, 2, "discard the stale page and read current history once")
f.emit("session.text.delta", { ...f.next, delta: " world" })
f.emit("session.text.delta", { ...f.next, delta: "!" })
assert.equal(f.text(), "hello world!", "streaming must continue while reconciliation is pending")
assert.equal(f.reads.length, 2, "event bursts must not start overlapping reads")
f.reads[1].resolve({ data: [completed], cursor: {} })
await flush()
assert.equal(f.reads.length, 3)
const current = {
id: "m2", type: "assistant", agent: "build", model, time: { created: 6 },
content: [{ type: "text", text: "hello world!" }],
}
f.reads[2].resolve({ data: [current, completed], cursor: {} })
await flush()
f.emit("session.text.delta", { ...f.next, delta: " More." })
assert.equal(f.text(), "hello world! More.", "do not lose or double-apply deltas around the accepted page")
assert.equal(f.reads.length, 3)
} finally { f.cleanup() }
})
test("a disposed native projection does not retry a delayed terminal history read", async () => {
const f = fixture("native-settlement-disposed")
try {
f.start()
f.dispose()
f.reads[0].resolve({ data: [completed], cursor: {} })
await flush()
assert.equal(f.reads.length, 1)
assert.equal(f.text(), "hello", "disposal must preserve the separately owned visible snapshot")
} finally { f.cleanup() }
})

View file

@ -96,6 +96,9 @@ export function getOpenCodeInstanceGeneration(instanceId: string): number {
function createDataEntry(instanceId: string, directory: string): DataEntry {
const listeners = new Set<(event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void>()
const messageSnapshots = new Map<string, SessionMessageInfo[]>()
const eventRevisions = new Map<string, number>()
const instanceGeneration = getOpenCodeInstanceGeneration(instanceId)
let disposed = false
return createRoot((dispose) => {
const event = {
listen(handler: (event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) {
@ -117,9 +120,24 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
get(messageTarget, messageProperty, messageReceiver) {
if (messageProperty !== "list") return Reflect.get(messageTarget, messageProperty, messageReceiver)
return async (input: { sessionID: string }, options?: unknown) => {
const snapshot = messageSnapshots.get(input.sessionID)
if (snapshot) return { data: [...snapshot].reverse(), cursor: {} }
return (client.message.list as any)(input, options)
// Native terminal-tool reconciliation replaces its cache after
// awaiting this response, without fencing intervening events.
// Keep the live projection until one trailing read is current;
// never merge an obsolete page into newer streaming messages.
for (;;) {
if (disposed || getOpenCodeInstanceGeneration(instanceId) !== instanceGeneration) {
throw new Error("Stale read from disposed OpenCode projection")
}
const snapshot = messageSnapshots.get(input.sessionID)
if (snapshot) return { data: [...snapshot].reverse(), cursor: {} }
const revision = eventRevisions.get(input.sessionID) ?? 0
const response = await (client.message.list as any)(input, options)
if (disposed || getOpenCodeInstanceGeneration(instanceId) !== instanceGeneration) {
throw new Error("Stale read from disposed OpenCode projection")
}
if ((eventRevisions.get(input.sessionID) ?? 0) !== revision) continue
return response
}
}
},
})
@ -136,6 +154,10 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
onError: (error) => log.warn("Failed to refresh OpenCode projection", { instanceId, error }),
})
const emit = (details: OpenCodeEvent) => {
// Isolated fresh-entry resyncs already fence their entire transaction.
// Only events applied to this reducer invalidate its own pending reads.
const sessionId = eventSessionId(details)
if (sessionId) eventRevisions.set(sessionId, (eventRevisions.get(sessionId) ?? 0) + 1)
for (const listener of listeners) listener({ name: details.type, details })
}
return {
@ -173,7 +195,10 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
data.session.setStatus(sessionId, sessionId in active ? "running" : "idle")
return true
},
dispose,
dispose() {
disposed = true
dispose()
},
}
})
}

View file

@ -0,0 +1,95 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { normalizeRuntimeEvent } from "../../../server/src/opencode/compatibility/events.ts"
import { applyOpenCodeDataEvent, destroyOpenCodeData } from "./opencode-data.ts"
import { sdkManager } from "../lib/sdk-manager.ts"
import { sseManager } from "../lib/sse-manager.ts"
import { addInstance, handleInstanceInvalidation, removeInstance } from "./instances.ts"
import { handleNativeSessionEvent, handleSessionIdle, handleSessionStatus } from "./session-events.ts"
import { messageStoreBus } from "./message-v2/bus.ts"
import { setActiveSession, setSessions } from "./session-state.ts"
test("the current native reducer preserves old event timing and newer precise start times", () => {
for (const started of [undefined, 0, 45]) {
const instanceId = `runtime-started-${started}`
const original = {
id: "evt_step", type: "session.step.started", created: 100,
durable: { aggregateID: "s", seq: 2, version: 1 },
data: { sessionID: "s", assistantMessageID: "m", agent: "build", model: { providerID: "fixture", id: "fixture" },
...(started === undefined ? {} : { started }) },
}
try {
const event = normalizeRuntimeEvent(original as any)
const data = applyOpenCodeDataEvent(instanceId, "/fixture", event)
assert.equal(data.session.message.get("s", "m")?.time.created, started ?? 100)
assert.ok("durable" in event)
assert.deepEqual(event.durable, original.durable)
assert.equal(original.data.started, started, "normalization must not mutate the input event")
} finally { destroyOpenCodeData(instanceId) }
}
})
for (const following of ["none", "idle", "status-idle", "new-execution"] as const) {
test(`settled tools reach the visible store with following ${following}`, async () => {
const instanceId = `runtime-tool-settlement-${following}`, sessionId = "s"
const statuses = sseManager.getStatuses
sseManager.getStatuses = () => new Map([[instanceId, "connected"]])
let reads = 0
const completed = {
id: "m", type: "assistant", agent: "build", model: { providerID: "fixture", id: "fixture" },
time: { created: 1, completed: 5 }, content: [{ type: "tool", id: "tool", name: "read",
time: { created: 2, ran: 3, completed: 4 },
state: { status: "completed", input: { path: "fixture" }, content: [{ type: "text", text: "settled" }], metadata: {} },
}],
}
let release!: (value: Record<string, unknown>) => void
const active = new Promise<Record<string, unknown>>(resolve => { release = resolve })
const client = {
session: { active: () => active, get: async () => ({ id: sessionId, location: { directory: "/fixture" }, time: { created: 1, updated: 5 } }) },
message: { list: async () => { reads++; return { data: [completed], cursor: {} } } },
} as any
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
setSessions(previous => new Map(previous).set(instanceId, new Map([[sessionId, {
id: sessionId, instanceId, title: "Fixture", parentId: null, location: { directory: "/fixture" },
status: "working", agent: "build", model: { providerId: "fixture", modelId: "fixture" }, time: { created: 1, updated: 1 },
} as any]])))
setActiveSession(instanceId, sessionId)
const emit = (type: string, data: Record<string, unknown>, created: number) => {
const event = normalizeRuntimeEvent({ id: `evt_${created}`, type, created, data, location: { directory: "/fixture" } } as any)
handleInstanceInvalidation(instanceId, event)
handleNativeSessionEvent(instanceId, event)
}
const state = () => (messageStoreBus.getOrCreate(instanceId).getMessage("m")?.parts.tool?.data as any)?.state
try {
const base = { sessionID: sessionId, assistantMessageID: "m" }
emit("session.step.started", { ...base, agent: "build", model: completed.model, started: 1 }, 1)
emit("session.tool.input.started", { ...base, id: "tool", name: "read" }, 2)
emit("session.tool.called", { ...base, id: "tool", input: { path: "fixture" } }, 3)
assert.equal(state()?.status, "running")
emit("session.execution.succeeded", { sessionID: sessionId }, 5)
if (following === "idle") handleSessionIdle(instanceId, { type: "session.idle", data: { sessionID: sessionId }, created: 6 } as any)
if (following === "status-idle") handleSessionStatus(instanceId, { type: "session.status", data: { sessionID: sessionId, status: { type: "idle" } }, created: 6 } as any)
if (following === "new-execution") emit("session.execution.started", { sessionID: sessionId }, 6)
release({})
if (following === "new-execution") {
await new Promise(resolve => setTimeout(resolve, 30))
assert.equal(state()?.status, "running", "a superseded terminal check cannot replace the current transcript")
return
}
for (let attempt = 0; attempt < 100 && state()?.status !== "completed"; attempt++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
assert.ok(reads > 0, "settlement must consult native history")
assert.equal(state()?.status, "completed")
assert.equal(state()?.output, "settled")
} finally {
release({})
sseManager.getStatuses = statuses
destroyOpenCodeData(instanceId)
removeInstance(instanceId, { authoritative: false })
sdkManager.destroyClientsForInstance(instanceId)
if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId)
}
})
}

View file

@ -188,9 +188,23 @@ async function reconcileTerminalNativeSessionStatus(
withSession(instanceId, sessionId, (session) => { session.generationRecovery = "interrupted" })
}
setTerminalNativeSessionStatus(instanceId, sessionId, options.failed, options.directory)
if (options.refreshMessages) {
refreshSettledSessionMessages(instanceId, sessionId, options.refreshMessages)
}
function refreshSettledSessionMessages(instanceId: string, sessionId: string, force = false): void {
// The native reducer can reconcile its own cache after a missing tool terminal
// event, but our bounded visible message store needs an authoritative load too.
const store = messageStoreBus.getInstance(instanceId)
const unsettledTools = store?.getSessionMessageIds(sessionId).some(id => {
const message = store.getMessage(id)
return message?.partIds.some(partId => {
const part = message.parts[partId]?.data
return part?.type === "tool" && (part.state?.status === "pending" || part.state?.status === "running")
})
})
if (force || unsettledTools) {
void loadMessages(instanceId, sessionId, { force: true }).catch((error) => {
log.warn("Failed to refresh interrupted session messages", { instanceId, sessionId, error })
log.warn("Failed to refresh settled session messages", { instanceId, sessionId, error })
})
}
}
@ -539,6 +553,7 @@ function handleSessionIdle(instanceId: string, event: SessionIdle): void {
}
ensureSessionStatus(instanceId, sessionId, "idle", event.location?.directory)
refreshSettledSessionMessages(instanceId, sessionId)
speakCompletedAssistantText(instanceId, sessionId)
log.info(`[SSE] Session idle: ${sessionId}`)
}
@ -552,6 +567,7 @@ function handleSessionStatus(instanceId: string, event: SessionStatusUpdated): v
const status = mapSdkSessionStatus(rawStatus)
const retry = mapSdkSessionRetry(rawStatus)
ensureSessionStatus(instanceId, sessionId, status, event.location?.directory, retry)
if (status === "idle") refreshSettledSessionMessages(instanceId, sessionId)
if (retry) {
const remainingSeconds = Math.max(0, Math.round((retry.next - Date.now()) / 1000))
const countdown =

View file

@ -0,0 +1,39 @@
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import type { FormAnswer, FormFields, FormInfo } from "@opencode/client"
import FormRequest from "../../../src/components/form-request"
import { ProviderAuthForm } from "../../../src/components/provider-auth/provider-auth-form"
import { getProviderAuthAnswer, getProviderAuthInitialAnswer } from "../../../src/lib/provider-auth"
import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
import { serverApi } from "../../../src/lib/api-client"
import "../../../src/index.css"
const fields: FormFields = [
{ key: "name", type: "string", title: "Name", required: true },
{ key: "tenant", type: "string", title: "Hidden tenant", hidden: true, required: true, default: "native-tenant" },
{ key: "flag", type: "boolean", title: "Hidden flag", hidden: true, default: false },
{ key: "unset", type: "boolean", hidden: true },
{ key: "inactive", type: "string", hidden: true, default: "omit", when: [{ key: "name", op: "eq", value: "other" }] },
]
const replies: FormAnswer[] = [], authReplies: Array<FormAnswer | undefined> = []
serverApi.fetchConfigOwner = async () => ({ settings: { locale: "en" } }) as any
serverApi.fetchStateOwner = async () => ({}) as any
serverApi.patchConfigOwner = async (_owner, patch) => patch as any
function Fixture() {
const [answer, setAnswer] = createSignal(getProviderAuthInitialAnswer(fields))
return <ConfigProvider><I18nProvider><main style={{ width: "600px", padding: "24px" }}>
<FormRequest form={{ id: "form", sessionID: "s", title: "Session form", fields, state: { status: "pending" } } as FormInfo}
onReply={async value => { replies.push(value) }} onCancel={async () => {}} />
<form aria-label="Provider form" onSubmit={event => {
event.preventDefault()
authReplies.push(getProviderAuthAnswer(fields, answer()))
}}>
<ProviderAuthForm fields={fields} answer={answer()} onAnswer={(key, value) => setAnswer(previous => ({ ...previous, [key]: value }) as FormAnswer)} />
<button type="submit">Connect fixture</button>
</form>
</main></I18nProvider></ConfigProvider>
}
render(() => <Fixture />, document.getElementById("root")!)
await updatePreferences({ locale: "en" })
;(window as any).fixture = { replies, authReplies }

View file

@ -0,0 +1,46 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { chromium, type Browser } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
let server: ViteDevServer, browser: Browser, url: string
before(async () => {
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
plugins: [solid(), { name: "hidden-forms-fixture", configureServer(s) {
s.middlewares.use("/fixture", async (_req, res) => {
res.setHeader("Content-Type", "text/html")
res.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/hidden-forms.tsx"></script></body></html>'))
})
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
})
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => { await browser?.close(); await server?.close() })
test("session and provider Forms hide protocol inputs but submit their defaults", async () => {
const page = await browser.newPage()
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
await page.goto(url)
await page.waitForFunction(() => Boolean((window as any).fixture))
for (const name of ["Session form", "Provider form"]) {
const form = page.getByRole("form", { name })
assert.equal(await form.locator("input").count(), 1, "hidden fields must not be mounted or focusable")
assert.equal(await form.getByText("Hidden tenant").count(), 0)
await form.getByRole("textbox", { name: "Name" }).fill("Alice")
await form.locator('button[type="submit"]').click()
}
assert.deepEqual(await page.evaluate(() => (window as any).fixture), {
replies: [{ name: "Alice", tenant: "native-tenant", flag: false }],
authReplies: [{ name: "Alice", tenant: "native-tenant", flag: false }],
})
assert.deepEqual(errors, [])
} finally { await page.close() }
})

View file

@ -230,8 +230,8 @@ async function runIsolated(cli) {
const client = OpenCode.make({ baseUrl, fetch: transport.fetch })
const connection = { endpoint, client, ...transport, assertCurrent() {}, invalidate() {} }
console.log(`Testing official runtime ${identity.version}`)
assert.equal((await client.server.status()).version, identity.version)
console.log(`PASS: production ${identity.discovery} discovery and canonical client.server.status()`)
assert.equal((await client.server.info()).version, identity.version)
console.log(`PASS: production ${identity.discovery} discovery and canonical client.server.info()`)
await testNativeLocationIdentity({ client, connection, root })
} finally {
child.kill()

View file

@ -98,7 +98,7 @@ try {
}
variables = {}
assert.equal((await probe(proxy, a, "removed-overrides")).tmpdir, path.join(root, "base"))
assert.equal((await native.server.status()).pid, info.pid)
assert.equal((await native.server.info()).pid, info.pid)
console.log(`PASS OpenCode ${info.version}: real manager + proxy + native shells; next-send updates, both conversations, removal, no writes on read, unchanged daemon PID`)
} finally {
await app?.close()

View file

@ -165,7 +165,7 @@ try {
const makeClient = () => OpenCode.make({ baseUrl, headers: {
Authorization: `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
}, fetch: runtimeFetch })
assert.equal((await client.server.status()).version, runtimeVersion)
assert.equal((await client.server.info()).version, runtimeVersion)
console.log(`Testing official runtime ${runtimeVersion}`)
const { testNativeLocationIdentity } = await import("./test-opencode-location-native.mjs")
await testNativeLocationIdentity({ client, connection, root })

View file

@ -20,7 +20,7 @@ export async function testPruningUI({ client, baseUrl, root, location, generate,
const { rememberRuntime } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
const { locationRequestOptions } = await tsImport("../packages/server/src/opencode/compatibility/location.ts", import.meta.url)
const endpoint = connection?.endpoint ?? { url: baseUrl, auth: { type: "basic", username: "opencode", password: "isolated-pruning-fixture" } }
if (!connection) rememberRuntime(endpoint, { ...await client.server.status(), discovery: "status" })
if (!connection) rememberRuntime(endpoint, { ...await client.server.info(), discovery: "info" })
const runtimeFetch = connection?.fetch ?? createRuntimeFetch(endpoint)
const owns = candidate => path.resolve(candidate) === path.resolve(location.directory)
const workspaceManager = {