diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 27b6051cf..840a77561 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -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 diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index cc14ac2f4..b2f17ef25 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -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. diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index ccd4ff15e..b216329c2 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -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/` | diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md index a42c03924..04ccd57ff 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -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. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index 0933ca54d..2a4ee64f4 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -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. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index ed27a9169..000878d89 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -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: diff --git a/dev-docs/OPENCODE_V2_COMPATIBILITY.md b/dev-docs/OPENCODE_V2_COMPATIBILITY.md index 07fdb1545..161eda98c 100644 --- a/dev-docs/OPENCODE_V2_COMPATIBILITY.md +++ b/dev-docs/OPENCODE_V2_COMPATIBILITY.md @@ -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. diff --git a/package-lock.json b/package-lock.json index a22aa5cf1..40c15bb99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/server/package.json b/packages/server/package.json index c78de894c..8c7089e3f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/opencode/compatibility/events.ts b/packages/server/src/opencode/compatibility/events.ts index 82c63cbc3..4b6cbe1a5 100644 --- a/packages/server/src/opencode/compatibility/events.ts +++ b/packages/server/src/opencode/compatibility/events.ts @@ -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 } diff --git a/packages/server/src/opencode/compatibility/transport.test.ts b/packages/server/src/opencode/compatibility/transport.test.ts index 3dde1efe5..b6a675551 100644 --- a/packages/server/src/opencode/compatibility/transport.test.ts +++ b/packages/server/src/opencode/compatibility/transport.test.ts @@ -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 () => { diff --git a/packages/server/src/opencode/compatibility/transport.ts b/packages/server/src/opencode/compatibility/transport.ts index f8d177c39..5075318da 100644 --- a/packages/server/src/opencode/compatibility/transport.ts +++ b/packages/server/src/opencode/compatibility/transport.ts @@ -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) diff --git a/packages/server/src/opencode/session-pruning/package.json b/packages/server/src/opencode/session-pruning/package.json index 463468371..026256ee4 100644 --- a/packages/server/src/opencode/session-pruning/package.json +++ b/packages/server/src/opencode/session-pruning/package.json @@ -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" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index eab450d9c..5d3a47568 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/form-request.tsx b/packages/ui/src/components/form-request.tsx index 81eca3c01..85eba241f 100644 --- a/packages/ui/src/components/form-request.tsx +++ b/packages/ui/src/components/form-request.tsx @@ -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 = (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 { diff --git a/packages/ui/src/lib/form-schema.ts b/packages/ui/src/lib/form-schema.ts index 49d0cc26f..06010956a 100644 --- a/packages/ui/src/lib/form-schema.ts +++ b/packages/ui/src/lib/form-schema.ts @@ -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, ): boolean { + return (field.type === "external" || !field.hidden) && isFormFieldActive(field, values) +} + +function isFormFieldActive(field: FormField, values: Record): 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): 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 diff --git a/packages/ui/src/lib/provider-auth.test.ts b/packages/ui/src/lib/provider-auth.test.ts index b3a55119b..4e4f9b2fe 100644 --- a/packages/ui/src/lib/provider-auth.test.ts +++ b/packages/ui/src/lib/provider-auth.test.ts @@ -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) diff --git a/packages/ui/src/lib/provider-auth.ts b/packages/ui/src/lib/provider-auth.ts index 459149215..7c9396a85 100644 --- a/packages/ui/src/lib/provider-auth.ts +++ b/packages/ui/src/lib/provider-auth.ts @@ -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 { diff --git a/packages/ui/src/stores/opencode-data-settlement.test.ts b/packages/ui/src/stores/opencode-data-settlement.test.ts new file mode 100644 index 000000000..b4f071fbb --- /dev/null +++ b/packages/ui/src/stores/opencode-data-settlement.test.ts @@ -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() { + let resolve!: (value: T) => void + const promise = new Promise(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 } + +function fixture(instanceId: string) { + const sessionId = "s" + const statuses = sseManager.getStatuses + sseManager.getStatuses = () => new Map([[instanceId, "connected"]]) + const active = deferred>() + const reads: ReturnType>[] = [] + 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() + 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) => { + 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(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() } +}) diff --git a/packages/ui/src/stores/opencode-data.ts b/packages/ui/src/stores/opencode-data.ts index 63678fcb6..3f053363d 100644 --- a/packages/ui/src/stores/opencode-data.ts +++ b/packages/ui/src/stores/opencode-data.ts @@ -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() + const eventRevisions = new Map() + 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() + }, } }) } diff --git a/packages/ui/src/stores/runtime-contract.test.ts b/packages/ui/src/stores/runtime-contract.test.ts new file mode 100644 index 000000000..394ebe114 --- /dev/null +++ b/packages/ui/src/stores/runtime-contract.test.ts @@ -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) => void + const active = new Promise>(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, 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) + } +}) +} diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index f5cadc656..b02a388fd 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -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 = diff --git a/packages/ui/tests/browser/fixtures/hidden-forms.tsx b/packages/ui/tests/browser/fixtures/hidden-forms.tsx new file mode 100644 index 000000000..06b2b6111 --- /dev/null +++ b/packages/ui/tests/browser/fixtures/hidden-forms.tsx @@ -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 = [] +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
+ { replies.push(value) }} onCancel={async () => {}} /> +
{ + event.preventDefault() + authReplies.push(getProviderAuthAnswer(fields, answer())) + }}> + setAnswer(previous => ({ ...previous, [key]: value }) as FormAnswer)} /> + + +
+} +render(() => , document.getElementById("root")!) +await updatePreferences({ locale: "en" }) +;(window as any).fixture = { replies, authReplies } diff --git a/packages/ui/tests/browser/hidden-forms.test.ts b/packages/ui/tests/browser/hidden-forms.test.ts new file mode 100644 index 000000000..b62a400c2 --- /dev/null +++ b/packages/ui/tests/browser/hidden-forms.test.ts @@ -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", '
')) + }) + } }], 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() } +}) diff --git a/scripts/test-opencode-location-native.mjs b/scripts/test-opencode-location-native.mjs index 3adf95166..a48662fb3 100644 --- a/scripts/test-opencode-location-native.mjs +++ b/scripts/test-opencode-location-native.mjs @@ -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() diff --git a/scripts/test-session-environment-native.mjs b/scripts/test-session-environment-native.mjs index 816f46838..a4a1eeb1a 100644 --- a/scripts/test-session-environment-native.mjs +++ b/scripts/test-session-environment-native.mjs @@ -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() diff --git a/scripts/test-session-pruning-native.mjs b/scripts/test-session-pruning-native.mjs index 60763d2c3..d9d771de1 100644 --- a/scripts/test-session-pruning-native.mjs +++ b/scripts/test-session-pruning-native.mjs @@ -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 }) diff --git a/scripts/test-session-pruning-ui.mjs b/scripts/test-session-pruning-ui.mjs index 6a6e55859..35e4cf340 100644 --- a/scripts/test-session-pruning-ui.mjs +++ b/scripts/test-session-pruning-ui.mjs @@ -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 = {