merge(DEV-v2): refresh PR649 session migration

Merge upstream DEV-v2 while retaining server-owned transactional worktree evacuation. The UI issues one deletion request and refreshes authoritative worktree and session state, avoiding partial client-cache-driven family moves; server deletion remains blocked whenever complete-family evacuation fails and rolls back transaction-owned moves.

Preserve the upstream native createData and cursor pagination contracts and the removal of forceNew canonical duplication. Validated with server and UI typechecks, focused server worktree transaction tests, focused UI browser integration and pagination tests, and git diff checks.
This commit is contained in:
Pascal André 2026-08-19 01:52:56 +02:00
commit 0d3bdd1a01
No known key found for this signature in database
65 changed files with 509 additions and 2269 deletions

View file

@ -148,7 +148,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

View file

@ -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.

View file

@ -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"`

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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/`.

200
package-lock.json generated
View file

@ -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"
}
}
}
}

View file

@ -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.

View file

@ -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",

View file

@ -42,7 +42,6 @@ export interface WorkspaceCreateRequest {
path: string
name?: string
requestId?: string
forceNew?: boolean
}
export interface WorkspaceCloneRequest {
@ -477,7 +476,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 }

View file

@ -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[] = []

View file

@ -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({

View file

@ -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 }

View file

@ -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<typeof WorkspaceManager>[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)
})
})

View file

@ -30,6 +30,7 @@ interface InstanceEventBridgeOptions {
export class InstanceEventBridge {
private readonly controller = new AbortController()
private status: InstanceStreamStatus = "connecting"
private generation = 0
private task?: Promise<void>
private readonly directoryOwners = new Map<string, { expiresAt: number; owners: Promise<string[]> }>()
private readonly sessionDirectories = new Map<string, { expiresAt: number; directory: Promise<string | undefined> }>()
@ -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) {

View file

@ -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<void>()

View file

@ -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<string, CreationRequestState>
@ -350,12 +355,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")
@ -486,6 +485,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
@ -501,6 +505,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,

View file

@ -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

View file

@ -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<boolean>
@ -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<boolean> => {
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<boolean> => {
await (this.dependencies.stop ?? Service.stop)(owned.stopOptions)
return true
}
private waitForStop = async (

View file

@ -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",

View file

@ -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)

View file

@ -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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (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<FolderSelectionViewProps> = (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())

View file

@ -170,7 +170,7 @@ const FormRequest: Component<FormRequestProps> = (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<FormRequestProps> = (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)}
/>

View file

@ -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<InstanceShellProps> = (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<InstanceShellProps> = (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) {

View file

@ -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,
)
}

View file

@ -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<PermissionApprovalModalProps> = (props) => {
const { t } = useI18n()
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
@ -198,13 +161,11 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (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<InterruptionItem[]>(() => {
@ -216,14 +177,6 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (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<PermissionApprovalModalProps> = (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<PermissionApprovalModalProps> = (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<PermissionApprovalModalProps> = (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<PermissionApprovalModalProps> = (props)
{(err) => <div class="tool-call-permission-error">{err()}</div>}
</Show>
</Show>
<Show when={item.kind === "question"}>
<div class="permission-center-fallback-hint">{t("permissionApproval.fallbackHint")}</div>
</Show>
</div>
}
>

View file

@ -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<PermissionNotificationBannerProps> = (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<PermissionNotificationBannerProps>
)
}
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

View file

@ -563,7 +563,7 @@ const SessionList: Component<SessionListProps> = (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"

View file

@ -66,7 +66,7 @@ export const SessionView: Component<SessionViewProps> = (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))

View file

@ -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<typeof useI18n>["t"]
store: () => ReturnType<typeof messageStoreBus.getOrCreate>
pendingPermission: () => { permission: PermissionRequest; active: boolean } | undefined
pendingQuestion: () => { request: QuestionRequest; active: boolean } | undefined
isPermissionActive: () => boolean
isQuestionActive: () => boolean
hasToolInput: () => boolean
isToolInputVisible: () => boolean
toolInput: () => Record<string, any> | 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<string | null>(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<string | null>(null)
const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal<Record<string, string[][]>>({})
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 = () => (
<QuestionToolBlock
toolName={props.toolName}
toolState={props.toolState}
toolCallId={props.toolCallIdentifier}
request={questionDetails}
active={props.isQuestionActive}
submitting={questionSubmitting}
error={questionError}
draftAnswers={questionDraftAnswers}
setDraftAnswers={setQuestionDraftAnswers}
onSubmit={() => 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: {
</Show>
{renderPermissionBlock()}
{renderQuestionBlock()}
</div>
)
}
@ -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<boolean | undefined>(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}

View file

@ -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<string>
toolState: Accessor<ToolState | undefined>
toolCallId: Accessor<string>
request: Accessor<QuestionRequest | undefined>
active: Accessor<boolean>
submitting: Accessor<boolean>
error: Accessor<string | null>
draftAnswers: Accessor<Record<string, string[][]>>
setDraftAnswers: (updater: (prev: Record<string, string[][]>) => Record<string, string[][]>) => void
onSubmit: () => void | Promise<void>
onDismiss: () => void | Promise<void>
}
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 (
<Show when={isVisible() && questions().length > 0}>
<div
class={`tool-call-permission p-0 gap-2 ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"} ${hasFinalAnswers() ? "tool-call-permission-answered" : ""}`}
>
<div class="tool-call-permission-body">
<div class="flex flex-col gap-2">
<For each={questions()}>
{(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 (
<div class="border border-base bg-surface-secondary p-3 text-primary">
<div class="flex items-baseline justify-between gap-2">
<div class="text-sm text-primary">
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
</div>
<Show when={multi()}>
<div class="text-xs text-muted">{t("toolCall.question.multiple")}</div>
</Show>
</div>
<div class="mt-1 text-sm font-medium text-primary">{q?.question}</div>
<div class="mt-3 flex flex-col gap-1">
<For each={q?.options ?? []}>
{(opt, optIndex) => {
const checked = () => selected().includes(opt.label)
return (
<label
class={`flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
title={opt.description}
>
<input
ref={(el) => {
if (i() === 0 && optIndex() === 0) firstInputRef = el
}}
type={inputType()}
name={groupName()}
class="mt-0.5 accent-[var(--accent-primary)]"
checked={checked()}
disabled={!props.active() || props.submitting()}
onChange={() => toggleOption(i(), opt.label)}
/>
<div class="flex flex-col">
<div class="text-sm leading-tight text-primary">{opt.label}</div>
<div class="text-xs text-muted leading-tight">{opt.description}</div>
</div>
</label>
)
}}
</For>
<Show when={showCustomAnswer()}>
<label
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
title={t("toolCall.question.custom.title")}
>
<input
ref={(el) => {
if (i() === 0 && (q?.options?.length ?? 0) === 0) firstInputRef = el
}}
type={inputType()}
name={groupName()}
class="mt-0.5 accent-[var(--accent-primary)]"
checked={customChecked()}
disabled={!props.active() || props.submitting()}
onChange={(e) => {
const container = e.currentTarget.closest("label")
const input = container?.querySelector("input[type='text']") as HTMLInputElement | null
if (!props.active()) return
if (customChecked()) {
clearCustomAnswer(i(), customSelected())
if (input) {
delete input.dataset.lastValue
}
return
}
toggleFromCustomInput(i(), input)
}}
/>
<div class="flex flex-1 flex-col gap-2">
<div class="text-sm leading-tight text-primary">{t("toolCall.question.custom.label")}</div>
<input
class="w-full rounded-md border border-base bg-surface-base px-2 py-1 text-sm text-primary"
type="text"
placeholder={t("toolCall.question.custom.placeholder")}
disabled={!props.active() || props.submitting()}
value={customValue()}
onFocus={(e) => {
if (!props.active()) return
// Keep the radio/checkbox selected while editing.
toggleFromCustomInput(i(), e.currentTarget)
}}
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.isComposing) {
if (!submitDisabled()) {
props.onSubmit()
}
}
}}
/>
</div>
</label>
</Show>
</div>
</div>
)
}}
</For>
<Show when={props.active()}>
<div class="tool-call-permission-actions px-3 pb-3">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={submitDisabled()}
onClick={() => props.onSubmit()}
>
{t("toolCall.question.actions.submit")}
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={props.submitting()}
onClick={() => props.onDismiss()}
>
{t("toolCall.question.actions.dismiss")}
</button>
</div>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>{t("toolCall.question.shortcuts.submit")}</span>
<kbd class="kbd">Esc</kbd>
<span>{t("toolCall.question.shortcuts.dismiss")}</span>
</div>
<Show when={props.error()}>
<div class="tool-call-permission-error">{props.error()}</div>
</Show>
</div>
</Show>
<Show when={!props.active() && props.request()}>
<p class="tool-call-permission-queued-text px-3 pb-3">{t("toolCall.question.queuedText")}</p>
</Show>
</div>
</div>
</div>
</Show>
)
}

View file

@ -60,6 +60,10 @@ export function createForegroundRefreshController(
}
return {
invalidate() {
dirtyGeneration += 1
void runRefresh()
},
handle(status: WorkspaceEventTransportStatus) {
if (status === "disconnected") {
connected = false

View file

@ -113,19 +113,14 @@ async function restoreTabs(context: RestoreContext): Promise<void> {
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

View file

@ -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

View file

@ -41,10 +41,18 @@ export function useForegroundRefresh(options: ForegroundRefreshOptions): void {
}
controller.handle(status)
})
const streamGenerations = new Map<string, number>()
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()
})
})
}

View file

@ -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

View file

@ -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<string | null>(null
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
// Interruption queues (permissions + questions) per instance
// Interruption queues per instance
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequest[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
class InterruptionRegistry<T extends { id: string }> {
private readonly enqueuedAt = new Map<string, number>()
private readonly sessionCounts = new Map<string, Map<string, number>>()
@ -183,10 +178,9 @@ class InterruptionRegistry<T extends { id: string }> {
}
const permissionRegistry = new InterruptionRegistry<PermissionRequest>()
const questionRegistry = new InterruptionRegistry<QuestionRequest>()
const formRegistry = new InterruptionRegistry<FormInfo>()
type InterruptionKind = "permission" | "question" | "form"
type InterruptionKind = "permission" | "form"
type ActiveInterruption = { kind: InterruptionKind; id: string } | null
@ -213,7 +207,6 @@ const initialSessionHydrations = new Map<string, Promise<void>>()
const initialWorkspaceMetadataHydrations = new Map<string, Promise<void>>()
const pendingRequestSyncEpochs = new Map<string, number>()
const pendingPermissionMutationEpochs = new Map<string, number>()
const pendingQuestionMutationEpochs = new Map<string, number>()
const pendingFormMutationEpochs = new Map<string, number>()
const pendingRequestSyncGenerations = new Map<string, number>()
const pendingRequestSyncs = new Map<string, {
@ -231,7 +224,6 @@ function bumpEpoch(epochs: Map<string, number>, 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<void> {
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<void>
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<void> {
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<void> {
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<void>
function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNullable<typeof sseManager.onInvalidation>>[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,

View file

@ -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)

View file

@ -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)
})

View file

@ -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<MessageInfo>) => 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,

View file

@ -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 })
})
})

View file

@ -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<string, Record<string, PermissionEntry>>
}
export interface QuestionEntry {
request: QuestionRequest
messageId?: string
partId?: string
enqueuedAt: number
}
export interface InstanceQuestionState {
queue: QuestionEntry[]
active: QuestionEntry | null
byMessage: Record<string, Record<string, QuestionEntry>>
}
export interface ScrollSnapshot {
scrollTop: number
scrollRatio?: number
@ -125,7 +111,6 @@ export interface InstanceMessageState {
pendingParts: Record<string, PendingPartEntry[]>
sessionRevisions: Record<string, number>
permissions: InstancePermissionState
questions: InstanceQuestionState
usage: Record<string, SessionUsageState>
scrollState: Record<string, ScrollSnapshot>
latestTodos: Record<string, LatestTodoSnapshot | undefined>

View file

@ -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<void>((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)
}
})
})

View file

@ -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<string>
eventOrder: string[]
parts: Map<string, TrackedPart>
renderTimers: Map<string, ReturnType<typeof setTimeout>>
settledMessageIds: Set<string>
settledMessageOrder: string[]
}
const MAX_TRACKED_EVENT_IDS = 20_000
const MAX_SETTLED_MESSAGE_IDS = 100
const RENDER_INTERVAL_MS = 16
const streamingState = new Map<string, Map<string, SessionStreamingState>>()
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)
}

View file

@ -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<string, unknown>, 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)
}
})
})

View file

@ -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<string, { data: Data; emit: (event: OpenCodeEvent) => 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<string, MessageInfo>()
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)
}

View file

@ -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<void>((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"])
})

View file

@ -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<string, string>()
const catalogRefreshes = new Map<string, { key: string; promise: Promise<void> }>()
const agentRequestIds = new Map<string, number>()
const providerRequestIds = new Map<string, number>()
const sessionPageRequests = new Map<string, Promise<void>>()
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<string, SDKSessi
async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions): Promise<ProjectSessionListResponse> {
const client = getRootClient(instanceId)
const directories = getUniqueSessionDirectories([
options.directory,
...getWorktrees(instanceId).map((worktree) => worktree.directory),
])
const sessionsById = new Map<string, SDKSession>()
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<string>()
try {
if (options.parentID === null && !options.search) {
await Promise.all(response.data.map(async (root) => {
let cursor: string | undefined
const seenCursors = new Set<string>()
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<string, Session>())
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<void> {
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<void> {
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<void> {
@ -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<voi
}
function removeSessionRuntimeState(instanceId: string, sessionId: string, authoritative = true): void {
clearNativeContentDeltaState(instanceId, sessionId)
cancelSessionGenerationAdmissions(instanceId, sessionId)
if (authoritative) markSessionDeletedAuthoritative(instanceId, sessionId)
deleteSessionAttachments(instanceId, sessionId)
@ -1079,7 +1107,6 @@ async function loadMessages(
return next
})
reconcilePendingPermissionsV2(instanceId, sessionId)
reconcilePendingQuestionsV2(instanceId, sessionId)
}
}
@ -1100,9 +1127,6 @@ async function loadMessages(
return next
})
}
if (instances().has(instanceId) && sessions().get(instanceId)?.has(sessionId)) {
reconcileNativeContentAfterSnapshot(instanceId, sessionId)
}
}
if (retryAfterRevisionConflict && sessions().get(instanceId)?.has(sessionId)) {

View file

@ -1,9 +1,6 @@
import type {
PermissionAsked,
PermissionReplied,
QuestionAsked,
QuestionRejected,
QuestionReplied,
SessionCompactionEnded,
SessionCreated,
SessionExecutionFailed,
@ -23,23 +20,14 @@ import {
getRequestIdFromPermissionReply,
} from "../types/permission"
import type { PermissionRequest } from "../types/permission"
import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question"
import type { QuestionRequest } from "../types/question"
import { showToastNotification, type ToastHandle, ToastVariant } from "../lib/notifications"
import { sendOsNotification } from "../lib/os-notifications"
import { preferences } from "./preferences"
import {
instances,
addPermissionToQueue,
getPermissionQueue,
removePermissionFromQueue,
markPermissionReplied,
hasRepliedPermission,
addQuestionToQueue,
removeQuestionFromQueue,
addPendingForm,
reconcilePendingSessionIndicators,
removePendingForm,
setPendingFormAddedHandler,
} from "./instances"
import { showAlertDialog } from "./alerts"
@ -56,41 +44,20 @@ import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForI
import { mergeFetchedSessionRuntimeState } from "./session-generation-recovery"
import { tGlobal } from "../lib/i18n"
import { fetchSessions, loadMessages, removeSessionRuntimeState } from "./session-api"
import { fetchSessions, removeSessionRuntimeState } from "./session-api"
import { getRootClient } from "./opencode-client"
import { getWorktrees } from "./worktrees"
import {
upsertPermissionV2,
upsertQuestionV2,
removePermissionV2,
removeQuestionV2,
setSessionRevertV2,
} from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import { handleConversationAssistantPartUpdated } from "./conversation-speech"
import {
applyNativeContentDelta,
clearNativeContentDeltaState,
settleNativeContentDeltas,
} from "./native-session-streaming"
const log = getLogger("sse")
const pendingSessionFetches = new Map<string, {
status: SessionStatus
retry?: SessionRetryState | null
}>()
const NATIVE_REFRESH_DELAY_MS = 75
const NATIVE_TERMINAL_RETRY_DELAY_MS = 500
const MAX_NATIVE_TERMINAL_REFRESH_ATTEMPTS = 3
const nativeRefreshes = new Map<string, {
instanceId: string
sessionId: string
pending: boolean
speakAfter: boolean
timer?: ReturnType<typeof setTimeout>
running?: Promise<void>
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<void> {
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,

View file

@ -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,
}
}

View file

@ -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<void>((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"

View file

@ -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 })
})
})

View file

@ -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)
})
})

View file

@ -1,30 +1,26 @@
type SessionPendingState = {
id: string
pendingPermission?: boolean
pendingQuestion?: boolean
pendingForm?: boolean
}
export function applySessionPendingState<T extends SessionPendingState>(
sessions: Map<string, T>,
permissionSessionIds: ReadonlySet<string>,
questionSessionIds: ReadonlySet<string>,
formSessionIds: ReadonlySet<string> = new Set(),
): Map<string, T> {
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

View file

@ -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<void>((resolve) => setImmediate(resolve))
assert.equal(refreshCalls, 1)
assert.equal(refreshCalls, 0)
assert.notEqual(store.getMessage(request.id)?.status, "error")
} finally {
cleanup()

View file

@ -200,8 +200,8 @@ function isSessionSearchLoading(instanceId: string): boolean {
return sessionSearch().get(instanceId)?.loading ?? false
}
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission" | "pendingQuestion" | "pendingForm">): InstanceSessionIndicatorStatus | "idle" {
if (session.pendingPermission || session.pendingQuestion || session.pendingForm) {
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission" | "pendingForm">): InstanceSessionIndicatorStatus | "idle" {
if (session.pendingPermission || session.pendingForm) {
return "permission"
}
const status = session.status ?? "idle"
@ -268,7 +268,7 @@ function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map<stri
let compacting = 0
for (const session of instanceSessions.values()) {
if (session.pendingPermission || session.pendingQuestion || session.pendingForm) {
if (session.pendingPermission || session.pendingForm) {
permission += 1
continue
}
@ -562,7 +562,7 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S
function setSessionPending(
instanceId: string,
sessionId: string,
field: "pendingPermission" | "pendingQuestion" | "pendingForm",
field: "pendingPermission" | "pendingForm",
pending: boolean,
): void {
if (pending) cancelSessionGenerationAdmissions(instanceId, sessionId)
@ -580,10 +580,6 @@ function setSessionPendingPermission(instanceId: string, sessionId: string, pend
setSessionPending(instanceId, sessionId, "pendingPermission", pending)
}
function setSessionPendingQuestion(instanceId: string, sessionId: string, pending: boolean): void {
setSessionPending(instanceId, sessionId, "pendingQuestion", pending)
}
function setSessionPendingForm(instanceId: string, sessionId: string, pending: boolean): void {
setSessionPending(instanceId, sessionId, "pendingForm", pending)
}
@ -591,14 +587,13 @@ function setSessionPendingForm(instanceId: string, sessionId: string, pending: b
function reconcileSessionPendingState(
instanceId: string,
permissionSessionIds: ReadonlySet<string>,
questionSessionIds: ReadonlySet<string>,
formSessionIds: ReadonlySet<string> = 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
@ -1237,7 +1232,6 @@ export {
pruneDraftPrompts,
withSession,
setSessionPendingPermission,
setSessionPendingQuestion,
setSessionPendingForm,
reconcileSessionPendingState,
markSessionIdleSeen,

View file

@ -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)
})
})

View file

@ -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
}

View file

@ -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 {

View file

@ -1,9 +1,9 @@
import type { Session } from "../types/session"
export function shouldSessionHoldWakeLock(
session: Pick<Session, "status" | "pendingPermission" | "pendingQuestion" | "pendingForm">,
session: Pick<Session, "status" | "pendingPermission" | "pendingForm">,
): boolean {
if (session.pendingPermission || session.pendingQuestion || session.pendingForm) {
if (session.pendingPermission || session.pendingForm) {
return false
}

View file

@ -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
}

View file

@ -74,7 +74,6 @@ export interface Session extends Omit<SDKSession, "parentID" | "model"> {
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