From 4359b4bfe947bc84cdf80b21d5d50c998a232e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 19 Aug 2026 01:50:39 +0200 Subject: [PATCH] fix(v2): align with current OpenCode client contract Pin the server and UI client to the installed beta runtime and reject mismatched CLI versions before shared-service startup. Delegate proven shared-service shutdown to Service.stop while retaining CodeNomad lease, peer, registration, endpoint, and process-identity checks. Adopt native Forms and the client/solid data reducer for live messages, tools, permissions, and input forms. Propagate internal stream generations into authoritative reconnect reconciliation and replace directory-wide session scans with native project cursor pagination. Fail worktree deletion when session evacuation fails and deduplicate canonical workspace folders instead of exposing non-isolated duplicate instances. Update migration notes and CI coverage for the reviewed contract. Validated with server/UI/Electron typechecks, 244 UI partition tests, 122 Electron native tests, 248 server tests plus 3 platform skips (the sole Windows cleanup race passed in isolation), UI/server/Electron builds, Tauri cargo check --locked, and git diff --check. --- .github/workflows/pr-build.yml | 2 +- .../codenomad-architecture-guide/SKILL.md | 2 +- .../references/sdk-api-reference.md | 2 +- .../references/sdk-critical-behaviors.md | 2 +- CONTRIBUTING.md | 2 +- MIGRATION_V2.md | 14 +- dev-docs/SUMMARY.md | 2 +- dev-docs/architecture.md | 2 +- dev-docs/technical-implementation.md | 2 +- package-lock.json | 200 +++++----- packages/server/README.md | 2 +- packages/server/package.json | 2 +- packages/server/src/api-types.ts | 3 +- packages/server/src/events/bus.test.ts | 10 +- .../src/server/routes/workspaces.test.ts | 2 - .../server/src/server/routes/workspaces.ts | 2 - .../__tests__/workspace-identity.test.ts | 16 +- .../server/src/workspaces/instance-events.ts | 4 +- .../server/src/workspaces/manager.test.ts | 29 +- packages/server/src/workspaces/manager.ts | 19 +- .../src/workspaces/opencode-service.test.ts | 49 ++- .../server/src/workspaces/opencode-service.ts | 20 +- packages/ui/package.json | 2 +- packages/ui/src/App.tsx | 4 +- .../src/components/folder-selection-view.tsx | 11 +- packages/ui/src/components/form-request.tsx | 4 +- .../components/instance/instance-shell2.tsx | 7 +- packages/ui/src/components/message-block.tsx | 3 +- .../components/permission-approval-modal.tsx | 69 +--- .../permission-notification-banner.tsx | 13 +- packages/ui/src/components/session-list.tsx | 2 +- .../src/components/session/session-view.tsx | 2 +- packages/ui/src/components/tool-call.tsx | 139 +------ .../components/tool-call/question-block.tsx | 354 ------------------ .../hooks/foreground-refresh-controller.ts | 4 + .../src/lib/hooks/use-app-session-restore.ts | 11 +- .../lib/hooks/use-foreground-refresh.test.ts | 10 + .../src/lib/hooks/use-foreground-refresh.ts | 8 + packages/ui/src/lib/sse-manager.ts | 15 - packages/ui/src/stores/instances.ts | 280 ++------------ packages/ui/src/stores/message-v2/bridge.ts | 62 --- .../stores/message-v2/instance-store.test.ts | 85 ----- .../src/stores/message-v2/instance-store.ts | 173 +-------- .../message-v2/pending-request-dedupe.test.ts | 57 --- packages/ui/src/stores/message-v2/types.ts | 15 - .../stores/native-session-streaming.test.ts | 127 ------- .../ui/src/stores/native-session-streaming.ts | 234 ------------ packages/ui/src/stores/opencode-data.test.ts | 39 ++ packages/ui/src/stores/opencode-data.ts | 61 +++ .../src/stores/permission-lifecycle.test.ts | 53 +-- packages/ui/src/stores/session-api.ts | 104 +++-- packages/ui/src/stores/session-events.ts | 183 +-------- .../ui/src/stores/session-list-options.ts | 10 +- .../src/stores/session-native-events.test.ts | 120 +----- .../ui/src/stores/session-pagination.test.ts | 34 +- .../src/stores/session-pending-state.test.ts | 16 +- .../ui/src/stores/session-pending-state.ts | 6 +- .../src/stores/session-send-lifecycle.test.ts | 4 +- packages/ui/src/stores/session-state.ts | 18 +- packages/ui/src/stores/session-status.test.ts | 11 +- packages/ui/src/stores/session-status.ts | 2 +- packages/ui/src/stores/sessions.ts | 4 - .../ui/src/stores/wake-lock-eligibility.ts | 4 +- packages/ui/src/stores/worktrees.ts | 4 +- packages/ui/src/types/question.ts | 29 -- packages/ui/src/types/session.ts | 1 - 66 files changed, 510 insertions(+), 2272 deletions(-) delete mode 100644 packages/ui/src/components/tool-call/question-block.tsx delete mode 100644 packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts delete mode 100644 packages/ui/src/stores/native-session-streaming.test.ts delete mode 100644 packages/ui/src/stores/native-session-streaming.ts create mode 100644 packages/ui/src/stores/opencode-data.test.ts create mode 100644 packages/ui/src/stores/opencode-data.ts delete mode 100644 packages/ui/src/types/question.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 70857478..12569236 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -147,7 +147,7 @@ jobs: packages/ui/src/lib/hooks/use-active-session-message-load.test.ts packages/ui/src/stores/forms.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts - packages/ui/src/stores/native-session-streaming.test.ts + packages/ui/src/stores/opencode-data.test.ts packages/ui/src/stores/permission-lifecycle.test.ts packages/ui/src/stores/pty-store-reactivity.test.ts packages/ui/src/stores/session-actions.test.ts diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 0eb499f6..3fafd7f1 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -15,7 +15,7 @@ description: | ## Native OpenCode V2 Baseline -- The only OpenCode client dependency is the experimental `@opencode-ai/client` protocol. Server and UI must stay aligned on the latest reviewed `next` release; runtime CLI discovery is not exact-version-gated. Current public `@opencode-ai/sdk` docs describe a different contract. +- The only OpenCode client dependency is the experimental `@opencode-ai/client` protocol. Server, UI, and the selected runtime CLI must stay on the exact version pinned by the server package. Current public `@opencode-ai/sdk` docs describe a different contract. - Do not use `@opencode-ai/sdk`, `@opencode-ai/sdk/v2/client`, or `createOpencodeClient()`; follow installed `@opencode-ai/client` declarations. - There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging. - The server owns one shared OpenCode service through `OpenCodeSharedService` and its custom lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Production does not call `Service.ensure` or `Service.stop` directly. Workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. 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 ed4ef3b9..a30fb5b1 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 keeps the experimental `@opencode-ai/client` protocol aligned in `packages/server/package.json` and `packages/ui/package.json` on the latest reviewed `next` release. Runtime CLI discovery is not exact-version-gated. This is distinct from the current public `@opencode-ai/sdk` documentation. +CodeNomad keeps the experimental `@opencode-ai/client` protocol aligned in `packages/server/package.json` and `packages/ui/package.json`. Runtime startup probes the selected CLI and requires that exact server dependency version. This is distinct from the current public `@opencode-ai/sdk` documentation. - Promise client: `import { OpenCode } from "@opencode-ai/client"` - Service lifecycle: `import { Service } from "@opencode-ai/client/service"` 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 6336e3ee..62c2ebca 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 -- Keep server and UI on the same latest reviewed experimental `@opencode-ai/client` `next` release. Review OpenCode release notes, current documentation, installed declarations, and proxy/API parity on every upgrade; runtime CLI discovery is not exact-version-gated. +- Keep server, UI, and the selected runtime CLI on the exact experimental `@opencode-ai/client` version pinned by the server package. Review OpenCode release notes, current documentation, installed declarations, and proxy/API parity on every upgrade. - 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/CONTRIBUTING.md b/CONTRIBUTING.md index 7a1804bf..92a3381c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,7 +111,7 @@ Then open a pull request on GitHub targeting the `dev` branch. ### OpenCode V2 Boundaries -- Server and UI must use the same latest reviewed `@opencode-ai/client` `next` release. Runtime discovery does not require an exact CLI version. Review OpenCode release notes, current documentation, and installed declarations on every upgrade; this is not the public `@opencode-ai/sdk` contract. +- Server, UI, and the selected `opencode2` CLI must use the exact `@opencode-ai/client` version pinned by the server package. Startup probes and rejects mismatched CLIs. Review OpenCode release notes, current documentation, and installed declarations on every upgrade; this is not the public `@opencode-ai/sdk` contract. - Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode documentation](https://opencode.ai/docs/), and `node_modules/@opencode-ai/client/dist/promise/`. - `packages/server/src/workspaces/opencode-service.ts` owns a custom lease-locked discovery, launch, process-proof, and authenticated-stop lifecycle. Production does not call `Service.ensure` or `Service.stop` directly. Workspaces are native OpenCode locations/directories, not separate server processes. - V2 always uses `~/.local/share/opencode2/opencode.db`. Never reuse the V1 database for V2. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index 82675c63..2736a40c 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -45,18 +45,22 @@ The migration removes the V1 compatibility layer rather than maintaining both in - Avoid logging unredacted secret-bearing proxy request bodies. - Expose only an explicit method/path allowlist through the OpenCode proxy. New upstream APIs require an intentional proxy and ownership review; future OpenCode functionality is not automatic. - Share a consistent service registration location between Windows and WSL. -- Discover the installed `opencode2` service without requiring an exact runtime version match. Each dependency upgrade must review OpenCode release notes, current documentation, installed client declarations, and proxy/API parity. -- Use CodeNomad's hardened discovery/launch lifecycle in production rather than direct `Service.ensure`/`Service.stop` calls. A lifecycle lease records the registration, authenticated endpoint, daemon PID plus process-start identity and host/WSL namespace, and a hash of the launch command/environment. Proof can transfer between live CodeNomad processes through peer leases; the final process stops the daemon only after proving there are no live peers and every recorded identity still matches. +- Probe the selected `opencode2` CLI and discover/ensure only the exact version pinned by `packages/server/package.json`. Each dependency upgrade must review OpenCode release notes, current documentation, installed client declarations, and proxy/API parity. +- Wrap native `Service.ensure`/`Service.stop` with CodeNomad's ownership checks. A lifecycle lease records the registration, authenticated endpoint, daemon PID plus process-start identity and host/WSL namespace, and a hash of the launch command/environment. Proof can transfer between live CodeNomad processes through peer leases; the final process calls `Service.stop` only after proving there are no live peers and every recorded identity still matches. - Queue location eviction when its final logical owner is removed, then perform it only during proven final shared-service shutdown so another CodeNomad process cannot lose active upstream state. - Force the V2 service database to `~/.local/share/opencode2/opencode.db`. V1 and V2 must never point at the same database because their schemas are incompatible. - Isolate V2 restore state under `~/.codenomad/client-state/v2` and copy V1 state non-destructively on first launch, preserving downgrade history. ## Current Status -- The server/UI client dependencies are aligned on the latest reviewed OpenCode `next` release. The installed `opencode2` CLI is not exact-version-gated at runtime. -- The current working tree includes hardened lifecycle proof, launch-configuration matching, an isolated V2 database, deferred location eviction, proxy path/location validation, and reconnect reconciliation changes. +- Server, UI, and the selected `opencode2` CLI are exact-version-gated to `0.0.0-beta-17595`. +- Shared-service shutdown delegates to native `Service.stop` after CodeNomad proves ownership and excludes live peer leases. +- The UI uses native Forms instead of the removed Question API and `@opencode-ai/client/solid` `createData` for live message, tool, permission, and form reduction. +- Session inventory uses native project/subpath/parent/order cursor pagination; internal OpenCode stream generations trigger authoritative UI reconciliation even when browser SSE remains connected. +- A worktree is not deleted if session evacuation fails, and one canonical folder maps to one logical workspace instead of creating non-isolated duplicates. +- The current working tree retains the isolated V2 database, deferred location eviction, and proxy path/location ownership validation. - Current installed client declarations provide native PTY list/get/create/title-or-size update/remove and lifecycle events, but no output/read/stream API and no separate stop API. Removing a running PTY is therefore the only native stop action, and PTY output is not displayed. -- The migration remains a Draft. The full validation matrix and a real current-tree OpenCode V2 startup/session/event/Shell/shutdown smoke test are not yet recorded complete. +- Local validation passes server/UI/Electron typechecks, the CI UI partitions, Electron native tests, server tests (with three platform skips), UI/server/Electron builds, Tauri `cargo check --locked`, and `git diff --check`. ## Remaining Work diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 22da0017..68586120 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -164,7 +164,7 @@ dev-docs/ Development documentation ## Current OpenCode Baseline -- Experimental protocol client: server and UI use the same latest reviewed `@opencode-ai/client` `next` release; the runtime `opencode2` CLI is not exact-version-gated, and every upgrade reviews release notes, current documentation, installed declarations, and proxy/API parity +- Experimental protocol client: server, UI, and the runtime `opencode2` CLI use the exact version pinned in the server package; every upgrade reviews release notes, current documentation, installed declarations, and proxy/API parity - Service: one shared endpoint managed by CodeNomad's lease-locked process-proof lifecycle - Workspaces: native locations/directories - Database: V2 always uses `~/.local/share/opencode2/opencode.db`, separate from V1 diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 3c3a51cc..83ad283f 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -15,7 +15,7 @@ There is no `@opencode-ai/sdk` integration and no `packages/opencode-plugin` pac ## Shared Service And Locations -`packages/server/src/workspaces/opencode-service.ts` uses native discovery and headers, but production startup/shutdown does not call `Service.ensure` or `Service.stop` directly. Its custom launcher serializes lifecycle changes with cross-process leases, records the registration and authenticated endpoint, proves daemon and CodeNomad PIDs with process-start identity in the host or WSL namespace, and binds that proof to a launch command/environment hash. Live peer leases can inherit that proof; only the final verified CodeNomad process may request authenticated shutdown and wait for the exact daemon to exit. +`packages/server/src/workspaces/opencode-service.ts` uses native discovery and headers while retaining a custom launcher that serializes lifecycle changes with cross-process leases, records the registration and authenticated endpoint, proves daemon and CodeNomad PIDs with process-start identity in the host or WSL namespace, and binds that proof to a launch command/environment hash. Live peer leases can inherit that proof; only the final verified CodeNomad process may call `Service.stop`. WSL daemons use the same authenticated graceful-stop request instead because the published fallback signals PIDs in the caller's namespace. The V2 service always uses `~/.local/share/opencode2/opencode.db`. V1 and V2 must use separate databases because their schemas are incompatible. diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 3c658e9d..d5ef75cb 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -2,7 +2,7 @@ ## OpenCode Dependency -Server and UI use the same latest reviewed experimental `@opencode-ai/client` `next` release. Import the generated Promise client from `@opencode-ai/client`. Runtime service discovery does not require an exact CLI version. Every upgrade must review OpenCode release notes, current documentation, installed declarations, and proxy/API parity. +Server and UI use the same reviewed experimental `@opencode-ai/client` release. Import the generated Promise client from `@opencode-ai/client`. Runtime startup probes the selected CLI and discovery/ensure require the exact server dependency version. Every upgrade must review OpenCode release notes, current documentation, installed declarations, and proxy/API parity. Do not add `@opencode-ai/sdk`, old `{ data, error }` SDK wrappers, `createOpencodeClient()`, or a `packages/opencode-plugin` package. Verify method signatures in `node_modules/@opencode-ai/client/dist/promise/`. diff --git a/package-lock.json b/package-lock.json index e4b97072..22e5b6ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3302,44 +3302,6 @@ "node": ">= 8" } }, - "node_modules/@opencode-ai/client": { - "version": "0.0.0-next-17444", - "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-next-17444.tgz", - "integrity": "sha512-o5nNtHTSqId6eQWJXdMQ132f8anOgm1q6YrB2JgiVGDfhu8EA8TLl2nrP1789CA7Tvkfr855sT/QiZaPynVFcQ==", - "license": "MIT", - "dependencies": { - "@opencode-ai/protocol": "0.0.0-next-17444", - "@opencode-ai/schema": "0.0.0-next-17444" - }, - "peerDependencies": { - "effect": "4.0.0-beta.101" - }, - "peerDependenciesMeta": { - "effect": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/protocol": { - "version": "0.0.0-next-17444", - "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-next-17444.tgz", - "integrity": "sha512-IHCsPp3Tu0BlCqIziANPL04spnAO1NaN91eUeuwYPP1SGF1BhQltilG5jjzcTNssd+I7SbOEcpYs5TIrFKwAjQ==", - "license": "MIT", - "dependencies": { - "@opencode-ai/schema": "0.0.0-next-17444", - "effect": "4.0.0-beta.101" - } - }, - "node_modules/@opencode-ai/schema": { - "version": "0.0.0-next-17444", - "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-next-17444.tgz", - "integrity": "sha512-HhiKwCFDqOABWbjQgmTQcltRnoRaLxkK6TyXbQpCY1+QnvHu7hxJIwgL0z8YFhYNxyTCTgy85UccEvaBgfwwvg==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "1.1.0", - "effect": "4.0.0-beta.101" - } - }, "node_modules/@pinojs/redact": { "version": "0.4.0", "license": "MIT" @@ -6367,24 +6329,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/effect": { - "version": "4.0.0-beta.101", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.101.tgz", - "integrity": "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.9.0", - "find-my-way-ts": "^0.1.6", - "ini": "^7.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^2.0.4", - "multipasta": "^0.2.8", - "toml": "^4.1.2", - "uuid": "^14.0.1", - "yaml": "^2.9.0" - } - }, "node_modules/ejs": { "version": "3.1.10", "dev": true, @@ -7148,12 +7092,6 @@ "node": ">=14" } }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, "node_modules/find-up": { "version": "4.1.0", "license": "MIT", @@ -7963,15 +7901,6 @@ "version": "2.0.4", "license": "ISC" }, - "node_modules/ini": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", - "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", - "license": "ISC", - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -9177,12 +9106,6 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, - "node_modules/multipasta": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", - "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", - "license": "MIT" - }, "node_modules/mz": { "version": "2.7.0", "dev": true, @@ -11667,15 +11590,6 @@ "node": ">=0.6" } }, - "node_modules/toml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", - "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -13654,7 +13568,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode-ai/client": "0.0.0-next-17444", + "@opencode-ai/client": "0.0.0-beta-17595", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", @@ -13678,6 +13592,48 @@ "typescript": "^5.6.3" } }, + "packages/server/node_modules/@opencode-ai/client": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-17595.tgz", + "integrity": "sha512-uDM6jztQiyXulYQL9G7kx283cv+0WhgQ98AzfClKQnV5mOB5wVBTGIjD1FGRwyxhUNGJBrfaP+NqOwWFjZhoeg==", + "license": "MIT", + "dependencies": { + "@opencode-ai/protocol": "0.0.0-beta-17595", + "@opencode-ai/schema": "0.0.0-beta-17595" + }, + "peerDependencies": { + "effect": "4.0.0-beta.107", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "packages/server/node_modules/@opencode-ai/protocol": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-17595.tgz", + "integrity": "sha512-du77bQXIvOvqzyqS/I5gJMY3FZhXH2Oi3gs3ywAOnHCGRkwmVE9ofvFwR4G/Rzx/oVUOuGkyN28si29AluXGWw==", + "license": "MIT", + "dependencies": { + "@opencode-ai/schema": "0.0.0-beta-17595", + "effect": "4.0.0-beta.107" + } + }, + "packages/server/node_modules/@opencode-ai/schema": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-17595.tgz", + "integrity": "sha512-448/fW6HIHd2JT+TSgm4izcAWl51f17GR6WcfGbS9SyWmXFNnX1Fi03xNRgf7dnul9yyceXEsMbreUvteKxAYg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-beta.107" + } + }, "packages/server/node_modules/commander": { "version": "12.1.0", "license": "MIT", @@ -13685,6 +13641,19 @@ "node": ">=18" } }, + "packages/server/node_modules/effect": { + "version": "4.0.0-beta.107", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.107.tgz", + "integrity": "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.4", + "uuid": "^14.0.1" + } + }, "packages/server/node_modules/fuzzysort": { "version": "2.0.4", "license": "MIT" @@ -13704,7 +13673,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/client": "0.0.0-next-17444", + "@opencode-ai/client": "0.0.0-beta-17595", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", @@ -13740,6 +13709,61 @@ "vite-plugin-pwa": "^1.2.0", "vite-plugin-solid": "^2.10.0" } + }, + "packages/ui/node_modules/@opencode-ai/client": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-17595.tgz", + "integrity": "sha512-uDM6jztQiyXulYQL9G7kx283cv+0WhgQ98AzfClKQnV5mOB5wVBTGIjD1FGRwyxhUNGJBrfaP+NqOwWFjZhoeg==", + "license": "MIT", + "dependencies": { + "@opencode-ai/protocol": "0.0.0-beta-17595", + "@opencode-ai/schema": "0.0.0-beta-17595" + }, + "peerDependencies": { + "effect": "4.0.0-beta.107", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "packages/ui/node_modules/@opencode-ai/protocol": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-17595.tgz", + "integrity": "sha512-du77bQXIvOvqzyqS/I5gJMY3FZhXH2Oi3gs3ywAOnHCGRkwmVE9ofvFwR4G/Rzx/oVUOuGkyN28si29AluXGWw==", + "license": "MIT", + "dependencies": { + "@opencode-ai/schema": "0.0.0-beta-17595", + "effect": "4.0.0-beta.107" + } + }, + "packages/ui/node_modules/@opencode-ai/schema": { + "version": "0.0.0-beta-17595", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-17595.tgz", + "integrity": "sha512-448/fW6HIHd2JT+TSgm4izcAWl51f17GR6WcfGbS9SyWmXFNnX1Fi03xNRgf7dnul9yyceXEsMbreUvteKxAYg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-beta.107" + } + }, + "packages/ui/node_modules/effect": { + "version": "4.0.0-beta.107", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.107.tgz", + "integrity": "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.4", + "uuid": "^14.0.1" + } } } } diff --git a/packages/server/README.md b/packages/server/README.md index 28c7dcb6..276d833f 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -20,7 +20,7 @@ ## Prerequisites -- **OpenCode V2**: Install the latest `opencode2` CLI. Runtime discovery does not require an exact version match. +- **OpenCode V2**: Install the exact `opencode2` CLI version pinned by `@opencode-ai/client` in this package. Startup rejects a different version. - **OpenCode database**: V2 uses `~/.local/share/opencode2/opencode.db`, separate from the incompatible V1 database. - Node.js 18+ and npm (for running or building from source). - A workspace folder on disk you want to serve. diff --git a/packages/server/package.json b/packages/server/package.json index 7a12435b..32439f6d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -27,7 +27,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", - "@opencode-ai/client": "0.0.0-next-17444", + "@opencode-ai/client": "0.0.0-beta-17595", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 8104b040..9259e299 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -42,7 +42,6 @@ export interface WorkspaceCreateRequest { path: string name?: string requestId?: string - forceNew?: boolean } export interface WorkspaceCloneRequest { @@ -463,7 +462,7 @@ export type WorkspaceEventPayload = | { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } - | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } + | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; generation: number; reason?: string } | { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean } | { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string } diff --git a/packages/server/src/events/bus.test.ts b/packages/server/src/events/bus.test.ts index 71757ceb..40ebf1c1 100644 --- a/packages/server/src/events/bus.test.ts +++ b/packages/server/src/events/bus.test.ts @@ -7,20 +7,20 @@ import type { WorkspaceEventPayload } from "../api-types" describe("event bus instance status replay", () => { it("replays the latest instance status to a late subscriber", () => { const bus = new EventBus() - bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting" }) - bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }) + bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting", generation: 1 }) + bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected", generation: 1 }) const received: WorkspaceEventPayload[] = [] bus.onEvent((event) => received.push(event)) assert.deepEqual(received, [ - { type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }, + { type: "instance.eventStatus", instanceId: "workspace-1", status: "connected", generation: 1 }, ]) }) it("delivers terminal disconnects live without replaying stopped workspaces", () => { const bus = new EventBus() - bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" }) + bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected", generation: 1 }) const live: WorkspaceEventPayload[] = [] bus.onEvent((event) => live.push(event)) live.length = 0 @@ -29,6 +29,7 @@ describe("event bus instance status replay", () => { type: "instance.eventStatus", instanceId: "workspace-1", status: "disconnected", + generation: 1, reason: "workspace stopped", }) @@ -36,6 +37,7 @@ describe("event bus instance status replay", () => { type: "instance.eventStatus", instanceId: "workspace-1", status: "disconnected", + generation: 1, reason: "workspace stopped", }]) const replayed: WorkspaceEventPayload[] = [] diff --git a/packages/server/src/server/routes/workspaces.test.ts b/packages/server/src/server/routes/workspaces.test.ts index ef1bcdc5..2230949a 100644 --- a/packages/server/src/server/routes/workspaces.test.ts +++ b/packages/server/src/server/routes/workspaces.test.ts @@ -41,14 +41,12 @@ describe("workspace routes", () => { name: "Work", binaryPath: "C:/tools/ignored-opencode.exe", requestId: " restore-request ", - forceNew: true, }, }) assert.equal(response.statusCode, 201) assert.deepEqual(calls, [["C:/work", "Work", { requestId: "restore-request", - forceNew: true, }]]) const released = await app.inject({ diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 834ae27d..565e0c53 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -15,7 +15,6 @@ const WorkspaceCreateSchema = z.object({ path: z.string(), name: z.string().optional(), requestId: z.string().trim().min(1).max(128).optional(), - forceNew: z.boolean().optional(), }) const WorkspaceCloneSchema = z.object({ @@ -76,7 +75,6 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { const body = WorkspaceCreateSchema.parse(request.body ?? {}) const result = await deps.workspaceManager.create(body.path, body.name, { requestId: body.requestId, - forceNew: body.forceNew, }) reply.code(201) return result.created ? result.workspace : { ...result.workspace, reused: true as const } diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index cc9eebaf..435a964e 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, it } from "node:test" import pino from "pino" import { EventBus } from "../../events/bus" -import { WorkspaceManager } from "../manager" +import { EXPECTED_OPENCODE_VERSION, WorkspaceManager } from "../manager" import { normalizeWorkspaceIdentityPath, resolveWorkspaceIdentity } from "../workspace-identity" const temporaryDirectories: string[] = [] @@ -51,6 +51,7 @@ function createManager(rootDir: string) { eventBus: new EventBus(logger), logger, sharedService, + probeBinaryVersion: () => ({ valid: true, version: EXPECTED_OPENCODE_VERSION }), } as unknown as ConstructorParameters[0]) return manager } @@ -175,17 +176,4 @@ describe("workspace identity", () => { }) assert.equal((await manager.create(target)).created, true) }) - - it("allows forced canonical duplicates without replacing the reusable workspace", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const normal = await manager.create(target) - const forced = await manager.create(link, undefined, { forceNew: true }) - assert.notEqual(normal.workspace.id, forced.workspace.id) - - await manager.delete(forced.workspace.id) - const reused = await manager.create(link) - assert.equal(reused.created, false) - assert.equal(reused.workspace.id, normal.workspace.id) - }) }) diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index bd4abd5b..5cd752b4 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -30,6 +30,7 @@ interface InstanceEventBridgeOptions { export class InstanceEventBridge { private readonly controller = new AbortController() private status: InstanceStreamStatus = "connecting" + private generation = 0 private task?: Promise private readonly directoryOwners = new Map }>() private readonly sessionDirectories = new Map }>() @@ -68,6 +69,7 @@ export class InstanceEventBridge { private async run() { while (!this.controller.signal.aborted) { + this.generation += 1 this.clearLocationCaches() this.updateStatus("connecting") try { @@ -209,7 +211,7 @@ export class InstanceEventBridge { private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) { this.options.logger.debug({ instanceId, status, reason }, "Instance event status updated") - this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason }) + this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, generation: this.generation, reason }) } private delay(duration: number) { diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index e00bcaa0..c145e28e 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -8,6 +8,7 @@ import { WorkspaceLaunchCancelledError, WorkspaceManager, WorkspaceShutdownError, + EXPECTED_OPENCODE_VERSION, } from "./manager" import type { OpenCodeEnsureOptions } from "./opencode-service" import path from "node:path" @@ -87,12 +88,26 @@ function createHarness(service = new ControlledSharedService(), overrides: Recor eventBus, logger: pino({ level: "silent" }), sharedService: service, + probeBinaryVersion: () => ({ valid: true, version: EXPECTED_OPENCODE_VERSION }), ...overrides, }) return { manager, service, stopped } } describe("workspace manager shared service lifecycle", () => { + it("rejects a mismatched CLI before service startup and passes the exact client version", async () => { + const service = new ControlledSharedService() + const mismatch = createHarness(service, { + probeBinaryVersion: () => ({ valid: true, version: "0.0.0-wrong" }), + }) + await assert.rejects(mismatch.manager.create(process.cwd()), new RegExp(`expected ${EXPECTED_OPENCODE_VERSION}.*0\\.0\\.0-wrong`)) + assert.equal(service.validationCalls.length, 0) + + const matching = createHarness(service) + await matching.manager.create(process.cwd()) + assert.equal(service.validationCalls[0]?.options?.version, EXPECTED_OPENCODE_VERSION) + }) + it("uses bounded WSL mappings for root and real git worktree ownership", async () => { const base = await mkdtemp(path.join(os.tmpdir(), "codenomad-wsl-ownership-")) const repo = path.join(base, "repo") @@ -149,20 +164,6 @@ describe("workspace manager shared service lifecycle", () => { assert.equal(harness.service.validationCalls.length, 1) }) - it("evicts only after the last logical owner of a location is deleted", async () => { - const harness = createHarness() - const first = await harness.manager.create(process.cwd()) - const forced = await harness.manager.create(process.cwd(), undefined, { forceNew: true }) - - await harness.manager.delete(forced.workspace.id) - assert.deepEqual(harness.service.evictions, []) - assert.equal(harness.manager.get(first.workspace.id)?.status, "ready") - - await harness.manager.delete(first.workspace.id) - assert.deepEqual(harness.service.evictions, [{ directory: process.cwd(), workspaceID: "location-1" }]) - assert.deepEqual(harness.stopped, [forced.workspace.id, first.workspace.id]) - }) - it("cancels validation and cleans its logical location", async () => { const harness = createHarness() harness.service.validationGate = deferred() diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index d4e2b159..9bd88bd2 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -2,6 +2,7 @@ import path from "path" import os from "node:os" import { spawnSync } from "child_process" import { randomUUID } from "node:crypto" +import { createRequire } from "node:module" import type { Endpoint } from "@opencode-ai/client/service" import type { LocationGetOutput, LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import { EventBus } from "../events/bus" @@ -18,6 +19,7 @@ import { parseWslUncPath, resolveWslHostDirectory, resolveWslServiceDirectory, + probeBinaryVersion, } from "./spawn" import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-service" import { @@ -30,6 +32,9 @@ import { import { isPathOwnedByWorktree, resolveWorktreeSlugForDirectory } from "./worktree-directory" const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 +const require = createRequire(import.meta.url) +const packageJson = require("../../package.json") as { dependencies: { "@opencode-ai/client": string } } +export const EXPECTED_OPENCODE_VERSION = packageJson.dependencies["@opencode-ai/client"] const OPENCODE_DATABASE = path.join(os.homedir(), ".local", "share", "opencode2", "opencode.db") const ORDINARY_CREATION_OWNER = "" const WORKSPACE_STATE = Symbol("workspaceState") @@ -78,6 +83,7 @@ interface WorkspaceManagerOptions { platform?: NodeJS.Platform wslServiceDirectoryResolver?: (directory: string, distro: string, timeoutMs: number) => string | null wslHostDirectoryResolver?: (directory: string, distro: string, timeoutMs: number) => string | null + probeBinaryVersion?: typeof probeBinaryVersion } interface WorkspaceRecord extends WorkspaceDescriptor { @@ -132,7 +138,6 @@ export interface WorkspaceCreateResult { } export interface WorkspaceCreateOptions { requestId?: string - forceNew?: boolean } type CreationRequestState = "active" | "cancelled" | "released" type WorkspaceCreationOwnership = Map @@ -334,12 +339,6 @@ export class WorkspaceManager { if (this.shuttingDown) { throw new Error("Workspace manager is shutting down") } - if (options.forceNew) { - const ownership = this.createOwnership(options.requestId) - const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) - const result = await this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) - return this.finishCreation(result, options.requestId, ownership) - } const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId)) if (existing) { this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace") @@ -470,6 +469,11 @@ export class WorkspaceManager { const { id, path: workspacePath, binaryId: resolvedBinaryPath } = record try { const serverConfig = this.options.settings.getOwner("config", "server") + const version = (this.options.probeBinaryVersion ?? probeBinaryVersion)(resolvedBinaryPath) + if (!version.valid || version.version !== EXPECTED_OPENCODE_VERSION) { + const actual = version.version ?? version.reported ?? version.error ?? "unknown" + throw new Error(`OpenCode CLI version mismatch: expected ${EXPECTED_OPENCODE_VERSION}, selected binary reported ${actual}`) + } const configuredEnvironment = this.readConfiguredEnvironment(serverConfig) if (this.options.nodeExtraCaCertsPath) configuredEnvironment.NODE_EXTRA_CA_CERTS = this.options.nodeExtraCaCertsPath configuredEnvironment.XDG_STATE_HOME = SERVICE_STATE_ROOT @@ -485,6 +489,7 @@ export class WorkspaceManager { }) const ensureOptions: OpenCodeEnsureOptions = { file: SERVICE_REGISTRATION_FILE, + version: EXPECTED_OPENCODE_VERSION, command: launch.command, contenderFile: SERVICE_CONTENDER_FILE, leaseFile: SERVICE_LEASE_FILE, diff --git a/packages/server/src/workspaces/opencode-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts index 10074e7d..9b05f2de 100644 --- a/packages/server/src/workspaces/opencode-service.test.ts +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -295,6 +295,30 @@ describe("OpenCodeSharedService", () => { } }) + it("delegates proven host shutdown to Service.stop with the registration file", async () => { + const state = await serviceState("codenomad-service-sdk-stop-") + let stopFile: string | undefined + const service = new OpenCodeSharedService({ + discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }), + ensure: async (options) => { + options?.onStart?.("missing") + return { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } } + }, + stop: async (options) => { stopFile = options?.file }, + headers: () => undefined, + waitForStop: async () => true, + getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), + makeClient: () => ({} as OpenCodeClient), + }) + try { + await service.endpoint(state.options("owner", true)) + await service.shutdown() + assert.equal(stopFile, state.file) + } finally { + await rm(state.root, { recursive: true, force: true }) + } + }) + it("never owns a symlinked registration", { skip: process.platform === "win32" }, async () => { const state = await serviceState("codenomad-service-link-") const target = path.join(state.root, "target.json") @@ -342,8 +366,9 @@ describe("OpenCodeSharedService", () => { it("checks WSL stop completion in the distro despite a coincidental live Windows PID", async () => { const state = await serviceState("codenomad-service-wsl-stop-") let healthChecks = 0 + let sdkStops = 0 let wslPidExists = true - const server = (await import("node:http")).createServer((_request, response) => { + const server = (await import("node:http")).createServer((request, response) => { healthChecks += 1 if (healthChecks <= 2) { response.destroy() @@ -358,23 +383,25 @@ describe("OpenCodeSharedService", () => { state.info.url = `http://127.0.0.1:${address.port}` await writeFile(state.file, JSON.stringify(state.info)) const wslNamespace = { kind: "wsl", distro: "Ubuntu" } as const - const service = createOwnedService( - state, - async () => true, - true, - () => true, - undefined, - true, - async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), - async (pid, _timeoutMs, namespace) => wslPidExists + const endpoint = { url: state.info.url, auth: { type: "basic" as const, username: "opencode", password: state.info.password } } + const service = new OpenCodeSharedService({ + discover: async () => endpoint, + ensure: async (options) => { options?.onStart?.("missing"); return endpoint }, + stop: async () => { sdkStops += 1 }, + headers: () => undefined, + isProcessAlive: () => true, + getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), + probeProcessIdentity: async (pid, _timeoutMs, namespace) => wslPidExists ? { status: "found", identity: processIdentity(pid, undefined, namespace) } : { status: "missing" }, - ) + makeClient: () => ({} as OpenCodeClient), + }) try { await service.endpoint({ ...state.options("owner", true), nativePid: false, wslDistro: "Ubuntu" }) assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.nativePid, false) assert.deepEqual(JSON.parse(await readFile(state.lease("owner"), "utf8")).service.processIdentity.namespace, wslNamespace) await assert.rejects(service.shutdown({ timeoutMs: 500 }), /did not exit|completion timed out/) + assert.equal(sdkStops, 1) assert.ok(healthChecks > 2) assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping") wslPidExists = false diff --git a/packages/server/src/workspaces/opencode-service.ts b/packages/server/src/workspaces/opencode-service.ts index 13cec1f9..962b5959 100644 --- a/packages/server/src/workspaces/opencode-service.ts +++ b/packages/server/src/workspaces/opencode-service.ts @@ -100,6 +100,7 @@ interface OwnedService { export interface OpenCodeSharedServiceDependencies { discover: typeof Service.discover ensure?: typeof Service.ensure + stop?: typeof Service.stop headers: typeof Service.headers makeClient: typeof OpenCode.make requestStop?: (info: Info, endpoint: Endpoint, timeoutMs: number) => Promise @@ -125,6 +126,7 @@ export class OpenCodeSharedService { constructor(private readonly dependencies: OpenCodeSharedServiceDependencies = { discover: Service.discover, + stop: Service.stop, headers: Service.headers, makeClient: OpenCode.make, }) {} @@ -304,7 +306,9 @@ export class OpenCodeSharedService { const proof = this.serviceProof(owned) await this.writeLease(lease.file, { ...ownMetadata, service: proof, state: "stopping", updatedAt: Date.now() }) const stopped = await this.withDeadline( - (this.dependencies.requestStop ?? this.requestStop)(owned.info, owned.endpoint, remaining), + this.dependencies.requestStop + ? this.dependencies.requestStop(owned.info, owned.endpoint, remaining) + : this.requestStop(owned), remaining, "OpenCode service stop", ) @@ -1187,17 +1191,9 @@ export class OpenCodeSharedService { } } - private requestStop = async (info: Info, endpoint: Endpoint, timeoutMs: number): Promise => { - if (!info.id) return false - const response = await fetch(new URL("/api/service/stop", info.url), { - method: "POST", - headers: { ...this.dependencies.headers(endpoint), "content-type": "application/json" }, - body: JSON.stringify({ instanceID: info.id }), - signal: AbortSignal.timeout(timeoutMs), - }) - if (!response.ok) return false - const body = await response.json() as { accepted?: unknown } - return body?.accepted === true + private requestStop = async (owned: OwnedService): Promise => { + await (this.dependencies.stop ?? Service.stop)(owned.stopOptions) + return true } private waitForStop = async ( diff --git a/packages/ui/package.json b/packages/ui/package.json index e8414d85..539d761e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,7 +13,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/client": "0.0.0-next-17444", + "@opencode-ai/client": "0.0.0-beta-17595", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 992e354f..2288ccad 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -404,7 +404,7 @@ const App: Component = () => { return recent?.projectName?.trim() || getPathBasename(folderPath) } - async function handleSelectFolder(folderPath: string, options?: { forceNew?: boolean }) { + async function handleSelectFolder(folderPath: string) { if (!folderPath) { return } @@ -415,7 +415,7 @@ const App: Component = () => { setIsSelectingFolder(true) try { - const result = await createInstance(folderPath, projectName, { forceNew: options?.forceNew }) + const result = await createInstance(folderPath, projectName) recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, folderPath) if (result.reused) { selectInstanceTab(result.instanceId) diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 92e29c2b..c7bf0809 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -30,7 +30,7 @@ type HomeTab = "local" | "servers" interface FolderSelectionViewProps { - onSelectFolder: (folder: string, options?: { forceNew?: boolean }) => void + onSelectFolder: (folder: string) => void onSelectExistingInstance: (instanceId: string, recentPath: string) => void onOpenSidecar?: () => void isLoading?: boolean @@ -179,7 +179,7 @@ const FolderSelectionView: Component = (props) => { if (activeTab() === "local") { const folder = folders()[index] if (folder) { - handleFolderSelect(folder.path, true) + handleFolderSelect(folder.path) } return } @@ -279,9 +279,9 @@ const FolderSelectionView: Component = (props) => { return t("time.relative.justNow") } - function handleFolderSelect(path: string, forceNew = false) { + function handleFolderSelect(path: string) { if (isLoading()) return - props.onSelectFolder(path, forceNew ? { forceNew: true } : undefined) + props.onSelectFolder(path) } function handleExistingInstanceSelect(instanceId: string, recentPath: string) { @@ -800,8 +800,7 @@ const FolderSelectionView: Component = (props) => { class="folder-home-recent-primary-action" disabled={isLoading()} aria-labelledby={projectLabelId()} - title={t("folderSelection.recent.openNewInstance")} - onClick={() => handleFolderSelect(folder.path, true)} + onClick={() => handleFolderSelect(folder.path)} onFocus={() => { setFocusMode("recent") setSelectedIndex(index()) diff --git a/packages/ui/src/components/form-request.tsx b/packages/ui/src/components/form-request.tsx index def0f5ee..bba3a0a8 100644 --- a/packages/ui/src/components/form-request.tsx +++ b/packages/ui/src/components/form-request.tsx @@ -170,7 +170,7 @@ const FormRequest: Component = (props) => { class="form-request-input" type="text" value={customString()} - placeholder={t("toolCall.question.custom.placeholder")} + placeholder={t("formRequest.customValuesPlaceholder")} aria-describedby={field.description ? descriptionId : undefined} onInput={(event) => update(field.key, event.currentTarget.value || undefined)} /> @@ -206,7 +206,7 @@ const FormRequest: Component = (props) => { class="form-request-input" type="text" value={customString()} - placeholder={t("toolCall.question.custom.placeholder")} + placeholder={t("formRequest.customValuesPlaceholder")} aria-describedby={field.description ? descriptionId : undefined} onInput={(event) => update(field.key, event.currentTarget.value || undefined)} /> diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index d84af5ae..6ccb673e 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -38,7 +38,7 @@ import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" import PromptInput from "../prompt-input" import { useI18n } from "../../lib/i18n" -import { activeInterruption, getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" +import { activeInterruption, getPermissionQueueLength } from "../../stores/instances" import { getFormQueue } from "../../stores/forms" import SessionSidebar from "./shell/SessionSidebar" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" @@ -351,8 +351,7 @@ const InstanceShell2: Component = (props) => { const hasPendingRequests = createMemo(() => { const permissions = getPermissionQueueLength(props.instance.id) - const questions = getQuestionQueueLength(props.instance.id) - return permissions + questions + getFormQueue(props.instance.id).length > 0 + return permissions + getFormQueue(props.instance.id).length > 0 }) const activePromptInputApi = createMemo(() => { @@ -431,7 +430,7 @@ const InstanceShell2: Component = (props) => { const activeSession = activeSessionForInstance() const needsPermission = Boolean(activeSession?.pendingPermission) - const needsQuestion = Boolean(activeSession?.pendingQuestion || activeSession?.pendingForm) + const needsQuestion = Boolean(activeSession?.pendingForm) const needsInput = needsPermission || needsQuestion if (needsInput) { diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 972a7ab1..96de64ea 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -746,8 +746,7 @@ export default function MessageBlock(props: MessageBlockProps) { if (part?.type !== "tool" || props.toolVisibility(part.tool || "") !== "hidden") return true return Boolean( part.pendingPermission?.active || - props.store().getPermissionState(item.messageId, item.partId)?.active || - props.store().getQuestionState(item.messageId, item.partId)?.active, + props.store().getPermissionState(item.messageId, item.partId)?.active, ) } diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 4699fa71..4564e040 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -1,14 +1,11 @@ import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, onCleanup, type Component } from "solid-js" import type { PermissionRequest } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" -import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" import { useI18n } from "../lib/i18n" import { activeInterruption, getPermissionQueue, getPermissionEnqueuedAtForInstance, - getQuestionQueue, - getQuestionEnqueuedAtForInstance, sendPermissionResponse, } from "../stores/instances" import { activeSessionId, ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" @@ -103,40 +100,6 @@ function resolveToolCallFromPermission( return null } -function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null { - const sessionId = getQuestionSessionId(request) - const messageId = getQuestionMessageId(request) - if (!sessionId || !messageId) return null - - const store = messageStoreBus.getInstance(instanceId) - if (!store) return null - - const record = store.getMessage(messageId) - if (!record) return null - - const callId = getQuestionCallId(request) - if (!callId) return null - - for (const partId of record.partIds) { - const partRecord = record.parts?.[partId] - const part = partRecord?.data as any - if (!part || part.type !== "tool") continue - const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined - if (partCallId !== callId) continue - - if (typeof part.id !== "string" || part.id.length === 0) continue - return { - messageId, - sessionId, - toolPart: part as ResolvedToolCall["toolPart"], - messageVersion: record.revision, - partVersion: partRecord?.revision ?? 0, - } - } - - return null -} - const PermissionApprovalModal: Component = (props) => { const { t } = useI18n() const [loadingSession, setLoadingSession] = createSignal(null) @@ -198,13 +161,11 @@ const PermissionApprovalModal: Component = (props) } const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId)) - const questionQueue = createMemo(() => getQuestionQueue(props.instanceId)) const formQueue = createMemo(() => getFormQueue(props.instanceId)) const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null) type InterruptionItem = | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequest } - | { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest } | { kind: "form"; id: string; sessionId: string; createdAt: number; payload: FormInfo } const orderedQueue = createMemo(() => { @@ -216,14 +177,6 @@ const PermissionApprovalModal: Component = (props) payload: permission, })) - const questions = questionQueue().map((question) => ({ - kind: "question" as const, - id: question.id, - sessionId: getQuestionSessionId(question) || "", - createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id), - payload: question, - })) - const formsForFallback = formQueue().filter((form) => shouldRenderFormInFallback( form, activeSessionId().get(props.instanceId), @@ -236,7 +189,7 @@ const PermissionApprovalModal: Component = (props) payload: form, })) - return [...permissions, ...questions, ...forms].sort((a, b) => a.createdAt - b.createdAt) + return [...permissions, ...forms].sort((a, b) => a.createdAt - b.createdAt) }) const hasRequests = createMemo(() => orderedQueue().length > 0) @@ -323,8 +276,7 @@ const PermissionApprovalModal: Component = (props) if (item.kind === "permission") { return resolveToolCallFromPermission(props.instanceId, item.payload) } - if (item.kind === "question") return resolveToolCallFromQuestion(props.instanceId, item.payload) - return null + return null }) const showFallback = () => !resolved() @@ -332,28 +284,20 @@ const PermissionApprovalModal: Component = (props) const kindLabel = () => item.kind === "permission" ? t("permissionApproval.kind.permission") - : item.kind === "question" - ? t("permissionApproval.kind.question") - : t("permissionApproval.kind.form") + : t("permissionApproval.kind.form") const primaryTitle = () => { if (item.kind === "permission") { return getPermissionDisplayTitle(item.payload) } - if (item.kind === "form") return item.payload.title - const first = item.payload.questions?.[0]?.question - return typeof first === "string" && first.trim().length > 0 ? first : t("permissionApproval.kind.question") + return item.payload.title } const secondaryTitle = () => { if (item.kind === "permission") { return getPermissionKind(item.payload) } - if (item.kind === "form") return t("permissionApproval.kind.form") - const count = item.payload.questions?.length ?? 0 - return count === 1 - ? t("permissionApproval.questionCount.one", { count }) - : t("permissionApproval.questionCount.other", { count }) + return t("permissionApproval.kind.form") } return ( @@ -443,9 +387,6 @@ const PermissionApprovalModal: Component = (props) {(err) =>
{err()}
} - -
{t("permissionApproval.fallbackHint")}
-
} > diff --git a/packages/ui/src/components/permission-notification-banner.tsx b/packages/ui/src/components/permission-notification-banner.tsx index 6531555a..459a068b 100644 --- a/packages/ui/src/components/permission-notification-banner.tsx +++ b/packages/ui/src/components/permission-notification-banner.tsx @@ -1,7 +1,7 @@ import { Show, createMemo, type Component } from "solid-js" import { ShieldAlert } from "lucide-solid" import { useI18n } from "../lib/i18n" -import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances" +import { getPermissionQueueLength } from "../stores/instances" import { getFormQueue } from "../stores/forms" interface PermissionNotificationBannerProps { @@ -12,9 +12,8 @@ interface PermissionNotificationBannerProps { const PermissionNotificationBanner: Component = (props) => { const { t } = useI18n() const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId)) - const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId)) const formCount = createMemo(() => getFormQueue(props.instanceId).length) - const queueLength = createMemo(() => permissionCount() + questionCount() + formCount()) + const queueLength = createMemo(() => permissionCount() + formCount()) const hasRequests = createMemo(() => queueLength() > 0) const label = createMemo(() => { const total = queueLength() @@ -33,14 +32,6 @@ const PermissionNotificationBanner: Component ) } - if (questionCount() > 0) { - parts.push( - questionCount() === 1 - ? t("permissionBanner.detail.question.one", { count: questionCount() }) - : t("permissionBanner.detail.question.other", { count: questionCount() }), - ) - } - if (formCount() > 0) { parts.push( formCount() === 1 diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 1abb795a..38ca8ce6 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -560,7 +560,7 @@ const SessionList: Component = (props) => { } } const needsPermission = () => Boolean(rowProps.session.pendingPermission) - const needsQuestion = () => Boolean(rowProps.session.pendingQuestion || rowProps.session.pendingForm) + const needsQuestion = () => Boolean(rowProps.session.pendingForm) const needsInput = () => needsPermission() || needsQuestion() const statusClassName = () => { if (needsInput()) return "session-permission" diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 9504caa0..48cb3918 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -66,7 +66,7 @@ export const SessionView: Component = (props) => { const sessionNeedsInput = createMemo(() => { const currentSession = session() if (!currentSession) return false - return Boolean(currentSession.pendingPermission || currentSession.pendingQuestion || currentSession.pendingForm) + return Boolean(currentSession.pendingPermission || currentSession.pendingForm) }) const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId)) diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 3d74b696..5ee059a1 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -5,16 +5,14 @@ import { messageStoreBus } from "../stores/message-v2/bus" import { useTheme } from "../lib/theme" import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useConfig } from "../stores/preferences" -import { activeInterruption, sendFormCancel, sendFormReply, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances" +import { activeInterruption, sendFormCancel, sendFormReply, sendPermissionResponse } from "../stores/instances" import { getFormQueue, type FormInfo } from "../stores/forms" import { copyToClipboard } from "../lib/clipboard" import type { PermissionRequest } from "../types/permission" import { getPermissionSessionId } from "../types/permission" -import type { QuestionRequest } from "../types/question" import { useI18n } from "../lib/i18n" import { resolveToolRenderer } from "./tool-call/renderers" import { resolveToolExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" -import { QuestionToolBlock } from "./tool-call/question-block" import { PermissionToolBlock } from "./tool-call/permission-block" import FormRequest from "./form-request" import { resolveFormToolTarget } from "./form-request-tool-target" @@ -124,9 +122,7 @@ function ToolCallDetails(props: { t: ReturnType["t"] store: () => ReturnType pendingPermission: () => { permission: PermissionRequest; active: boolean } | undefined - pendingQuestion: () => { request: QuestionRequest; active: boolean } | undefined isPermissionActive: () => boolean - isQuestionActive: () => boolean hasToolInput: () => boolean isToolInputVisible: () => boolean toolInput: () => Record | undefined @@ -178,18 +174,12 @@ function ToolCallDetails(props: { const ansiFinalCache = createVariantCache("ansi-final") const permissionDetails = createMemo(() => props.pendingPermission()?.permission) - const questionDetails = createMemo(() => props.pendingQuestion()?.request) const activePermissionKey = createMemo(() => { const permission = permissionDetails() return permission && props.isPermissionActive() ? permission.id : "" }) - const activeQuestionKey = createMemo(() => { - const request = questionDetails() - return request && props.isQuestionActive() ? request.id : "" - }) - const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionError, setPermissionError] = createSignal(null) @@ -223,7 +213,7 @@ function ToolCallDetails(props: { }) createEffect(() => { - const activeKey = activePermissionKey() || activeQuestionKey() + const activeKey = activePermissionKey() if (!activeKey) return requestAnimationFrame(() => { props.toolCallRootEl()?.scrollIntoView({ block: "center", behavior: "smooth" }) @@ -265,10 +255,6 @@ function ToolCallDetails(props: { onCleanup(() => document.removeEventListener("keydown", handler)) }) - const [questionSubmitting, setQuestionSubmitting] = createSignal(false) - const [questionError, setQuestionError] = createSignal(null) - const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal>({}) - function isTextInputFocused() { const active = document.activeElement return ( @@ -278,83 +264,6 @@ function ToolCallDetails(props: { ) } - async function handleQuestionSubmit() { - const request = questionDetails() - if (!request || !props.isQuestionActive()) { - return - } - const answers = (questionDraftAnswers()[request.id] ?? []).map((x) => (Array.isArray(x) ? x : [])) - const normalized = request.questions.map((_, index) => { - const row = answers[index] ?? [] - return row.map((value) => value.trim()).filter((value) => value.length > 0) - }) - if (normalized.some((item) => (item?.length ?? 0) === 0)) { - setQuestionError(props.t("toolCall.question.validation.answerAll")) - return - } - - setQuestionSubmitting(true) - setQuestionError(null) - try { - await sendQuestionReply(props.instanceId, request.sessionID, request.id, normalized) - } catch (error) { - log.error("Failed to send question reply", error) - setQuestionError(error instanceof Error ? error.message : props.t("toolCall.question.errors.unableToReply")) - } finally { - setQuestionSubmitting(false) - } - } - - async function handleQuestionDismiss() { - const request = questionDetails() - if (!request || !props.isQuestionActive()) { - return - } - setQuestionSubmitting(true) - setQuestionError(null) - try { - await sendQuestionReject(props.instanceId, request.sessionID, request.id) - } catch (error) { - log.error("Failed to reject question", error) - setQuestionError(error instanceof Error ? error.message : props.t("toolCall.question.errors.unableToDismiss")) - } finally { - setQuestionSubmitting(false) - } - } - - createEffect(() => { - const activeKey = activeQuestionKey() - if (!activeKey) return - const handler = (event: KeyboardEvent) => { - if (isTextInputFocused()) return - if (event.key === "Enter") { - event.preventDefault() - void handleQuestionSubmit() - } else if (event.key === "Escape") { - event.preventDefault() - void handleQuestionDismiss() - } - } - document.addEventListener("keydown", handler) - onCleanup(() => document.removeEventListener("keydown", handler)) - }) - - createEffect(() => { - const request = questionDetails() - if (!request) { - setQuestionSubmitting(false) - setQuestionError(null) - return - } - setQuestionError(null) - const requestId = request.id - setQuestionDraftAnswers((prev) => { - if (prev[requestId]) return prev - const initial = request.questions.map(() => []) - return { ...prev, [requestId]: initial } - }) - }) - const status = () => props.toolState()?.status || "" const toolInputDisplay = createMemo((): { content: string; copyText: string; language: string } | null => { @@ -497,22 +406,6 @@ function ToolCallDetails(props: { /> ) - const renderQuestionBlock = () => ( - void handleQuestionSubmit()} - onDismiss={() => void handleQuestionDismiss()} - /> - ) - const shouldShowPendingMessage = () => { const tool = props.toolName() return status() === "pending" && !props.pendingPermission() && tool !== "todowrite" @@ -702,7 +595,6 @@ function ToolCallDetails(props: { {renderPermissionBlock()} - {renderQuestionBlock()} ) } @@ -738,15 +630,6 @@ export default function ToolCall(props: ToolCallProps) { return toolCallMemo()?.pendingPermission }) - const questionState = createMemo(() => store().getQuestionState(props.messageId, toolCallIdentifier())) - const pendingQuestion = createMemo(() => { - const state = questionState() - if (state) { - return { request: state.entry.request as QuestionRequest, active: state.active } - } - return undefined - }) - const pendingForm = createMemo(() => getFormQueue(props.instanceId).find((form) => { if (form.sessionID !== props.sessionId || !props.messageId) return false const target = resolveFormToolTarget(form, store()) @@ -796,20 +679,12 @@ export default function ToolCall(props: ToolCallProps) { return active?.kind === "permission" && active.id === pending.permission.id }) - const isQuestionActive = createMemo(() => { - const pending = pendingQuestion() - if (!pending?.request) return false - if (pending.active) return true - const active = activeRequest() - return active?.kind === "question" && active.id === pending.request.id - }) - const hasPendingForm = createMemo(() => Boolean(pendingForm())) - const isToolVisible = createMemo(() => toolVisibility() !== "hidden" || isPermissionActive() || isQuestionActive() || hasPendingForm()) + const isToolVisible = createMemo(() => toolVisibility() !== "hidden" || isPermissionActive() || hasPendingForm()) const expanded = () => { - if (isPermissionActive() || isQuestionActive() || hasPendingForm()) return true + if (isPermissionActive() || hasPendingForm()) return true const override = userExpanded() if (override !== null) return override return defaultExpandedForTool() @@ -830,7 +705,7 @@ export default function ToolCall(props: ToolCallProps) { const [diagnosticsOverride, setDiagnosticsOverride] = createSignal(undefined) const diagnosticsExpanded = () => { - if (isPermissionActive() || isQuestionActive() || hasPendingForm()) return true + if (isPermissionActive() || hasPendingForm()) return true const override = diagnosticsOverride() if (override !== undefined) return override return diagnosticsDefaultExpanded() @@ -864,7 +739,7 @@ export default function ToolCall(props: ToolCallProps) { const combinedStatusClass = () => { const base = statusClass() - return pendingPermission() || pendingQuestion() || pendingForm() ? `${base} tool-call-awaiting-permission` : base + return pendingPermission() || pendingForm() ? `${base} tool-call-awaiting-permission` : base } function toggle() { @@ -1168,9 +1043,7 @@ export default function ToolCall(props: ToolCallProps) { t={t} store={store} pendingPermission={pendingPermission} - pendingQuestion={pendingQuestion} isPermissionActive={isPermissionActive} - isQuestionActive={isQuestionActive} hasToolInput={hasToolInput} isToolInputVisible={isToolInputVisible} toolInput={toolInput} diff --git a/packages/ui/src/components/tool-call/question-block.tsx b/packages/ui/src/components/tool-call/question-block.tsx deleted file mode 100644 index c2e6f399..00000000 --- a/packages/ui/src/components/tool-call/question-block.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { createMemo, Show, For, createEffect, type Accessor } from "solid-js" -import type { ToolState } from "../../types/tool-state" -import type { QuestionRequest } from "../../types/question" -import { useI18n } from "../../lib/i18n" - -type QuestionOption = { label: string; description: string } - -type QuestionPrompt = { - header: string - question: string - options: QuestionOption[] - multiple?: boolean -} - -export type QuestionToolBlockProps = { - toolName: Accessor - toolState: Accessor - toolCallId: Accessor - request: Accessor - active: Accessor - submitting: Accessor - error: Accessor - draftAnswers: Accessor> - setDraftAnswers: (updater: (prev: Record) => Record) => void - onSubmit: () => void | Promise - onDismiss: () => void | Promise -} - -export function QuestionToolBlock(props: QuestionToolBlockProps) { - const { t } = useI18n() - let firstInputRef: HTMLInputElement | undefined - - createEffect(() => { - if (props.active() && firstInputRef) { - firstInputRef.focus() - } - }) - - const requestId = createMemo(() => { - const state = props.toolState() - const request = props.request() - return request?.id ?? (state as any)?.input?.requestID ?? `question-${props.toolCallId()}` - }) - - const questions = createMemo(() => { - const state = props.toolState() - const request = props.request() - const isQuestionTool = props.toolName() === "question" - if (!request && !isQuestionTool) return [] as QuestionPrompt[] - - const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? [] - const list = Array.isArray(questionsSource) ? questionsSource : [] - return list as QuestionPrompt[] - }) - - const isVisible = createMemo(() => { - const request = props.request() - const isQuestionTool = props.toolName() === "question" - return Boolean(request) || isQuestionTool - }) - - const answers = createMemo(() => { - const state = props.toolState() - - const completedAnswers = - (state as any)?.status === "completed" && Array.isArray((state as any)?.metadata?.answers) - ? ((state as any).metadata.answers as string[][]) - : undefined - - if (completedAnswers) return completedAnswers - - const request = props.request() - const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) // defensive (if server ever inlines) - - if (Array.isArray(requestAnswers) && requestAnswers.some((row) => Array.isArray(row) && row.length > 0)) { - return requestAnswers as string[][] - } - - const draft = props.draftAnswers()[requestId()] ?? [] - return Array.isArray(draft) ? draft : [] - }) - - const hasFinalAnswers = createMemo(() => { - const state = props.toolState() - if ((state as any)?.status === "completed") return true - - const request = props.request() - const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) - if (Array.isArray(requestAnswers) && requestAnswers.length > 0) { - return requestAnswers.every((row) => Array.isArray(row) && row.length > 0) - } - - return false - }) - - const updateAnswer = (questionIndex: number, next: string[]) => { - if (!props.active()) return - props.setDraftAnswers((prev) => { - const current = prev[requestId()] ?? [] - const updated = [...current] - updated[questionIndex] = next - return { ...prev, [requestId()]: updated } - }) - } - - const toggleOption = (questionIndex: number, label: string) => { - const info = questions()[questionIndex] - const multi = info?.multiple === true - const existing = answers()[questionIndex] ?? [] - if (multi) { - const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label] - updateAnswer(questionIndex, next) - return - } - updateAnswer(questionIndex, [label]) - } - - const submitDisabled = () => { - if (!props.active()) return true - if (props.submitting()) return true - return questions().some((_, index) => (answers()[index]?.length ?? 0) === 0) - } - - const toggleFromCustomInput = (questionIndex: number, input: HTMLInputElement | null) => { - if (!props.active()) return - const rawValue = input?.value ?? "" - const value = rawValue - if (value.trim().length === 0) return - - const info = questions()[questionIndex] - const multi = info?.multiple === true - if (!multi) { - // When switching a radio to custom, clear existing selection first. - updateAnswer(questionIndex, []) - toggleOption(questionIndex, value) - return - } - - // For multi-select, focusing the input should never toggle an existing custom value off. - // Ensure the current input value is selected; removal is handled by unchecking Custom. - const existing = answers()[questionIndex] ?? [] - if (!existing.includes(value)) { - updateAnswer(questionIndex, [...existing, value]) - } - } - - const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => { - if (!props.active()) return - if (valuesToRemove.length === 0) return - const existing = answers()[questionIndex] ?? [] - const next = existing.filter((value) => !valuesToRemove.includes(value)) - updateAnswer(questionIndex, next) - } - - const handleCustomTyping = (questionIndex: number, input: HTMLInputElement) => { - if (!props.active()) return - - const value = input.value - const trimmed = value.trim() - const info = questions()[questionIndex] - const multi = info?.multiple === true - - if (!multi) { - updateAnswer(questionIndex, trimmed.length > 0 ? [value] : []) - return - } - - const optionLabels = new Set((info?.options ?? []).map((opt) => opt.label)) - const existing = answers()[questionIndex] ?? [] - const last = input.dataset.lastValue ?? "" - - let next = existing.filter((item) => item !== last) - - if (trimmed.length > 0) { - // Only treat it as custom if it doesn't match an existing option label. - if (!optionLabels.has(trimmed) && !next.includes(value)) { - next = [...next, value] - } else if (optionLabels.has(trimmed)) { - // If they typed an existing option label, don't treat it as custom. - } else if (!next.includes(value)) { - next = [...next, value] - } - input.dataset.lastValue = value - } else { - delete input.dataset.lastValue - } - - updateAnswer(questionIndex, next) - } - - return ( - 0}> -
-
-
- - {(q, index) => { - const i = () => index() - const multi = () => q?.multiple === true - const selected = () => answers()[i()] ?? [] - const inputType = () => (multi() ? "checkbox" : "radio") - const groupName = () => `question-${requestId()}-${i()}` - const optionLabels = () => new Set((q?.options ?? []).map((opt) => opt.label)) - const customSelected = () => selected().filter((value) => !optionLabels().has(value)) - const customValue = () => customSelected()[0] ?? "" - const customChecked = () => customValue().length > 0 - const showCustomAnswer = () => !hasFinalAnswers() || customChecked() - - return ( -
-
-
- {t("toolCall.question.number", { number: i() + 1 })} {q?.header} -
- -
{t("toolCall.question.multiple")}
-
-
- -
{q?.question}
- -
- - {(opt, optIndex) => { - const checked = () => selected().includes(opt.label) - return ( - - ) - }} - - - - - -
-
- ) - }} -
- - -
-
- - -
- -
- Enter - {t("toolCall.question.shortcuts.submit")} - Esc - {t("toolCall.question.shortcuts.dismiss")} -
- - -
{props.error()}
-
-
-
- - -

{t("toolCall.question.queuedText")}

-
-
-
-
-
- ) -} diff --git a/packages/ui/src/lib/hooks/foreground-refresh-controller.ts b/packages/ui/src/lib/hooks/foreground-refresh-controller.ts index 556fb37f..41e9fd49 100644 --- a/packages/ui/src/lib/hooks/foreground-refresh-controller.ts +++ b/packages/ui/src/lib/hooks/foreground-refresh-controller.ts @@ -60,6 +60,10 @@ export function createForegroundRefreshController( } return { + invalidate() { + dirtyGeneration += 1 + void runRefresh() + }, handle(status: WorkspaceEventTransportStatus) { if (status === "disconnected") { connected = false diff --git a/packages/ui/src/lib/hooks/use-app-session-restore.ts b/packages/ui/src/lib/hooks/use-app-session-restore.ts index a1b1c5ef..761e18f8 100644 --- a/packages/ui/src/lib/hooks/use-app-session-restore.ts +++ b/packages/ui/src/lib/hooks/use-app-session-restore.ts @@ -113,19 +113,14 @@ async function restoreTabs(context: RestoreContext): Promise { try { const instanceId = await runAbortable(async (operationSignal) => { const existingId = match.existingWorkspaceId - const create = (forceNew: boolean) => createInstance(tab.folder, tab.projectName, { - activate: false, signal: operationSignal, forceNew, + const create = () => createInstance(tab.folder, tab.projectName, { + activate: false, signal: operationSignal, waitForCreateCommit: waitForCreateCommit ? () => waitForCreateCommit : undefined, shouldCreateCommit: canCommitCreation, onBeforeCreateCommit: (id) => seedRestoredWorkspaceScrollSnapshots(id, tab), onCreateCommit: (id) => capture.recordRestoredTab(match.tabIndex, getInstanceAppTabId(id)), }) - let creation = existingId || isWebHost() ? null : await create(match.descriptor.occurrence > 0) - if (creation && claimedIds.has(creation.instanceId)) { - if (operationSignal.aborted) throw getAbortReason(operationSignal) - if (creation.requestId) await cancelRestoreCreationRequest(creation.instanceId, creation.requestId) - creation = await create(true) - } + const creation = existingId || isWebHost() ? null : await create() if (creation && finishCreateCommit) await waitForWorkspaceMountAdoption() finishCreateCommit?.() const id = existingId ?? creation?.instanceId ?? null diff --git a/packages/ui/src/lib/hooks/use-foreground-refresh.test.ts b/packages/ui/src/lib/hooks/use-foreground-refresh.test.ts index f89bb7ef..b0f683d8 100644 --- a/packages/ui/src/lib/hooks/use-foreground-refresh.test.ts +++ b/packages/ui/src/lib/hooks/use-foreground-refresh.test.ts @@ -33,6 +33,16 @@ describe("foreground refresh controller", () => { controller.dispose() }) + it("refreshes when the internal stream generation changes", async () => { + let calls = 0 + const controller = createForegroundRefreshController(() => { calls += 1 }) + controller.handle("connected") + controller.invalidate() + await tick() + assert.equal(calls, 1) + controller.dispose() + }) + it("runs a trailing refresh when another gap happens during recovery", async () => { const first = deferred() let calls = 0 diff --git a/packages/ui/src/lib/hooks/use-foreground-refresh.ts b/packages/ui/src/lib/hooks/use-foreground-refresh.ts index 7f1f5781..53ed21b7 100644 --- a/packages/ui/src/lib/hooks/use-foreground-refresh.ts +++ b/packages/ui/src/lib/hooks/use-foreground-refresh.ts @@ -41,10 +41,18 @@ export function useForegroundRefresh(options: ForegroundRefreshOptions): void { } controller.handle(status) }) + const streamGenerations = new Map() + const unsubscribeGeneration = serverEvents.on("instance.eventStatus", (event) => { + if (event.type !== "instance.eventStatus" || event.status !== "connected") return + const previous = streamGenerations.get(event.instanceId) + streamGenerations.set(event.instanceId, event.generation) + if (previous !== undefined && previous !== event.generation) controller.invalidate() + }) onCleanup(() => { controller.dispose() unsubscribe() + unsubscribeGeneration() }) }) } diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index f47af465..ebf9d28c 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -2,9 +2,6 @@ import { createSignal } from "solid-js" import type { PermissionAsked, PermissionReplied, - QuestionAsked, - QuestionRejected, - QuestionReplied, SessionCompactionEnded, SessionCreated, SessionDeleted, @@ -67,9 +64,6 @@ type SSEEvent = | SessionStatus2 | PermissionAsked | PermissionReplied - | QuestionAsked - | QuestionReplied - | QuestionRejected | TuiToastShow | ServerInstanceDisposedEvent | WorktreeReadyEvent @@ -156,13 +150,6 @@ class SSEManager { case "permission.replied": this.onPermissionReplied?.(instanceId, event as PermissionReplied) break - case "question.asked": - this.onQuestionAsked?.(instanceId, event as QuestionAsked) - break - case "question.replied": - case "question.rejected": - this.onQuestionAnswered?.(instanceId, event as QuestionReplied | QuestionRejected) - break case "server.instance.disposed": this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent) break @@ -198,8 +185,6 @@ class SSEManager { onSessionStatus?: (instanceId: string, event: SessionStatus2) => void onPermissionUpdated?: (instanceId: string, event: PermissionAsked) => void onPermissionReplied?: (instanceId: string, event: PermissionReplied) => void - onQuestionAsked?: (instanceId: string, event: QuestionAsked) => void - onQuestionAnswered?: (instanceId: string, event: QuestionReplied | QuestionRejected) => void onNativeSessionEvent?: (instanceId: string, event: NativeSessionEvent) => void onInvalidation?: (instanceId: string, event: V2Event) => void onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 79e73dc4..97841bb4 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -2,8 +2,6 @@ import { createSignal } from "solid-js" import type { Instance, LogEntry } from "../types/instance" import type { PermissionReply, PermissionRequest } from "../types/permission" import { getPermissionSessionId, mergePermissionRequest } from "../types/permission" -import type { QuestionRequest } from "../types/question" -import { getQuestionSessionId } from "../types/question" import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { serverApi } from "../lib/api-client" @@ -39,12 +37,11 @@ import { sessions, setSessionPendingForm, setSessionPendingPermission, - setSessionPendingQuestion, } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" -import { clearNativeContentDeltaState } from "./native-session-streaming" -import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge" +import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data" +import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge" import { clearRepliedPermissions, hasRepliedPermission, @@ -130,12 +127,10 @@ const [activeInstanceId, setActiveInstanceId] = createSignal(null const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) -// Interruption queues (permissions + questions) per instance +// Interruption queues per instance const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) -const [questionQueues, setQuestionQueues] = createSignal>(new Map()) -const [activeQuestionId, setActiveQuestionId] = createSignal>(new Map()) class InterruptionRegistry { private readonly enqueuedAt = new Map() private readonly sessionCounts = new Map>() @@ -183,10 +178,9 @@ class InterruptionRegistry { } const permissionRegistry = new InterruptionRegistry() -const questionRegistry = new InterruptionRegistry() const formRegistry = new InterruptionRegistry() -type InterruptionKind = "permission" | "question" | "form" +type InterruptionKind = "permission" | "form" type ActiveInterruption = { kind: InterruptionKind; id: string } | null @@ -213,7 +207,6 @@ const initialSessionHydrations = new Map>() const initialWorkspaceMetadataHydrations = new Map>() const pendingRequestSyncEpochs = new Map() const pendingPermissionMutationEpochs = new Map() -const pendingQuestionMutationEpochs = new Map() const pendingFormMutationEpochs = new Map() const pendingRequestSyncGenerations = new Map() const pendingRequestSyncs = new Map, instanceId: string): void { function invalidatePendingRequestSync(instanceId: string): void { bumpEpoch(pendingRequestSyncEpochs, instanceId) bumpEpoch(pendingPermissionMutationEpochs, instanceId) - bumpEpoch(pendingQuestionMutationEpochs, instanceId) bumpEpoch(pendingFormMutationEpochs, instanceId) } type RestoreWorkspaceDescriptor = WorkspaceDescriptor & { reused?: boolean } @@ -356,7 +348,6 @@ function reconcilePendingSessionIndicators(instanceId: string): void { reconcileSessionPendingState( instanceId, new Set(permissionRegistry.sessionIds(instanceId)), - new Set(questionRegistry.sessionIds(instanceId)), new Set(formRegistry.sessionIds(instanceId)), ) } @@ -511,6 +502,7 @@ function releaseInstanceResources(instanceId: string) { if (instance.client) { sdkManager.destroyClientsForInstance(instanceId) } + destroyOpenCodeData(instanceId) sseManager.seedStatus(instanceId, "disconnected") } @@ -563,51 +555,6 @@ async function syncPendingPermissions( } } -async function syncPendingQuestions( - instanceId: string, - propagateErrors = false, - isCurrent: () => boolean = () => true, -): Promise { - const instance = instances().get(instanceId) - if (!instance?.client) return - const mutationEpoch = pendingQuestionMutationEpochs.get(instanceId) ?? 0 - - try { - const remote: QuestionRequest[] = [] - for (const location of buildV2RequestLocations(instance.folder, getWorktrees(instanceId))) { - const response = await instance.client.question.request.list({ location }) - log.info("question.request.list", { instanceId, location, resolvedLocation: response.location }) - remote.push(...response.data) - } - - const remoteIds = new Set(remote.map((request) => request.id)) - if (!isCurrent() || (pendingQuestionMutationEpochs.get(instanceId) ?? 0) !== mutationEpoch) { - if (propagateErrors) throw pendingRequestSyncSuperseded - return - } - const local = getQuestionQueue(instanceId) - - // Remove any stale local requests missing from server. - for (const entry of local) { - if (!remoteIds.has(entry.id)) { - removeQuestionFromQueue(instanceId, entry.id) - removeQuestionV2(instanceId, entry.id) - } - } - - // Upsert all server-side pending questions. - for (const request of remote) { - questionRegistry.ensureEnqueuedAt(request) - addQuestionToQueue(instanceId, request) - upsertQuestionV2(instanceId, request) - } - reconcilePendingSessionIndicators(instanceId) - } catch (error) { - log.warn("Failed to sync pending questions", { instanceId, error }) - if (propagateErrors) throw error - } -} - async function syncPendingForms( instanceId: string, propagateErrors = false, @@ -649,7 +596,6 @@ async function runPendingRequestSync( try { await Promise.all([ syncPendingPermissions(instanceId, true, isCurrent), - syncPendingQuestions(instanceId, true, isCurrent), syncPendingForms(instanceId, true, isCurrent), ]) return @@ -781,7 +727,6 @@ function clearReloadableInstanceState(instanceId: string): void { clearInstanceMetadata(instanceId) messageStoreBus.clearInstanceScrollSnapshots(instanceId) clearPermissionQueue(instanceId) - clearQuestionQueue(instanceId) clearPendingFormQueue(instanceId) } @@ -1112,7 +1057,6 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) { clearCommands(id) clearPermissionQueue(id) clearRepliedPermissions(id) - clearQuestionQueue(id) clearPendingFormQueue(id) clearInstanceMetadata(id) clearPermissionAutoAcceptForInstance(id) @@ -1130,7 +1074,6 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) { // Clean up session indexes and drafts for removed instance clearCacheForInstance(id) - clearNativeContentDeltaState(id) messageStoreBus.unregisterInstance(id) clearInstanceDraftPrompts(id) clearSessionListRequestState(id) @@ -1215,7 +1158,6 @@ async function createInstance( onBeforeCreateCommit?: (instanceId: string) => void onCreateCommit?: (instanceId: string) => void waitForCreateCommit?: () => Promise - forceNew?: boolean }, ): Promise<{ instanceId: string; reused: boolean; requestId?: string }> { const restoreRequestId = options?.signal ? createRestoreCreationRequestId() : undefined @@ -1245,7 +1187,6 @@ async function createInstance( path: folder, name: projectName, requestId: restoreRequestId, - forceNew: options?.forceNew, }, { signal: options?.signal }) requestResolved = true const reused = workspace.reused === true @@ -1431,28 +1372,6 @@ function hasPendingPermission(instanceId: string, permissionId: string): boolean return getPermissionQueue(instanceId).some((permission) => permission.id === permissionId) } -function getQuestionQueue(instanceId: string): QuestionRequest[] { - const queue = questionQueues().get(instanceId) - if (!queue) { - return [] - } - return queue -} - -function getQuestionQueueLength(instanceId: string): number { - return getQuestionQueue(instanceId).length -} - -function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string): number { - // Ensure we have a stable timestamp for sorting/ordering. - const queue = getQuestionQueue(instanceId) - const match = queue.find((q) => q.id === requestId) - if (match) { - return questionRegistry.ensureEnqueuedAt(match) - } - return questionRegistry.enqueuedAtFor(requestId) -} - function getPermissionEnqueuedAtForInstance(instanceId: string, permissionId: string): number { const queue = getPermissionQueue(instanceId) const match = queue.find((permission) => permission.id === permissionId) @@ -1464,7 +1383,6 @@ function getPermissionEnqueuedAtForInstance(instanceId: string, permissionId: st function computeActiveInterruption(instanceId: string): ActiveInterruption { const permissions = getPermissionQueue(instanceId) - const questions = getQuestionQueue(instanceId) const forms = getFormQueue(instanceId) const candidates: Array<{ kind: InterruptionKind; id: string; enqueuedAt: number }> = [] if (permissions[0]) candidates.push({ @@ -1472,11 +1390,6 @@ function computeActiveInterruption(instanceId: string): ActiveInterruption { id: permissions[0].id, enqueuedAt: permissionRegistry.ensureEnqueuedAt(permissions[0]), }) - if (questions[0]) candidates.push({ - kind: "question", - id: questions[0].id, - enqueuedAt: questionRegistry.ensureEnqueuedAt(questions[0]), - }) if (forms[0]) candidates.push({ kind: "form", id: forms[0].id, @@ -1508,15 +1421,6 @@ function setActiveInterruptionForInstance(instanceId: string, nextActive: Active return next }) - setActiveQuestionId((prev) => { - const next = new Map(prev) - if (nextActive?.kind === "question") { - next.set(instanceId, nextActive.id) - } else { - next.set(instanceId, null) - } - return next - }) } function recomputeActiveInterruption(instanceId: string): void { @@ -1686,142 +1590,10 @@ function clearPermissionQueue(instanceId: string): void { recomputeActiveInterruption(instanceId) } -function addQuestionToQueue(instanceId: string, request: QuestionRequest): void { - bumpEpoch(pendingQuestionMutationEpochs, instanceId) - let inserted = false - setQuestionQueues((prev) => { - const next = new Map(prev) - const queue = next.get(instanceId) ?? ([] as QuestionRequest[]) - - if (queue.some((q) => q.id === request.id)) { - return next - } - - questionRegistry.ensureEnqueuedAt(request) - const updatedQueue = [...queue, request].sort((a, b) => { - return questionRegistry.ensureEnqueuedAt(a) - questionRegistry.ensureEnqueuedAt(b) - }) - next.set(instanceId, updatedQueue) - inserted = true - return next - }) - - if (!inserted) { - return - } - - recomputeActiveInterruption(instanceId) - - const sessionId = getQuestionSessionId(request) - if (sessionId) { - questionRegistry.increment(instanceId, sessionId) - setSessionPendingQuestion(instanceId, sessionId, true) - - } -} - -function removeQuestionFromQueue(instanceId: string, requestId: string): void { - bumpEpoch(pendingQuestionMutationEpochs, instanceId) - const removedSessionId = getQuestionSessionId(getQuestionQueue(instanceId).find((q) => q.id === requestId)) - - setQuestionQueues((prev) => { - const next = new Map(prev) - const queue = next.get(instanceId) ?? ([] as QuestionRequest[]) - const filtered = queue.filter((item) => item.id !== requestId) - - if (filtered.length > 0) { - next.set(instanceId, filtered) - } else { - next.delete(instanceId) - } - return next - }) - - questionRegistry.remove(instanceId, requestId) - recomputeActiveInterruption(instanceId) - - if (removedSessionId) { - const remaining = questionRegistry.decrement(instanceId, removedSessionId) - setSessionPendingQuestion(instanceId, removedSessionId, remaining > 0) - } -} - -function clearQuestionQueue(instanceId: string): void { - bumpEpoch(pendingQuestionMutationEpochs, instanceId) - questionRegistry.clear(instanceId, getQuestionQueue(instanceId), (sessionId) => { - setSessionPendingQuestion(instanceId, sessionId, false) - }) - setQuestionQueues((prev) => { - const next = new Map(prev) - next.delete(instanceId) - return next - }) - setActiveQuestionId((prev) => { - const next = new Map(prev) - next.delete(instanceId) - return next - }) - recomputeActiveInterruption(instanceId) -} - function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void { setActiveInterruptionForInstance(instanceId, { kind: "permission", id: permissionId }) } -function setActiveQuestionIdForInstance(instanceId: string, requestId: string): void { - setActiveInterruptionForInstance(instanceId, { kind: "question", id: requestId }) -} - -async function sendQuestionReply( - instanceId: string, - _sessionId: string, - requestId: string, - answers: string[][], -): Promise { - const instance = instances().get(instanceId) - if (!instance?.client) { - throw new Error("Instance not ready") - } - - try { - const request = getQuestionQueue(instanceId).find((entry) => entry.id === requestId) - if (!request) throw new Error(`Question request not found: ${requestId}`) - await getRootClient(instanceId).question.reply({ - sessionID: request.sessionID, - requestID: requestId, - answers, - }) - - removeQuestionFromQueue(instanceId, requestId) - removeQuestionV2(instanceId, requestId) - } catch (error) { - log.error("Failed to send question reply", error) - throw error - } -} - -async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise { - const instance = instances().get(instanceId) - if (!instance?.client) { - throw new Error("Instance not ready") - } - - try { - const request = getQuestionQueue(instanceId).find((entry) => entry.id === requestId) - if (!request) throw new Error(`Question request not found: ${requestId}`) - await getRootClient(instanceId).question.reject({ - sessionID: request.sessionID, - requestID: requestId, - }) - - removeQuestionFromQueue(instanceId, requestId) - removeQuestionV2(instanceId, requestId) - } catch (error) { - log.error("Failed to send question reject", error) - throw error - } -} - async function sendPermissionResponse( instanceId: string, _sessionId: string, @@ -1925,6 +1697,35 @@ async function sendFormCancel(instanceId: string, formId: string): Promise function handleInstanceInvalidation(instanceId: string, event: Parameters>[1]): void { const instance = instances().get(instanceId) if (!instance?.client) return + const data = applyOpenCodeDataEvent(instanceId, instance.folder, event) + const sessionId = "sessionID" in event.data && typeof event.data.sessionID === "string" + ? event.data.sessionID + : event.type === "form.created" + ? event.data.form.sessionID + : undefined + if (sessionId && event.type.startsWith("session.")) projectOpenCodeMessages(instanceId, sessionId, data) + if (sessionId && (event.type === "permission.asked" || event.type === "permission.replied")) { + const remote = (data.session.permission.list(sessionId) ?? []).filter((permission) => !hasRepliedPermission(instanceId, permission.id)) + const ids = new Set(remote.map((permission) => permission.id)) + for (const permission of getPermissionQueue(instanceId)) { + if (permission.sessionID === sessionId && !ids.has(permission.id)) { + removePermissionFromQueue(instanceId, permission.id) + removePermissionV2(instanceId, permission.id) + } + } + for (const permission of remote) { + const queued = addPermissionToQueue(instanceId, permission) ?? permission + upsertPermissionV2(instanceId, queued) + } + } + if (sessionId && event.type.startsWith("form.")) { + const remote = data.session.form.list(sessionId) ?? [] + const ids = new Set(remote.map((form) => form.id)) + for (const form of getFormQueue(instanceId)) { + if (form.sessionID === sessionId && !ids.has(form.id)) removePendingForm(instanceId, form.id) + } + for (const form of remote) addPendingForm(instanceId, form) + } const targets = getInstanceRefreshTargets(event.type) if (targets.length) void refreshVolatileInstanceState(instanceId, targets) } @@ -2010,7 +1811,7 @@ export { getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming, - // Permission + question management + // Permission and form management permissionQueues, activePermissionId, getPermissionQueue, @@ -2024,23 +1825,12 @@ export { clearPermissionQueue, sendPermissionResponse, setActivePermissionIdForInstance, - questionQueues, - activeQuestionId, activeInterruption, - getQuestionQueue, - getQuestionQueueLength, - getQuestionEnqueuedAtForInstance, - addQuestionToQueue, - removeQuestionFromQueue, - clearQuestionQueue, - sendQuestionReply, - sendQuestionReject, sendFormReply, sendFormCancel, addPendingForm, removePendingForm, setPendingFormAddedHandler, - setActiveQuestionIdForInstance, disconnectedInstance, acknowledgeDisconnectedInstance, disposeInstance, diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 8774f4e1..b42185c3 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -1,7 +1,5 @@ import type { PermissionRequest } from "../../types/permission" import { getPermissionCallId, getPermissionMessageId, getPermissionSessionId } from "../../types/permission" -import type { QuestionRequest } from "../../types/question" -import { getQuestionCallId, getQuestionMessageId } from "../../types/question" import type { Message, MessageInfo, ClientPart } from "../../types/message" import type { Session } from "../../types/session" import { messageStoreBus } from "./bus" @@ -227,66 +225,6 @@ export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: st } } -function extractQuestionMessageId(request: QuestionRequest): string | undefined { - return getQuestionMessageId(request) -} - -function extractQuestionCallId(request: QuestionRequest): string | undefined { - return getQuestionCallId(request) -} - -export function upsertQuestionV2(instanceId: string, request: QuestionRequest): void { - if (!request) return - const store = messageStoreBus.getOrCreate(instanceId) - const messageId = extractQuestionMessageId(request) - let partId: string | undefined = undefined - const callId = extractQuestionCallId(request) - if (callId) { - partId = resolvePartIdFromCallId(store, messageId, callId) - } - store.upsertQuestion({ - request, - messageId, - partId, - enqueuedAt: Date.now(), - }) -} - -export function reconcilePendingQuestionsV2(instanceId: string, sessionId?: string): void { - const store = messageStoreBus.getOrCreate(instanceId) - const pending = store.state.questions.queue - if (!pending || pending.length === 0) return - - for (const entry of pending) { - if (!entry) continue - const request = entry.request - if (!request) continue - - const questionSessionId = request.sessionID - if (sessionId && questionSessionId && questionSessionId !== sessionId) { - continue - } - - const messageId = entry.messageId ?? extractQuestionMessageId(request) - const callId = extractQuestionCallId(request) - const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId) - if (entry.partId && (!resolvedPartId || resolvedPartId === entry.partId)) continue - if (!resolvedPartId) continue - - store.upsertQuestion({ - ...entry, - messageId, - partId: resolvedPartId, - }) - } -} - -export function removeQuestionV2(instanceId: string, requestId: string): void { - if (!requestId) return - const store = messageStoreBus.getOrCreate(instanceId) - store.removeQuestion(requestId) -} - export function removePermissionV2(instanceId: string, permissionId: string): void { if (!permissionId) return const store = messageStoreBus.getOrCreate(instanceId) diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 17a187a9..49e7d5a0 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -65,77 +65,6 @@ describe("message-v2 todo state", () => { }) }) -describe("message-v2 question attachment", () => { - it("rebinds a question when its tool part arrives after question.asked", () => { - const store = createInstanceMessageStore("instance-1") - store.addOrUpdateSession({ id: "session-1" }) - store.upsertMessage({ id: "message-1", sessionId: "session-1", role: "assistant", status: "streaming", parts: [] }) - store.upsertQuestion({ - request: { - id: "question-1", - sessionID: "session-1", - questions: [{ header: "Confirm", question: "Continue?", options: [] }], - tool: { messageID: "message-1", id: "call-1" }, - }, - messageId: "message-1", - enqueuedAt: 1_000, - }) - - assert.equal(store.getQuestionState("message-1", "call-1"), null) - - store.applyPartUpdate({ - messageId: "message-1", - part: { id: "part-1", type: "tool", tool: "question", callID: "call-1", state: { status: "running", input: {} } } as any, - }) - - assert.equal(store.getQuestionState("message-1", "part-1")?.entry.request.id, "question-1") - assert.equal(store.state.questions.queue.length, 1) - }) - - it("uses the native tool part id when no normalized callID is present", () => { - const store = createInstanceMessageStore("instance-1") - store.addOrUpdateSession({ id: "session-1" }) - store.upsertMessage({ id: "message-1", sessionId: "session-1", role: "assistant", status: "streaming", parts: [] }) - store.upsertQuestion({ - request: { - id: "question-1", - sessionID: "session-1", - questions: [], - tool: { messageID: "message-1", id: "part-native" }, - }, - messageId: "message-1", - enqueuedAt: 1_000, - }) - - store.applyPartUpdate({ - messageId: "message-1", - part: { id: "part-native", type: "tool", tool: "question", state: { status: "running", input: {} } } as any, - }) - - assert.equal(store.getQuestionState("message-1", "part-native")?.entry.request.id, "question-1") - }) - - it("moves a question from a stale optimistic part to the authoritative part", () => { - const store = createInstanceMessageStore("instance-1") - store.addOrUpdateSession({ id: "session-1" }) - store.upsertMessage({ id: "message-1", sessionId: "session-1", role: "assistant", status: "streaming", parts: [] }) - store.upsertQuestion({ - request: { id: "question-1", sessionID: "session-1", questions: [], tool: { messageID: "message-1", id: "call-1" } }, - messageId: "message-1", - partId: "part-optimistic", - enqueuedAt: 1_000, - }) - - store.applyPartUpdate({ - messageId: "message-1", - part: { id: "part-authoritative", type: "tool", tool: "question", callID: "call-1", state: { status: "running", input: {} } } as any, - }) - - assert.equal(store.getQuestionState("message-1", "part-optimistic"), null) - assert.equal(store.getQuestionState("message-1", "part-authoritative")?.entry.request.id, "question-1") - }) -}) - describe("message-v2 hydrateMessages vs pending optimistic sends", () => { it("keeps an in-flight pending 'sending' message visible when a force reload snapshot doesn't include it yet", () => { const store = createInstanceMessageStore("instance-1") @@ -356,11 +285,6 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => { messageId: "msg-stale", enqueuedAt: 1, }) - store.upsertQuestion({ - request: { id: "question-stale", sessionID: "session-1", questions: [] }, - messageId: "msg-stale", - enqueuedAt: 1, - }) store.upsertMessage({ id: "msg-inflight", sessionId: "session-1", role: "user", status: "sending", parts: [{ type: "text", text: "new" } as any], isEphemeral: true, @@ -376,8 +300,6 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => { assert.equal(store.state.pendingParts["msg-stale"], undefined) assert.equal(store.state.permissions.byMessage["msg-stale"], undefined) assert.equal(store.state.permissions.queue.length, 0) - assert.equal(store.state.questions.byMessage["msg-stale"], undefined) - assert.equal(store.state.questions.queue.length, 0) assert.equal(store.state.usage["session-1"].totalInputTokens, 0) assert.equal(store.getLatestTodoSnapshot("session-1"), undefined) }) @@ -398,11 +320,6 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => { messageId: "msg-old", enqueuedAt: 1, }) - store.upsertQuestion({ - request: { id: "question-old", sessionID: "session-1", questions: [] }, - messageId: "msg-old", - enqueuedAt: 1, - }) store.hydrateMessages("session-1", [{ id: "msg-new", sessionId: "session-1", role: "user", status: "complete", @@ -414,8 +331,6 @@ describe("message-v2 hydrateMessages vs pending optimistic sends", () => { assert.equal(store.state.pendingParts["msg-old"], undefined) assert.equal(store.state.permissions.byMessage["msg-old"], undefined) assert.equal(store.state.permissions.queue.length, 0) - assert.equal(store.state.questions.byMessage["msg-old"], undefined) - assert.equal(store.state.questions.queue.length, 0) assert.equal(store.state.usage["session-1"].totalInputTokens, 0) assert.equal(store.getLatestTodoSnapshot("session-1"), undefined) }) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index fd5159ae..5eb32071 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -13,7 +13,7 @@ import { import type { ClientPart, MessageInfo } from "../../types/message" import { mergePermissionRequest } from "../../types/permission" import { clearRecordDisplayCacheForMessages } from "./record-display-cache" -import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe" +import { shouldSkipPendingRequestUpsert } from "./pending-request-dedupe" import type { InstanceMessageState, LatestTodoSnapshot, @@ -22,7 +22,6 @@ import type { PartUpdateInput, PendingPartEntry, PermissionEntry, - QuestionEntry, ReplaceMessageIdOptions, ScrollSnapshot, SessionRecord, @@ -53,11 +52,6 @@ function createInitialState(instanceId: string): InstanceMessageState { active: null, byMessage: {}, }, - questions: { - queue: [], - active: null, - byMessage: {}, - }, usage: {}, scrollState: {}, latestTodos: {}, @@ -243,9 +237,6 @@ export interface InstanceMessageStore { upsertPermission: (entry: PermissionEntry) => void removePermission: (permissionId: string) => void getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null - upsertQuestion: (entry: QuestionEntry) => void - removeQuestion: (requestId: string) => void - getQuestionState: (messageId?: string, partId?: string) => { entry: QuestionEntry; active: boolean } | null setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null rebuildUsage: (sessionId: string, infos: Iterable) => void @@ -665,17 +656,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt draft.active = draft.queue[0] ?? null }), ) - setState( - "questions", - produce((draft) => { - const omitted = new Set(omittedIds) - omittedIds.forEach((id) => { - delete draft.byMessage[id] - }) - draft.queue = draft.queue.filter((entry) => !entry.messageId || !omitted.has(entry.messageId)) - draft.active = draft.queue[0] ?? null - }), - ) } if (usageState) { @@ -844,17 +824,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt draft.active = draft.queue[0] ?? null }), ) - setState( - "questions", - produce((draft) => { - const dropped = new Set(droppedIds) - droppedIds.forEach((id) => { - delete draft.byMessage[id] - }) - draft.queue = draft.queue.filter((entry) => !entry.messageId || !dropped.has(entry.messageId)) - draft.active = draft.queue[0] ?? null - }), - ) setState("usage", sessionId, createEmptyUsageState()) setState("latestTodos", sessionId, undefined) setState("sessions", sessionId, (current) => ({ @@ -1010,26 +979,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt ) } - function rebindQuestionForPart(messageId: string, partId: string, part: ClientPart) { - if (!messageId || !partId || part.type !== "tool") return - - const toolCallId = - (part as any).callID ?? - (part as any).callId ?? - (part as any).toolCallID ?? - (part as any).toolCallId ?? - (part as any).id ?? - undefined - if (!toolCallId) return - - const detached = Object.values(state.questions.byMessage[messageId] ?? {}).find((entry) => { - return entry && entry.partId !== partId && entry.request.tool?.id === toolCallId - }) - if (!detached) return - - upsertQuestion({ ...detached, messageId, partId }) - } - function applyPartUpdate(input: PartUpdateInput) { const message = state.messages[input.messageId] if (!message) { @@ -1062,7 +1011,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt ) rebindPermissionForPart(input.messageId, partId, cloned) - rebindQuestionForPart(input.messageId, partId, cloned) if (isCompletedTodoPart(cloned)) { recordLatestTodoSnapshot(message.sessionId, { @@ -1319,18 +1267,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) } - const questionMap = state.questions.byMessage[options.oldId] - if (questionMap) { - setState("questions", "byMessage", options.newId, questionMap) - setState("questions", (prev) => { - const next = { ...prev } - const nextByMessage = { ...next.byMessage } - delete nextByMessage[options.oldId] - next.byMessage = nextByMessage - return next - }) - } - const pending = state.pendingParts[options.oldId] if (pending) { setState("pendingParts", options.newId, pending) @@ -1446,94 +1382,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return { entry, active } } - function mergeQuestionEntry(entry: QuestionEntry): QuestionEntry { - const existing = state.questions.queue.find((item) => item.request.id === entry.request.id) - return mergePendingRequestEntry(entry, existing) - } - - function upsertQuestion(input: QuestionEntry) { - const entry = mergeQuestionEntry(input) - const messageKey = entry.messageId ?? "__global__" - const partKey = entry.partId ?? entry.request?.id ?? "__global__" - const existing = state.questions.queue.find((item) => item.request.id === entry.request.id) - const existingAtLocation = state.questions.byMessage[messageKey]?.[partKey] - const expectedActiveId = state.questions.queue[0]?.request.id - if (shouldSkipPendingRequestUpsert({ - existing, - existingAtLocationId: existingAtLocation?.request.id, - expectedActiveId, - activeId: state.questions.active?.request.id, - incomingId: entry.request.id, - incomingMessageId: entry.messageId, - incomingPartId: entry.partId, - incomingEnqueuedAt: entry.enqueuedAt, - existingValue: existing?.request, - incomingValue: entry.request, - })) { - return - } - - setState( - "questions", - produce((draft) => { - Object.keys(draft.byMessage).forEach((existingMessageKey) => { - const partEntries = draft.byMessage[existingMessageKey] - Object.keys(partEntries).forEach((existingPartKey) => { - if (partEntries[existingPartKey].request.id === entry.request.id) { - delete partEntries[existingPartKey] - } - }) - if (Object.keys(partEntries).length === 0) { - delete draft.byMessage[existingMessageKey] - } - }) - draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {} - draft.byMessage[messageKey][partKey] = entry - const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id) - if (existingIndex === -1) { - draft.queue.push(entry) - } else { - draft.queue[existingIndex] = entry - } - if (!draft.active || draft.active.request.id === entry.request.id) { - draft.active = entry - } - }), - ) - } - - function removeQuestion(requestId: string) { - setState( - "questions", - produce((draft) => { - draft.queue = draft.queue.filter((item) => item.request.id !== requestId) - if (draft.active?.request.id === requestId) { - draft.active = draft.queue[0] ?? null - } - Object.keys(draft.byMessage).forEach((messageKey) => { - const partEntries = draft.byMessage[messageKey] - Object.keys(partEntries).forEach((partKey) => { - if (partEntries[partKey].request.id === requestId) { - delete partEntries[partKey] - } - }) - if (Object.keys(partEntries).length === 0) { - delete draft.byMessage[messageKey] - } - }) - }), - ) - } - - function getQuestionState(messageId?: string, partId?: string) { - const messageKey = messageId ?? "__global__" - const partKey = partId ?? "__global__" - const entry = state.questions.byMessage[messageKey]?.[partKey] - if (!entry) return null - const active = state.questions.active?.request.id === entry.request.id - return { entry, active } - } - function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) { const session = state.sessions[sessionId] if (!session) return @@ -1577,14 +1425,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return next }) - setState("questions", "byMessage", (prev) => { - const next = { ...prev } - removedIds.forEach((id) => { - if (next[id]) delete next[id] - }) - return next - }) - withUsageState(sessionId, (draft) => { removedIds.forEach((id) => removeUsageEntry(draft, id)) }) @@ -1671,14 +1511,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return next }) - setState("questions", "byMessage", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => { - if (next[id]) delete next[id] - }) - return next - }) - setState("usage", (prev) => { const next = { ...prev } delete next[sessionId] @@ -1769,9 +1601,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt upsertPermission, removePermission, getPermissionState, - upsertQuestion, - removeQuestion, - getQuestionState, setSessionRevert, getSessionRevert, diff --git a/packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts b/packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts deleted file mode 100644 index 37c27235..00000000 --- a/packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe.ts" - -describe("pending request dedupe", () => { - it("skips unchanged polling updates at the same attachment location", () => { - const existing = { - messageId: "message-1", - partId: "part-1", - enqueuedAt: 1_000, - } - const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] } - - assert.equal(shouldSkipPendingRequestUpsert({ - existing, - existingAtLocationId: "question-1", - expectedActiveId: "question-1", - activeId: "question-1", - incomingId: "question-1", - incomingMessageId: "message-1", - incomingPartId: "part-1", - incomingEnqueuedAt: 1_000, - existingValue: request, - incomingValue: { ...request, questions: [...request.questions] }, - }), true) - }) - - it("does not skip when polling resolves a request from global state to a tool part", () => { - const existing = { - enqueuedAt: 1_000, - } - const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] } - - assert.equal(shouldSkipPendingRequestUpsert({ - existing, - existingAtLocationId: undefined, - expectedActiveId: "question-1", - activeId: "question-1", - incomingId: "question-1", - incomingMessageId: "message-1", - incomingPartId: "part-1", - incomingEnqueuedAt: 1_000, - existingValue: request, - incomingValue: request, - }), false) - }) - - it("keeps the earliest queue time while preserving resolved attachment ids", () => { - const merged = mergePendingRequestEntry( - { messageId: "message-1", partId: "part-1", enqueuedAt: 3_000 }, - { enqueuedAt: 1_000 }, - ) - - assert.deepEqual(merged, { messageId: "message-1", partId: "part-1", enqueuedAt: 1_000 }) - }) -}) diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index b49fbf00..5bbe2dec 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,7 +1,6 @@ import type { ClientPart } from "../../types/message" import type { PromptDisplayMetadata } from "../../lib/prompt-display-metadata" import type { PermissionRequest } from "../../types/permission" -import type { QuestionRequest } from "../../types/question" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" export type MessageRole = "user" | "assistant" @@ -62,19 +61,6 @@ export interface InstancePermissionState { byMessage: Record> } -export interface QuestionEntry { - request: QuestionRequest - messageId?: string - partId?: string - enqueuedAt: number -} - -export interface InstanceQuestionState { - queue: QuestionEntry[] - active: QuestionEntry | null - byMessage: Record> -} - export interface ScrollSnapshot { scrollTop: number scrollRatio?: number @@ -125,7 +111,6 @@ export interface InstanceMessageState { pendingParts: Record sessionRevisions: Record permissions: InstancePermissionState - questions: InstanceQuestionState usage: Record scrollState: Record latestTodos: Record diff --git a/packages/ui/src/stores/native-session-streaming.test.ts b/packages/ui/src/stores/native-session-streaming.test.ts deleted file mode 100644 index c69e3955..00000000 --- a/packages/ui/src/stores/native-session-streaming.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import { messageStoreBus } from "./message-v2/bus.ts" -import { - applyNativeContentDelta, - clearNativeContentDeltaState, - reconcileNativeContentAfterSnapshot, - reapplyNativeContentDeltas, - settleNativeContentDeltas, -} from "./native-session-streaming.ts" - -const delay = (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)) -const data = { sessionID: "session", assistantMessageID: "assistant" } -const text = (id: string, ordinal: number, delta: string, created = ordinal) => ({ - id, created, type: "session.text.delta" as const, data: { ...data, ordinal, delta }, -}) - -function cleanup(instanceId: string) { - clearNativeContentDeltaState(instanceId) - if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) -} - -describe("native session streaming", () => { - it("rejects malformed native deltas at the event boundary", () => { - const instanceId = "invalid-native-streaming" - try { - assert.equal(applyNativeContentDelta(instanceId, { - type: "session.text.delta", - data: { sessionID: "session", assistantMessageID: "", ordinal: 0, delta: "ignored" }, - } as any), false) - assert.equal(applyNativeContentDelta(instanceId, text("negative", -1, "ignored")), false) - assert.equal(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds("session").length, 0) - } finally { - cleanup(instanceId) - } - }) - - it("deduplicates replayed events and restores deltas after a stale snapshot", () => { - const instanceId = "replayed-native-streaming" - const event = text("event-1", 0, "hello", 10) - try { - applyNativeContentDelta(instanceId, event) - applyNativeContentDelta(instanceId, event) - const store = messageStoreBus.getOrCreate(instanceId) - assert.equal((store.getMessage("assistant")?.parts["assistant-text-native-0"]?.data as any)?.text, "hello") - - store.upsertMessage({ - id: "assistant", sessionId: "session", role: "assistant", status: "streaming", - parts: [{ id: "assistant-text-0", type: "text", text: "", sessionID: "session", messageID: "assistant" }], - }) - reapplyNativeContentDeltas(instanceId, "session") - assert.equal((store.getMessage("assistant")?.parts["assistant-text-0"]?.data as any)?.text, "hello") - assert.deepEqual(store.getMessage("assistant")?.partIds, ["assistant-text-0"]) - } finally { - cleanup(instanceId) - } - }) - - it("orders content parts by source time when native events arrive out of order", () => { - const instanceId = "ordered-native-streaming" - try { - applyNativeContentDelta(instanceId, text("second", 1, "world", 2)) - applyNativeContentDelta(instanceId, text("first", 0, "hello ", 1)) - reapplyNativeContentDeltas(instanceId, "session") - const message = messageStoreBus.getOrCreate(instanceId).getMessage("assistant") - assert.deepEqual(message?.partIds, ["assistant-text-native-0", "assistant-text-native-1"]) - assert.equal((message?.parts["assistant-text-native-0"]?.data as any)?.text, "hello ") - assert.equal((message?.parts["assistant-text-native-1"]?.data as any)?.text, "world") - } finally { - cleanup(instanceId) - } - }) - - it("does not overlay stale deltas across an authoritative tool boundary", () => { - const instanceId = "tool-boundary-streaming" - try { - applyNativeContentDelta(instanceId, text("first", 0, "before", 1)) - applyNativeContentDelta(instanceId, text("second", 1, "aftermore", 2)) - const store = messageStoreBus.getOrCreate(instanceId) - store.upsertMessage({ - id: "assistant", sessionId: "session", role: "assistant", status: "streaming", - parts: [ - { id: "assistant-text-0", type: "text", text: "before", sessionID: "session", messageID: "assistant" }, - { id: "tool", type: "tool", tool: "test", sessionID: "session", messageID: "assistant" } as any, - { id: "assistant-text-2", type: "text", text: "after", sessionID: "session", messageID: "assistant" }, - ], - }) - reconcileNativeContentAfterSnapshot(instanceId, "session") - - assert.deepEqual(store.getMessage("assistant")?.partIds, ["assistant-text-0", "tool", "assistant-text-2"]) - assert.equal((store.getMessage("assistant")?.parts["assistant-text-2"]?.data as any)?.text, "aftermore") - } finally { - cleanup(instanceId) - } - }) - - it("flushes pending deltas before settling and cancels cleared timers", async () => { - const instanceId = "terminal-timer-streaming" - try { - applyNativeContentDelta(instanceId, text("first", 0, "a", 1)) - applyNativeContentDelta(instanceId, text("second", 0, "b", 2)) - settleNativeContentDeltas(instanceId, "session") - const store = messageStoreBus.getOrCreate(instanceId) - assert.equal((store.getMessage("assistant")?.parts["assistant-text-native-0"]?.data as any)?.text, "ab") - assert.equal(store.getMessage("assistant")?.status, "complete") - - clearNativeContentDeltaState(instanceId, "session") - await delay(25) - assert.equal((store.getMessage("assistant")?.parts["assistant-text-native-0"]?.data as any)?.text, "ab") - } finally { - cleanup(instanceId) - } - }) - - it("does not recreate cleared session state during late reconciliation", () => { - const instanceId = "cleared-native-streaming" - try { - applyNativeContentDelta(instanceId, text("event", 0, "text", 1)) - messageStoreBus.unregisterInstance(instanceId) - clearNativeContentDeltaState(instanceId, "session") - reapplyNativeContentDeltas(instanceId, "session") - assert.equal(messageStoreBus.getInstance(instanceId), undefined) - } finally { - cleanup(instanceId) - } - }) -}) diff --git a/packages/ui/src/stores/native-session-streaming.ts b/packages/ui/src/stores/native-session-streaming.ts deleted file mode 100644 index 1009f4d5..00000000 --- a/packages/ui/src/stores/native-session-streaming.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { SessionReasoningDelta, SessionTextDelta } from "@opencode-ai/client" -import type { ClientPart, MessageInfo } from "../types/message" -import { messageStoreBus } from "./message-v2/bus" - -type NativeContentDelta = SessionTextDelta | SessionReasoningDelta -type TrackedPart = { - messageId: string - partId: string - type: "text" | "reasoning" - ordinal: number - order: number - text: string - createdAt: number -} -type SessionStreamingState = { - seenEventIds: Set - eventOrder: string[] - parts: Map - renderTimers: Map> - settledMessageIds: Set - settledMessageOrder: string[] -} - -const MAX_TRACKED_EVENT_IDS = 20_000 -const MAX_SETTLED_MESSAGE_IDS = 100 -const RENDER_INTERVAL_MS = 16 -const streamingState = new Map>() - -function getSessionState(instanceId: string, sessionId: string): SessionStreamingState { - let instance = streamingState.get(instanceId) - if (!instance) { - instance = new Map() - streamingState.set(instanceId, instance) - } - let session = instance.get(sessionId) - if (!session) { - session = { - seenEventIds: new Set(), eventOrder: [], parts: new Map(), renderTimers: new Map(), - settledMessageIds: new Set(), settledMessageOrder: [], - } - instance.set(sessionId, session) - } - return session -} - -function markEventSeen(state: SessionStreamingState, eventId: string): boolean { - if (state.seenEventIds.has(eventId)) return false - state.seenEventIds.add(eventId) - state.eventOrder.push(eventId) - while (state.eventOrder.length > MAX_TRACKED_EVENT_IDS) { - const expired = state.eventOrder.shift() - if (expired) state.seenEventIds.delete(expired) - } - return true -} - -function displayText(tracked: string, existing: string | undefined): string { - if (!existing) return tracked - if (existing.startsWith(tracked) && existing.length >= tracked.length) return existing - if (!tracked.startsWith(existing)) return existing - return tracked -} - -function renderTrackedMessage( - instanceId: string, - sessionId: string, - state: SessionStreamingState, - messageId: string, -): void { - const trackedParts = [...state.parts.values()] - .filter((part) => part.messageId === messageId) - .sort((left, right) => left.order - right.order) - if (trackedParts.length === 0) return - - const store = messageStoreBus.getOrCreate(instanceId) - const current = store.getMessage(messageId) - const currentParts = (current?.partIds ?? []) - .map((partId) => current?.parts[partId]?.data) - .filter((part): part is ClientPart => Boolean(part)) - const nextParts = currentParts.filter((part) => { - const id = typeof part.id === "string" ? part.id : "" - return !id.startsWith(`${messageId}-`) || !id.includes("-native-") - }) - - for (const tracked of trackedParts) { - const sameTypeIndexes = nextParts - .map((part, index) => ({ part, index })) - .filter(({ part }) => part.type === tracked.type) - const existingIndex = sameTypeIndexes[tracked.ordinal]?.index ?? -1 - const existing = existingIndex >= 0 ? nextParts[existingIndex] : undefined - const existingText = existing && "text" in existing && typeof existing.text === "string" ? existing.text : undefined - const part: ClientPart = { - ...(existing ?? {}), - id: existing?.id ?? tracked.partId, - type: tracked.type, - text: displayText(tracked.text, existingText), - sessionID: sessionId, - messageID: messageId, - } as ClientPart - if (existingIndex >= 0) nextParts[existingIndex] = part - else nextParts.push(part) - } - - const createdAt = current?.createdAt ?? trackedParts[0]?.createdAt ?? Date.now() - store.addOrUpdateSession({ id: sessionId }) - store.upsertMessage({ - id: messageId, - sessionId, - role: "assistant", - status: current?.status === "error" - ? "error" - : state.settledMessageIds.has(messageId) ? "complete" : "streaming", - createdAt, - updatedAt: Date.now(), - parts: nextParts, - }) - if (!store.getMessageInfo(messageId)) { - const info: MessageInfo = { - id: messageId, - sessionID: sessionId, - role: "assistant", - time: { created: createdAt }, - } - store.setMessageInfo(messageId, info) - } -} - -function flushMessage(instanceId: string, sessionId: string, state: SessionStreamingState, messageId: string): void { - const timer = state.renderTimers.get(messageId) - if (timer) clearTimeout(timer) - state.renderTimers.delete(messageId) - renderTrackedMessage(instanceId, sessionId, state, messageId) -} - -function scheduleMessageRender(instanceId: string, sessionId: string, state: SessionStreamingState, messageId: string): void { - if (state.renderTimers.has(messageId)) return - state.renderTimers.set(messageId, setTimeout(() => { - state.renderTimers.delete(messageId) - renderTrackedMessage(instanceId, sessionId, state, messageId) - }, RENDER_INTERVAL_MS)) -} - -export function applyNativeContentDelta(instanceId: string, event: NativeContentDelta): boolean { - const { sessionID, assistantMessageID, ordinal, delta } = event.data - if (!instanceId || !event.id || !sessionID || !assistantMessageID || !Number.isInteger(ordinal) || ordinal < 0 || typeof delta !== "string") { - return false - } - - const state = getSessionState(instanceId, sessionID) - if (state.settledMessageIds.has(assistantMessageID) || !markEventSeen(state, event.id)) return false - const partType = event.type === "session.text.delta" ? "text" : "reasoning" - const partId = `${assistantMessageID}-${partType}-native-${ordinal}` - const tracked = state.parts.get(partId) ?? { - messageId: assistantMessageID, - partId, - type: partType, - ordinal, - order: typeof event.created === "number" ? event.created : Date.now(), - text: "", - createdAt: typeof event.created === "number" ? event.created : Date.now(), - } - tracked.text += delta - state.parts.set(partId, tracked) - - const store = messageStoreBus.getOrCreate(instanceId) - if (!store.getMessage(assistantMessageID)) renderTrackedMessage(instanceId, sessionID, state, assistantMessageID) - else scheduleMessageRender(instanceId, sessionID, state, assistantMessageID) - return true -} - -export function reapplyNativeContentDeltas(instanceId: string, sessionId: string): void { - const state = streamingState.get(instanceId)?.get(sessionId) - if (!state) return - const messageIds = new Set([...state.parts.values()].map((part) => part.messageId)) - for (const messageId of messageIds) flushMessage(instanceId, sessionId, state, messageId) -} - -function markMessageComplete(instanceId: string, sessionId: string, messageId: string): void { - const store = messageStoreBus.getOrCreate(instanceId) - const message = store.getMessage(messageId) - if (!message || message.status === "complete" || message.status === "error") return - store.upsertMessage({ - id: message.id, - sessionId, - role: "assistant", - status: "complete", - createdAt: message.createdAt, - updatedAt: Date.now(), - }) -} - -export function settleNativeContentDeltas(instanceId: string, sessionId: string): void { - const state = streamingState.get(instanceId)?.get(sessionId) - if (!state) return - reapplyNativeContentDeltas(instanceId, sessionId) - for (const tracked of state.parts.values()) { - if (!state.settledMessageIds.has(tracked.messageId)) { - state.settledMessageIds.add(tracked.messageId) - state.settledMessageOrder.push(tracked.messageId) - } - markMessageComplete(instanceId, sessionId, tracked.messageId) - } - while (state.settledMessageOrder.length > MAX_SETTLED_MESSAGE_IDS) { - const expired = state.settledMessageOrder.shift() - if (!expired) continue - state.settledMessageIds.delete(expired) - for (const [partId, tracked] of state.parts) { - if (tracked.messageId === expired) state.parts.delete(partId) - } - } -} - -export function reconcileNativeContentAfterSnapshot(instanceId: string, sessionId: string): void { - const state = streamingState.get(instanceId)?.get(sessionId) - if (!state) return - reapplyNativeContentDeltas(instanceId, sessionId) - for (const messageId of state.settledMessageIds) markMessageComplete(instanceId, sessionId, messageId) -} - -export function clearNativeContentDeltaState(instanceId: string, sessionId?: string): void { - if (!sessionId) { - const instance = streamingState.get(instanceId) - for (const state of instance?.values() ?? []) { - for (const timer of state.renderTimers.values()) clearTimeout(timer) - } - streamingState.delete(instanceId) - return - } - const instance = streamingState.get(instanceId) - const state = instance?.get(sessionId) - for (const timer of state?.renderTimers.values() ?? []) clearTimeout(timer) - instance?.delete(sessionId) - if (instance?.size === 0) streamingState.delete(instanceId) -} diff --git a/packages/ui/src/stores/opencode-data.test.ts b/packages/ui/src/stores/opencode-data.test.ts new file mode 100644 index 00000000..f9eb0233 --- /dev/null +++ b/packages/ui/src/stores/opencode-data.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { messageStoreBus } from "./message-v2/bus.ts" +import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data.ts" + +describe("OpenCode data projection", () => { + it("uses createData to reduce messages, permissions, and forms", () => { + const instanceId = "opencode-data" + const base = { sessionID: "session", assistantMessageID: "assistant" } + const apply = (type: string, data: Record, created = 1) => + applyOpenCodeDataEvent(instanceId, "/work", { id: type, type, created, data } as any) + + try { + apply("session.step.started", { ...base, agent: "build", model: { providerID: "provider", id: "model" } }) + apply("session.text.started", base) + apply("session.text.delta", { ...base, ordinal: 0, delta: "hello" }) + apply("session.tool.input.started", { ...base, id: "tool", name: "bash" }) + apply("session.tool.called", { ...base, id: "tool", input: { command: "pwd" } }, 2) + const data = apply("session.tool.success", { ...base, id: "tool", content: [{ type: "text", text: "ok" }], metadata: {} }, 3) + projectOpenCodeMessages(instanceId, "session", data) + + const message = messageStoreBus.getOrCreate(instanceId).getMessage("assistant") + assert.equal((message?.parts["assistant-text-0"]?.data as any)?.text, "hello") + assert.equal((message?.parts.tool?.data as any)?.state.output, "ok") + + apply("permission.asked", { id: "permission", sessionID: "session", action: "read", resources: ["*"] }) + assert.equal(data.session.permission.list("session")?.[0]?.id, "permission") + + applyOpenCodeDataEvent(instanceId, "/work", { + id: "form", type: "form.created", created: 4, location: { directory: "/work" }, + data: { form: { id: "form", sessionID: "session", title: "Input", fields: [] } }, + } as any) + assert.equal(data.session.form.list("session")?.[0]?.id, "form") + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) +}) diff --git a/packages/ui/src/stores/opencode-data.ts b/packages/ui/src/stores/opencode-data.ts new file mode 100644 index 00000000..9c74d93d --- /dev/null +++ b/packages/ui/src/stores/opencode-data.ts @@ -0,0 +1,61 @@ +import type { OpenCodeEvent } from "@opencode-ai/client" +import { createData, type Data } from "@opencode-ai/client/solid" +import { createRoot } from "solid-js" +import type { MessageInfo } from "../types/message" +import { getRootClient } from "./opencode-client" +import { seedSessionMessagesV2 } from "./message-v2/bridge" +import { normalizeSessionMessage } from "./message-v2/normalizers" + +const entries = new Map void; dispose: () => void }>() + +function ensureData(instanceId: string, directory: string) { + const existing = entries.get(instanceId) + if (existing) return existing + + const listeners = new Set<(event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void>() + const entry = createRoot((dispose) => { + const event = { + listen(handler: (event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + on(type: OpenCodeEvent["type"], handler: (event: OpenCodeEvent) => void) { + return event.listen(({ details }) => { + if (details.type === type) handler(details) + }) + }, + } + return { + data: createData({ api: () => getRootClient(instanceId), directory, event: event as any }), + emit(details: OpenCodeEvent) { + for (const listener of listeners) listener({ name: details.type, details }) + }, + dispose, + } + }) + entries.set(instanceId, entry) + return entry +} + +export function applyOpenCodeDataEvent(instanceId: string, directory: string, event: OpenCodeEvent): Data { + const entry = ensureData(instanceId, directory) + entry.emit(event) + return entry.data +} + +export function projectOpenCodeMessages(instanceId: string, sessionId: string, data: Data): void { + const source = data.session.message.list(sessionId) + if (!source.length) return + const infos = new Map() + const messages = source.map((item) => { + const normalized = normalizeSessionMessage(sessionId, item) + infos.set(normalized.info.id, normalized.info) + return normalized.message + }) + seedSessionMessagesV2(instanceId, { id: sessionId }, messages, infos) +} + +export function destroyOpenCodeData(instanceId: string): void { + entries.get(instanceId)?.dispose() + entries.delete(instanceId) +} diff --git a/packages/ui/src/stores/permission-lifecycle.test.ts b/packages/ui/src/stores/permission-lifecycle.test.ts index afabc5df..6f9793b7 100644 --- a/packages/ui/src/stores/permission-lifecycle.test.ts +++ b/packages/ui/src/stores/permission-lifecycle.test.ts @@ -1,19 +1,14 @@ import assert from "node:assert/strict" import { afterEach, test } from "node:test" -import type { OpenCodeClient, PermissionReplyInput, QuestionRejectInput, QuestionReplyInput } from "@opencode-ai/client" +import type { OpenCodeClient, PermissionReplyInput } from "@opencode-ai/client" import { sdkManager } from "../lib/sdk-manager" import type { Instance } from "../types/instance" import { addInstance, addPermissionToQueue, - addQuestionToQueue, clearPermissionQueue, - clearQuestionQueue, getPermissionQueue, - getQuestionQueue, sendPermissionResponse, - sendQuestionReject, - sendQuestionReply, syncPendingRequests, updateInstance, } from "./instances" @@ -38,7 +33,6 @@ function addTestInstance(id: string, client: OpenCodeClient): void { afterEach(() => { for (const instanceId of instanceIds.splice(0)) { clearPermissionQueue(instanceId) - clearQuestionQueue(instanceId) messageStoreBus.unregisterInstance(instanceId) sdkManager.destroyClientsForInstance(instanceId) } @@ -72,66 +66,30 @@ test("permission replies use the queued request session", async () => { assert.deepEqual(getPermissionQueue("permission-reply"), []) }) -test("question replies and rejects use queued request sessions", async () => { - const replies: QuestionReplyInput[] = [] - const rejects: QuestionRejectInput[] = [] - const client = { - question: { - reply: async (input: QuestionReplyInput) => { replies.push(input) }, - reject: async (input: QuestionRejectInput) => { rejects.push(input) }, - }, - } as unknown as OpenCodeClient - sdkManager.createClient = (() => client) as typeof sdkManager.createClient - addTestInstance("question-replies", client) - addQuestionToQueue("question-replies", { id: "reply", sessionID: "reply-session", questions: [] }) - addQuestionToQueue("question-replies", { id: "reject", sessionID: "reject-session", questions: [] }) - const messageStore = messageStoreBus.getOrCreate("question-replies") - messageStore.upsertQuestion({ request: { id: "reply", sessionID: "reply-session", questions: [] }, enqueuedAt: 1 }) - messageStore.upsertQuestion({ request: { id: "reject", sessionID: "reject-session", questions: [] }, enqueuedAt: 2 }) - - await sendQuestionReply("question-replies", "stale-session", "reply", [["yes"]]) - await sendQuestionReject("question-replies", "stale-session", "reject") - - assert.deepEqual(replies, [{ sessionID: "reply-session", requestID: "reply", answers: [["yes"]] }]) - assert.deepEqual(rejects, [{ sessionID: "reject-session", requestID: "reject" }]) - assert.deepEqual(getQuestionQueue("question-replies"), []) - assert.equal(messageStore.state.questions.queue.length, 0) -}) - test("pending request sync cannot erase newer SSE mutations", async () => { const newPermission = { id: "new-permission", sessionID: "session", action: "edit", resources: ["*"], metadata: {}, } - const newQuestion = { id: "new-question", sessionID: "session", questions: [] } let resolvePermissions!: (value: { location: never; data: never[] }) => void - let resolveQuestions!: (value: { location: never; data: never[] }) => void let permissionCalls = 0 - let questionCalls = 0 const client = { permission: { request: { list: () => ++permissionCalls === 1 ? new Promise((resolve) => { resolvePermissions = resolve }) : Promise.resolve({ location: {} as never, data: [newPermission] }) } }, - question: { request: { list: () => ++questionCalls === 1 - ? new Promise((resolve) => { resolveQuestions = resolve }) - : Promise.resolve({ location: {} as never, data: [newQuestion] }) } }, form: { request: { list: async () => ({ location: {} as never, data: [] }) } }, } as unknown as OpenCodeClient addTestInstance("pending-request-race", client) addPermissionToQueue("pending-request-race", { id: "stale-permission", sessionID: "session", action: "edit", resources: [] }) - addQuestionToQueue("pending-request-race", { id: "stale-question", sessionID: "session", questions: [] }) const sync = syncPendingRequests("pending-request-race") assert.equal(syncPendingRequests("pending-request-race"), sync) await new Promise((resolve) => setImmediate(resolve)) updateInstance("pending-request-race", { pid: 2 }) addPermissionToQueue("pending-request-race", newPermission) - addQuestionToQueue("pending-request-race", newQuestion) resolvePermissions({ location: {} as never, data: [] }) - resolveQuestions({ location: {} as never, data: [] }) await sync assert.deepEqual(getPermissionQueue("pending-request-race").map(({ id }) => id), ["new-permission"]) - assert.deepEqual(getQuestionQueue("pending-request-race").map(({ id }) => id), ["new-question"]) }) test("pending request sync uses native global lists with an explicit directory", async () => { @@ -145,12 +103,6 @@ test("pending request sync uses native global lists with an explicit directory", }] } }, } }, - question: { request: { - list: async (input: { location?: unknown }) => { - locations.push(input.location) - return { location: {} as never, data: [{ id: "question", sessionID: "session", questions: [] }] } - }, - } }, form: { request: { list: async (input: { location?: unknown }) => { locations.push(input.location) @@ -162,7 +114,6 @@ test("pending request sync uses native global lists with an explicit directory", await syncPendingRequests("native-pending-api") - assert.deepEqual(locations, [{ directory: "/workspace" }, { directory: "/workspace" }, { directory: "/workspace" }]) + assert.deepEqual(locations, [{ directory: "/workspace" }, { directory: "/workspace" }]) assert.deepEqual(getPermissionQueue("native-pending-api").map(({ id }) => id), ["permission"]) - assert.deepEqual(getQuestionQueue("native-pending-api").map(({ id }) => id), ["question"]) }) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 78578a2d..0e34aaf1 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -50,14 +50,14 @@ import { setSessionSearchResults, setSessionListError, setSessionExpanded, + getSessionNextCursor, } from "./session-state" import { deleteSessionAttachments } from "./attachments" import { DEFAULT_MODEL_OUTPUT_LIMIT, getActiveCatalogLocation, getDefaultModel, isModelValid } from "./session-models" import { normalizeSessionMessage } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" -import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" +import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" -import { clearNativeContentDeltaState, reconcileNativeContentAfterSnapshot } from "./native-session-streaming" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { getOpencodeErrorMessage } from "../lib/opencode-api" @@ -69,8 +69,8 @@ import { import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, - getUniqueSessionDirectories, } from "./session-list-options" +import { getInstanceMetadata } from "./instance-metadata" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" import { fetchCommands } from "./commands" import { toRequestLocation } from "./request-locations" @@ -81,6 +81,7 @@ const catalogLocations = new Map() const catalogRefreshes = new Map }>() const agentRequestIds = new Map() const providerRequestIds = new Map() +const sessionPageRequests = new Map>() let nextSessionListRequestId = 0 function catalogLocationKey(location: LocationRef): string { @@ -142,11 +143,16 @@ type V2SessionListOptions = { directory?: string search?: string cursor?: string + project?: string + subpath?: string + parentID?: string | null + order?: "asc" | "desc" } type ProjectSessionListResponse = { data: SDKSession[] complete: boolean + nextCursor?: string } function getKnownParentId(session: SDKSession | Session): string | null | undefined { @@ -172,41 +178,35 @@ function hasMissingParentChain(session: SDKSession, loaded: Map { const client = getRootClient(instanceId) - const directories = getUniqueSessionDirectories([ - options.directory, - ...getWorktrees(instanceId).map((worktree) => worktree.directory), - ]) - const sessionsById = new Map() - let complete = true - let loadedPage = false - let firstError: unknown + const project = options.project ?? getInstanceMetadata(instanceId)?.project?.id + const listOptions = { ...options, project, order: options.order ?? "desc" as const } + const response = await client.session.list(buildProjectSessionListOptions(listOptions)) + const sessionsById = new Map(response.data.map((session) => [session.id, session])) - await Promise.all((directories.length ? directories : [undefined]).map(async (directory) => { - let cursor: string | undefined - const seenCursors = new Set() - try { + if (options.parentID === null && !options.search) { + await Promise.all(response.data.map(async (root) => { + let cursor: string | undefined + const seenCursors = new Set() do { - const response = await client.session.list(buildProjectSessionListOptions({ ...options, directory, cursor })) - loadedPage = true - for (const session of response.data) { - if (!sessionsById.has(session.id)) sessionsById.set(session.id, session) - } - cursor = response.cursor?.next ?? undefined - if (cursor && seenCursors.has(cursor)) throw new Error(`Repeated session cursor: ${cursor}`) + const children = await client.session.list(buildProjectSessionListOptions({ + project: project ?? root.projectID, + subpath: root.subpath, + parentID: root.id, + order: "asc", + cursor, + })) + for (const child of children.data) sessionsById.set(child.id, child) + cursor = children.cursor?.next ?? undefined + if (cursor && seenCursors.has(cursor)) throw new Error(`Repeated child session cursor: ${cursor}`) if (cursor) seenCursors.add(cursor) } while (cursor) - } catch (error) { - complete = false - firstError ??= error - log.warn("Failed to complete session list", { instanceId, directory, error }) - } - })) - - if (!loadedPage && firstError) throw firstError + })) + } return { data: Array.from(sessionsById.values()), - complete, + complete: !response.cursor?.next, + nextCursor: response.cursor?.next ?? undefined, } } @@ -306,7 +306,7 @@ async function fetchSessions(instanceId: string, options?: { setSessionListError(instanceId, null) try { - const sessionListOptions = instance.folder ? { directory: instance.folder } : {} + const sessionListOptions = { ...(instance.folder ? { directory: instance.folder } : {}), parentID: null as null, order: "desc" as const } const existingSessions = new Map(sessions().get(instanceId) ?? new Map()) log.info("session.list", { instanceId, limit: PROJECT_SESSION_LIST_LIMIT, directory: sessionListOptions.directory }) @@ -394,7 +394,7 @@ async function fetchSessions(instanceId: string, options?: { }) } - if (response.complete) setSessionPage(instanceId, rootIds, false, options?.reset ?? true) + setSessionPage(instanceId, rootIds, Boolean(response.nextCursor), options?.reset ?? true, response.nextCursor) reconcilePendingSessionIndicators(instanceId) @@ -430,7 +430,37 @@ async function fetchSessions(instanceId: string, options?: { } async function loadMoreSessions(instanceId: string): Promise { - return + const pending = sessionPageRequests.get(instanceId) + if (pending) return pending + const request = loadNextSessionPage(instanceId).finally(() => sessionPageRequests.delete(instanceId)) + sessionPageRequests.set(instanceId, request) + return request +} + +async function loadNextSessionPage(instanceId: string): Promise { + const cursor = getSessionNextCursor(instanceId) + if (!cursor) return + const instance = instances().get(instanceId) + if (!instance?.client) throw new Error("Instance not ready") + const response = await fetchV2Sessions(instanceId, { + directory: instance.folder, + parentID: null, + order: "desc", + cursor, + }) + if (getSessionNextCursor(instanceId) !== cursor) return + const deleted = getAuthoritativelyDeletedSessionIdsForInstance(instanceId) + setSessions((previous) => { + const next = new Map(previous) + const current = new Map(next.get(instanceId) ?? []) + for (const item of response.data) { + if (!deleted.has(item.id)) current.set(item.id, toClientSessionV2(instanceId, item, current.get(item.id))) + } + next.set(instanceId, current) + return next + }) + const roots = response.data.filter((item) => !item.parentID).map((item) => item.id) + setSessionPage(instanceId, roots, Boolean(response.nextCursor), false, response.nextCursor) } async function searchSessions(instanceId: string, query: string): Promise { @@ -530,7 +560,6 @@ function toClientSessionV2(instanceId: string, apiSession: SDKSession, existingS }, revert: apiSession.revert ?? existingSession?.revert, pendingPermission: existingSession?.pendingPermission, - pendingQuestion: existingSession?.pendingQuestion, } } @@ -735,7 +764,6 @@ async function deleteSession(instanceId: string, sessionId: string): Promise() -const NATIVE_REFRESH_DELAY_MS = 75 -const NATIVE_TERMINAL_RETRY_DELAY_MS = 500 -const MAX_NATIVE_TERMINAL_REFRESH_ATTEMPTS = 3 -const nativeRefreshes = new Map - running?: Promise - terminalAttempts?: number -}>() let activeRetryToast: ToastHandle | null = null function speakCompletedAssistantText(instanceId: string, sessionId: string): void { @@ -104,108 +71,11 @@ function speakCompletedAssistantText(instanceId: string, sessionId: string): voi } } -function requestNativeSessionRefresh(instanceId: string, sessionId: string, final = false): void { - const key = `${instanceId}:${sessionId}` - const refresh = nativeRefreshes.get(key) ?? { instanceId, sessionId, pending: false, speakAfter: false } - refresh.pending = true - refresh.speakAfter ||= final - nativeRefreshes.set(key, refresh) - - function schedule(delay: number): void { - if (refresh.timer || refresh.running) return - refresh.timer = setTimeout(() => { - refresh.timer = undefined - void run() - }, delay) - } - - async function run(): Promise { - if (refresh.running) return refresh.running - refresh.pending = false - refresh.running = (async () => { - const terminal = refresh.speakAfter - if (!instances().has(refresh.instanceId) - || !sessions().get(refresh.instanceId)?.has(refresh.sessionId) - || getAuthoritativelyDeletedSessionIdsForInstance(refresh.instanceId).has(refresh.sessionId)) { - clearNativeContentDeltaState(refresh.instanceId, refresh.sessionId) - return - } - try { - await loadMessages(refresh.instanceId, refresh.sessionId, { force: true, skipChildren: true }) - if (!instances().has(refresh.instanceId) - || !sessions().get(refresh.instanceId)?.has(refresh.sessionId) - || getAuthoritativelyDeletedSessionIdsForInstance(refresh.instanceId).has(refresh.sessionId)) { - clearNativeContentDeltaState(refresh.instanceId, refresh.sessionId) - return - } - if (terminal) { - settleNativeContentDeltas(refresh.instanceId, refresh.sessionId) - refresh.terminalAttempts = 0 - } - } catch (error) { - log.error("Failed to refresh native session messages", { instanceId, sessionId, error }) - if (terminal - && (refresh.terminalAttempts ?? 0) + 1 < MAX_NATIVE_TERMINAL_REFRESH_ATTEMPTS - && instances().has(refresh.instanceId) - && sessions().get(refresh.instanceId)?.has(refresh.sessionId)) { - refresh.terminalAttempts = (refresh.terminalAttempts ?? 0) + 1 - refresh.pending = true - } - } - })().finally(() => { - refresh.running = undefined - if (refresh.pending) { - if (refresh.speakAfter) schedule(NATIVE_TERMINAL_RETRY_DELAY_MS) - else schedule(NATIVE_REFRESH_DELAY_MS) - return - } - if (refresh.speakAfter) { - refresh.speakAfter = false - if (instances().has(refresh.instanceId) && sessions().get(refresh.instanceId)?.has(refresh.sessionId)) { - speakCompletedAssistantText(refresh.instanceId, refresh.sessionId) - } - } - nativeRefreshes.delete(key) - }) - return refresh.running - } - - if (final) { - if (refresh.timer) { - clearTimeout(refresh.timer) - refresh.timer = undefined - } - if (!refresh.running) void run() - } else { - schedule(NATIVE_REFRESH_DELAY_MS) - } -} - -function clearNativeSessionRefresh(instanceId: string, sessionId: string): void { - const refresh = nativeRefreshes.get(`${instanceId}:${sessionId}`) - if (refresh?.timer) clearTimeout(refresh.timer) - if (refresh) { - refresh.pending = false - refresh.speakAfter = false - } - nativeRefreshes.delete(`${instanceId}:${sessionId}`) -} - function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent): void { - if (event.type === "session.text.delta" || event.type === "session.reasoning.delta") { - const sessionId = event.data.sessionID - if (!instances().has(instanceId) || getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return - if (!applyNativeContentDelta(instanceId, event)) return - ensureSessionStatus(instanceId, sessionId, "working", event.location?.directory) - return - } switch (event.type) { case "form.created": - addPendingForm(instanceId, event.data.form) - return case "form.replied": case "form.cancelled": - removePendingForm(instanceId, event.data.id) return case "session.renamed": if (!sessions().get(instanceId)?.has(event.data.sessionID)) void fetchSessionInfo(instanceId, event.data.sessionID, event.location?.directory) @@ -235,7 +105,6 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent) return case "session.compaction.started": ensureSessionStatus(instanceId, event.data.sessionID, "compacting", event.location?.directory) - requestNativeSessionRefresh(instanceId, event.data.sessionID) return case "session.compaction.failed": case "session.execution.interrupted": @@ -258,7 +127,6 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent) ) { ensureSessionStatus(instanceId, sessionId, "working", event.location?.directory) } - requestNativeSessionRefresh(instanceId, sessionId) } function setTerminalNativeSessionStatus(instanceId: string, sessionId: string, failed: boolean, directory?: string): void { @@ -266,8 +134,7 @@ function setTerminalNativeSessionStatus(instanceId: string, sessionId: string, f if (existing) setSessionStatus(instanceId, sessionId, "idle", { force: true }) else ensureSessionStatus(instanceId, sessionId, "idle", directory) if (failed) messageStoreBus.getOrCreate(instanceId).failPendingSends(sessionId) - settleNativeContentDeltas(instanceId, sessionId) - requestNativeSessionRefresh(instanceId, sessionId, true) + speakCompletedAssistantText(instanceId, sessionId) } function handleSessionMoved(sourceInstanceId: string, sessionId: string, directory: string): void { @@ -407,7 +274,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory retry: compacting ? null : fetched.retry, idleSince: getIdleSinceForStatusTransition(existing?.status, compacting ? "compacting" : fetched.status, existing?.idleSince), pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission, - pendingQuestion: existing?.pendingQuestion ?? false, pendingForm: existing?.pendingForm ?? false, runtimeStatusKnown: compacting || existing?.runtimeStatusKnown || false, } @@ -576,8 +442,6 @@ function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): v if (!sessionId) return log.info(`[SSE] Session deleted: ${sessionId}`) - clearNativeSessionRefresh(instanceId, sessionId) - clearNativeContentDeltaState(instanceId, sessionId) removeSessionRuntimeState(instanceId, sessionId) } @@ -593,8 +457,7 @@ function handleSessionIdle(instanceId: string, event: SessionIdle): void { } ensureSessionStatus(instanceId, sessionId, "idle", event.location?.directory) - settleNativeContentDeltas(instanceId, sessionId) - requestNativeSessionRefresh(instanceId, sessionId, true) + speakCompletedAssistantText(instanceId, sessionId) log.info(`[SSE] Session idle: ${sessionId}`) } @@ -638,8 +501,6 @@ function handleSessionCompacted(instanceId: string, event: SessionCompactionEnde if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true }) else ensureSessionStatus(instanceId, sessionID, "working", event.location?.directory) - loadMessages(instanceId, sessionID, { force: true }).catch((error) => log.error("Failed to reload session after compaction", error)) - const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionID) const label = session?.title?.trim() ? session.title : sessionID @@ -703,12 +564,7 @@ function handlePermissionUpdated(instanceId: string, event: PermissionAsked): vo log.info(`[SSE] Ignoring stale permission request after local reply: ${permissionId}`) return } - const isPending = getPermissionQueue(instanceId).some((pending) => pending.id === permissionId) - void isPending - log.info(`[SSE] Permission request: ${permissionId} (${getPermissionKind(permission)})`) - const queuedPermission = addPermissionToQueue(instanceId, permission) ?? permission - upsertPermissionV2(instanceId, queuedPermission) const sessionId = getPermissionSessionId(permission) @@ -726,45 +582,12 @@ function handlePermissionReplied(instanceId: string, event: PermissionReplied): log.info(`[SSE] Permission replied: ${requestId}`) markPermissionReplied(instanceId, requestId) - removePermissionFromQueue(instanceId, requestId) - removePermissionV2(instanceId, requestId) -} - -function handleQuestionAsked(instanceId: string, event: QuestionAsked): void { - const request = event.data as QuestionRequest - if (!request) return - log.info(`[SSE] Question asked: ${getQuestionId(request)}`) - addQuestionToQueue(instanceId, request) - upsertQuestionV2(instanceId, request) - - const sessionId = getQuestionSessionId(request) - - if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) { - const title = getInstanceDisplayName(instanceId) - const label = getSessionTitle(instanceId, sessionId) - const body = label ? `Session "${label}" needs input` : "Session needs input" - fireOsNotification({ title, body }) - } -} - -function handleQuestionAnswered( - instanceId: string, - event: QuestionReplied | QuestionRejected, -): void { - const requestId = getRequestIdFromQuestionReply(event.data) - if (!requestId) return - - log.info(`[SSE] Question answered: ${requestId}`) - removeQuestionFromQueue(instanceId, requestId) - removeQuestionV2(instanceId, requestId) } export { handleNativeSessionEvent, handlePermissionReplied, handlePermissionUpdated, - handleQuestionAsked, - handleQuestionAnswered, handleSessionCompacted, handleSessionDeleted, handleSessionError, diff --git a/packages/ui/src/stores/session-list-options.ts b/packages/ui/src/stores/session-list-options.ts index 9736749a..8057af7d 100644 --- a/packages/ui/src/stores/session-list-options.ts +++ b/packages/ui/src/stores/session-list-options.ts @@ -1,9 +1,13 @@ -export const PROJECT_SESSION_LIST_LIMIT = 10000 +export const PROJECT_SESSION_LIST_LIMIT = 200 type ProjectSessionListInput = { directory?: string search?: string cursor?: string + project?: string + subpath?: string + parentID?: string | null + order?: "asc" | "desc" } export type ProjectSessionListOptions = ProjectSessionListInput & { @@ -26,6 +30,10 @@ export function buildProjectSessionListOptions(options: ProjectSessionListInput) ...(options.directory ? { directory: options.directory } : {}), ...(options.search ? { search: options.search } : {}), ...(options.cursor ? { cursor: options.cursor } : {}), + ...(options.project ? { project: options.project } : {}), + ...(options.subpath ? { subpath: options.subpath } : {}), + ...(options.parentID !== undefined ? { parentID: options.parentID } : {}), + ...(options.order ? { order: options.order } : {}), limit: PROJECT_SESSION_LIST_LIMIT, } } diff --git a/packages/ui/src/stores/session-native-events.test.ts b/packages/ui/src/stores/session-native-events.test.ts index fbaececd..9ce2d67d 100644 --- a/packages/ui/src/stores/session-native-events.test.ts +++ b/packages/ui/src/stores/session-native-events.test.ts @@ -4,9 +4,7 @@ import { describe, it } from "node:test" import { sdkManager } from "../lib/sdk-manager.ts" import type { Session } from "../types/session.ts" import { addInstance, removeInstance } from "./instances.ts" -import { messageStoreBus } from "./message-v2/bus.ts" -import { clearNativeContentDeltaState } from "./native-session-streaming.ts" -import { handleNativeSessionEvent, handleSessionIdle, handleSessionStatus } from "./session-events.ts" +import { handleNativeSessionEvent, handleSessionStatus } from "./session-events.ts" import { clearInstanceDeletedSessionAuthority, sessions, setSessions } from "./session-state.ts" const delay = (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)) @@ -21,122 +19,6 @@ function session(instanceId: string, id: string): Session { } describe("native session event reducer", () => { - it("coalesces text and tool events, then refreshes authoritatively on idle", async () => { - const instanceId = "native-events" - const sessionId = "session" - let calls = 0 - const client = { - session: {}, - message: { - list: async () => { - calls += 1 - return { data: [{ - id: "assistant", - type: "assistant", - agent: "build", - model: { providerID: "provider", id: "model" }, - time: { created: 2, ...(calls > 1 ? { completed: 3 } : {}) }, - content: [ - { type: "text", text: calls > 1 ? "completed text" : "streaming text" }, - { - id: "tool", - type: "tool", - name: "bash", - time: { created: 2 }, - state: { status: "running", input: { command: "pwd" }, metadata: {} }, - }, - ], - }] } - }, - }, - } as any - ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) - addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) - - try { - handleNativeSessionEvent(instanceId, { - id: "text", created: 1, type: "session.text.delta", - data: { sessionID: sessionId, assistantMessageID: "assistant", ordinal: 0, delta: "streaming text" }, - }) - const store = messageStoreBus.getOrCreate(instanceId) - assert.equal((store.getMessage("assistant")?.parts["assistant-text-native-0"]?.data as any)?.text, "streaming text") - handleNativeSessionEvent(instanceId, { - id: "tool", created: 2, type: "session.tool.progress", - data: { sessionID: sessionId, assistantMessageID: "assistant", id: "tool", metadata: {} }, - }) - await delay(120) - - assert.equal(calls, 1) - assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "working") - assert.equal((store.getMessage("assistant")?.parts["assistant-text-0"]?.data as any)?.text, "streaming text") - assert.equal((store.getMessage("assistant")?.parts.tool?.data as any)?.state.status, "running") - - handleSessionIdle(instanceId, { type: "session.idle", data: { sessionID: sessionId } } as any) - await delay(20) - - assert.equal(calls, 2) - assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "idle") - assert.equal(store.getMessage("assistant")?.status, "complete") - assert.equal((store.getMessage("assistant")?.parts["assistant-text-0"]?.data as any)?.text, "completed text") - } finally { - messageStoreBus.unregisterInstance(instanceId) - setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) - clearInstanceDeletedSessionAuthority(instanceId) - removeInstance(instanceId, { authoritative: false }) - sdkManager.destroyClientsForInstance(instanceId) - } - }) - - it("refreshes periodically during a continuous fast native stream", async () => { - const instanceId = "native-fast-stream" - const sessionId = "session" - let calls = 0 - const client = { - message: { list: async () => { calls += 1; return { data: [] } } }, - } as any - ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) - addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) - - const eventTypes = ["session.text.delta", "session.reasoning.delta", "session.tool.progress"] as const - let eventIndex = 0 - const stream = setInterval(() => { - const type = eventTypes[eventIndex % eventTypes.length] - const id = `event-${eventIndex++}` - handleNativeSessionEvent(instanceId, { - id, - created: eventIndex, - type, - data: type === "session.text.delta" - ? { sessionID: sessionId, assistantMessageID: "assistant", ordinal: 0, delta: "text " } - : type === "session.reasoning.delta" - ? { sessionID: sessionId, assistantMessageID: "assistant", ordinal: 0, delta: "reason " } - : { sessionID: sessionId, assistantMessageID: "assistant", id: "tool", metadata: {} }, - } as any) - }, 10) - - try { - await delay(260) - clearInterval(stream) - await delay(120) - - assert.ok(calls >= 2, `expected periodic refreshes, received ${calls}`) - assert.ok(calls <= 5, `expected refreshes to stay bounded, received ${calls}`) - const streamed = messageStoreBus.getOrCreate(instanceId).getMessage("assistant") - assert.match((streamed?.parts["assistant-text-native-0"]?.data as any)?.text ?? "", /text/) - assert.match((streamed?.parts["assistant-reasoning-native-0"]?.data as any)?.text ?? "", /reason/) - } finally { - clearInterval(stream) - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) - clearInstanceDeletedSessionAuthority(instanceId) - removeInstance(instanceId, { authoritative: false }) - sdkManager.destroyClientsForInstance(instanceId) - } - }) - it("keeps the newest status while an unknown session is hydrating", async () => { const instanceId = "native-status-race" const sessionId = "unknown" diff --git a/packages/ui/src/stores/session-pagination.test.ts b/packages/ui/src/stores/session-pagination.test.ts index e14f8799..2e061ca5 100644 --- a/packages/ui/src/stores/session-pagination.test.ts +++ b/packages/ui/src/stores/session-pagination.test.ts @@ -5,7 +5,6 @@ import { applySessionPage, getDefaultSessionPaginationState } from "./session-pa import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, - getUniqueSessionDirectories, } from "./session-list-options.ts" describe("project session list loading", () => { @@ -30,35 +29,10 @@ describe("project session list loading", () => { }) }) - it("queries each unique logical root and known worktree directory", () => { - assert.deepEqual( - getUniqueSessionDirectories(["/repo", "/repo", "/repo/.codenomad/worktrees/feature"]), - ["/repo", "/repo/.codenomad/worktrees/feature"], - ) - }) - - it("normalizes Windows paths when deduplicating directories", () => { - assert.deepEqual( - getUniqueSessionDirectories([String.raw`C:\Repo`, "c:/repo/", String.raw`C:\Repo\.codenomad\worktrees\feature`]), - [String.raw`C:\Repo`, String.raw`C:\Repo\.codenomad\worktrees\feature`], - ) - }) - - it("marks the loaded session list complete because the API does not paginate", () => { - const state = applySessionPage(getDefaultSessionPaginationState(), ["root-1", "root-2"], false, true) - - assert.deepEqual(state.ids, ["root-1", "root-2"]) - assert.equal(state.hasMore, false) - assert.equal(state.nextCursor, undefined) - }) - - it("resets stale cursor state when the one-shot list refreshes", () => { - const previous = applySessionPage(getDefaultSessionPaginationState(), ["old-root"], true, true, "old-cursor") - const next = applySessionPage(previous, ["new-root"], false, true) - - assert.deepEqual(next.ids, ["new-root"]) - assert.equal(next.hasMore, false) - assert.equal(next.nextCursor, undefined) + it("retains the native cursor without dropping prior roots", () => { + const first = applySessionPage(getDefaultSessionPaginationState(), ["root-1"], true, true, "page-2") + const next = applySessionPage(first, ["root-2"], false, false) + assert.deepEqual(next, { ids: ["root-1", "root-2"], hasMore: false, nextCursor: undefined }) }) }) diff --git a/packages/ui/src/stores/session-pending-state.test.ts b/packages/ui/src/stores/session-pending-state.test.ts index d4f075ed..9f39e358 100644 --- a/packages/ui/src/stores/session-pending-state.test.ts +++ b/packages/ui/src/stores/session-pending-state.test.ts @@ -7,44 +7,34 @@ describe("applySessionPendingState", () => { it("reconciles interruptions that arrived before sessions", () => { const sessions = new Map([ ["permission", { id: "permission" }], - ["question", { id: "question" }], ["form", { id: "form" }], ["idle", { id: "idle", pendingPermission: true }], ]) - const result = applySessionPendingState(sessions, new Set(["permission"]), new Set(["question"]), new Set(["form"])) + const result = applySessionPendingState(sessions, new Set(["permission"]), new Set(["form"])) assert.deepEqual(result.get("permission"), { id: "permission", pendingPermission: true, - pendingQuestion: false, - pendingForm: false, - }) - assert.deepEqual(result.get("question"), { - id: "question", - pendingPermission: false, - pendingQuestion: true, pendingForm: false, }) assert.deepEqual(result.get("form"), { id: "form", pendingPermission: false, - pendingQuestion: false, pendingForm: true, }) assert.deepEqual(result.get("idle"), { id: "idle", pendingPermission: false, - pendingQuestion: false, pendingForm: false, }) }) it("preserves the map when pending state already matches", () => { const sessions = new Map([ - ["session", { id: "session", pendingPermission: true, pendingQuestion: false, pendingForm: false }], + ["session", { id: "session", pendingPermission: true, pendingForm: false }], ]) - assert.equal(applySessionPendingState(sessions, new Set(["session"]), new Set(), new Set()), sessions) + assert.equal(applySessionPendingState(sessions, new Set(["session"]), new Set()), sessions) }) }) diff --git a/packages/ui/src/stores/session-pending-state.ts b/packages/ui/src/stores/session-pending-state.ts index d2bb7697..7ce70a7f 100644 --- a/packages/ui/src/stores/session-pending-state.ts +++ b/packages/ui/src/stores/session-pending-state.ts @@ -1,30 +1,26 @@ type SessionPendingState = { id: string pendingPermission?: boolean - pendingQuestion?: boolean pendingForm?: boolean } export function applySessionPendingState( sessions: Map, permissionSessionIds: ReadonlySet, - questionSessionIds: ReadonlySet, formSessionIds: ReadonlySet = new Set(), ): Map { let next = sessions for (const [sessionId, session] of sessions) { const pendingPermission = permissionSessionIds.has(sessionId) - const pendingQuestion = questionSessionIds.has(sessionId) const pendingForm = formSessionIds.has(sessionId) if ( session.pendingPermission === pendingPermission - && session.pendingQuestion === pendingQuestion && session.pendingForm === pendingForm ) continue if (next === sessions) next = new Map(sessions) - next.set(sessionId, { ...session, pendingPermission, pendingQuestion, pendingForm }) + next.set(sessionId, { ...session, pendingPermission, pendingForm }) } return next diff --git a/packages/ui/src/stores/session-send-lifecycle.test.ts b/packages/ui/src/stores/session-send-lifecycle.test.ts index fd41e19e..8a45bc59 100644 --- a/packages/ui/src/stores/session-send-lifecycle.test.ts +++ b/packages/ui/src/stores/session-send-lifecycle.test.ts @@ -190,7 +190,7 @@ describe("optimistic send lifecycle", () => { } }) - it("refreshes an accepted send on native success without marking it failed", async () => { + it("does not reload an accepted send on native success", async () => { const instanceId = "send-session-success" const sessionId = "session" let request: any @@ -216,7 +216,7 @@ describe("optimistic send lifecycle", () => { assert.equal(store.getMessage(request.id)?.status, "sent") await new Promise((resolve) => setImmediate(resolve)) - assert.equal(refreshCalls, 1) + assert.equal(refreshCalls, 0) assert.notEqual(store.getMessage(request.id)?.status, "error") } finally { cleanup() diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index a40ef568..1309212f 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -200,8 +200,8 @@ function isSessionSearchLoading(instanceId: string): boolean { return sessionSearch().get(instanceId)?.loading ?? false } -function getIndicatorBucket(session: Pick): InstanceSessionIndicatorStatus | "idle" { - if (session.pendingPermission || session.pendingQuestion || session.pendingForm) { +function getIndicatorBucket(session: Pick): InstanceSessionIndicatorStatus | "idle" { + if (session.pendingPermission || session.pendingForm) { return "permission" } const status = session.status ?? "idle" @@ -268,7 +268,7 @@ function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map, - questionSessionIds: ReadonlySet, formSessionIds: ReadonlySet = new Set(), ): void { setSessions((prev) => { const instanceSessions = prev.get(instanceId) if (!instanceSessions) return prev - const reconciled = applySessionPendingState(instanceSessions, permissionSessionIds, questionSessionIds, formSessionIds) + const reconciled = applySessionPendingState(instanceSessions, permissionSessionIds, formSessionIds) if (reconciled === instanceSessions) return prev const next = new Map(prev) @@ -654,7 +649,7 @@ function hydrateSessionGenerationRecovery( ): void { for (const [sessionId, persisted] of Object.entries(markers)) { withSession(instanceId, sessionId, (session) => { - const recovery = session.pendingPermission || session.pendingQuestion || session.pendingForm + const recovery = session.pendingPermission || session.pendingForm ? null : resolveHydratedGenerationRecovery(persisted, session.status, session.runtimeStatusKnown === true) if ((session.generationRecovery ?? null) === recovery) return false @@ -1240,7 +1235,6 @@ export { pruneDraftPrompts, withSession, setSessionPendingPermission, - setSessionPendingQuestion, setSessionPendingForm, reconcileSessionPendingState, markSessionIdleSeen, diff --git a/packages/ui/src/stores/session-status.test.ts b/packages/ui/src/stores/session-status.test.ts index 92afd542..445ebdd0 100644 --- a/packages/ui/src/stores/session-status.test.ts +++ b/packages/ui/src/stores/session-status.test.ts @@ -8,18 +8,17 @@ import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility.ts" describe("shouldSessionHoldWakeLock", () => { it("holds wake lock only for qualifying active work", () => { - assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: false, pendingForm: false }), true) + assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingForm: false }), true) assert.equal( - shouldSessionHoldWakeLock({ status: "compacting", pendingPermission: false, pendingQuestion: false, pendingForm: false }), + shouldSessionHoldWakeLock({ status: "compacting", pendingPermission: false, pendingForm: false }), true, ) - assert.equal(shouldSessionHoldWakeLock({ status: "idle", pendingPermission: false, pendingQuestion: false, pendingForm: false }), false) + assert.equal(shouldSessionHoldWakeLock({ status: "idle", pendingPermission: false, pendingForm: false }), false) }) it("does not hold wake lock while waiting for permission or input", () => { - assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: true, pendingQuestion: false, pendingForm: false }), false) - assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: true, pendingForm: false }), false) - assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: false, pendingForm: true }), false) + assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: true, pendingForm: false }), false) + assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingForm: true }), false) }) }) diff --git a/packages/ui/src/stores/session-status.ts b/packages/ui/src/stores/session-status.ts index 9aa8e920..cc88968b 100644 --- a/packages/ui/src/stores/session-status.ts +++ b/packages/ui/src/stores/session-status.ts @@ -123,7 +123,7 @@ export function shouldShowSessionStatus( return false } - if (session.pendingPermission || session.pendingQuestion || session.pendingForm || session.retry) { + if (session.pendingPermission || session.pendingForm || session.retry) { return true } diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 68d83ded..dda6d4c5 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -100,8 +100,6 @@ import { handleNativeSessionEvent, handlePermissionReplied, handlePermissionUpdated, - handleQuestionAnswered, - handleQuestionAsked, handleSessionCompacted, handleSessionDeleted, handleSessionError, @@ -121,8 +119,6 @@ sseManager.onSessionStatus = handleSessionStatus sseManager.onTuiToast = handleTuiToast sseManager.onPermissionUpdated = handlePermissionUpdated sseManager.onPermissionReplied = handlePermissionReplied -sseManager.onQuestionAsked = handleQuestionAsked -sseManager.onQuestionAnswered = handleQuestionAnswered sseManager.onWorktreeReady = handleWorktreeReady export { diff --git a/packages/ui/src/stores/wake-lock-eligibility.ts b/packages/ui/src/stores/wake-lock-eligibility.ts index a56151f4..63b5ed87 100644 --- a/packages/ui/src/stores/wake-lock-eligibility.ts +++ b/packages/ui/src/stores/wake-lock-eligibility.ts @@ -1,9 +1,9 @@ import type { Session } from "../types/session" export function shouldSessionHoldWakeLock( - session: Pick, + session: Pick, ): boolean { - if (session.pendingPermission || session.pendingQuestion || session.pendingForm) { + if (session.pendingPermission || session.pendingForm) { return false } diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index e0067a69..24c515a0 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -127,9 +127,7 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc if (!trimmed || trimmed === "root") { throw new Error("Invalid worktree") } - await moveSessionsFromDeletedWorktree(instanceId, trimmed).catch((error) => { - log.warn("Failed to move sessions from deleted worktree", { instanceId, slug: trimmed, error }) - }) + await moveSessionsFromDeletedWorktree(instanceId, trimmed) await serverApi.deleteWorktree(instanceId, trimmed, options) } diff --git a/packages/ui/src/types/question.ts b/packages/ui/src/types/question.ts deleted file mode 100644 index 50f34bce..00000000 --- a/packages/ui/src/types/question.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { - QuestionReplied, - QuestionRejected, - QuestionRequest as NativeQuestionRequest, -} from "@opencode-ai/client" - -export type QuestionRequest = NativeQuestionRequest - -export function getQuestionId(question: QuestionRequest | null | undefined): string { - return question?.id ?? "" -} - -export function getQuestionSessionId(question: QuestionRequest | null | undefined): string | undefined { - return question?.sessionID -} - -export function getQuestionMessageId(question: QuestionRequest | null | undefined): string | undefined { - return question?.tool?.messageID -} - -export function getQuestionCallId(question: QuestionRequest | null | undefined): string | undefined { - return question?.tool?.id -} - -export function getRequestIdFromQuestionReply( - data: QuestionReplied["data"] | QuestionRejected["data"] | null | undefined, -): string | undefined { - return data?.requestID -} diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index e3a0a583..40f2e880 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -74,7 +74,6 @@ export interface Session extends Omit { location: LocationRef version?: string pendingPermission?: boolean // Indicates if session is waiting on user permission - pendingQuestion?: boolean // Indicates if session is waiting on user input pendingForm?: boolean // Indicates if session is waiting on a structured form response status: SessionStatus // Single source of truth for session status retry?: SessionRetryState | null // Retry metadata for transient backoff states