mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-20 13:53:35 +00:00
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:
commit
0d3bdd1a01
65 changed files with 509 additions and 2269 deletions
2
.github/workflows/pr-build.yml
vendored
2
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
200
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -60,6 +60,10 @@ export function createForegroundRefreshController(
|
|||
}
|
||||
|
||||
return {
|
||||
invalidate() {
|
||||
dirtyGeneration += 1
|
||||
void runRefresh()
|
||||
},
|
||||
handle(status: WorkspaceEventTransportStatus) {
|
||||
if (status === "disconnected") {
|
||||
connected = false
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
39
packages/ui/src/stores/opencode-data.test.ts
Normal file
39
packages/ui/src/stores/opencode-data.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
61
packages/ui/src/stores/opencode-data.ts
Normal file
61
packages/ui/src/stores/opencode-data.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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"])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue