mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77f7d5366c | |||
| ca1064bf80 | |||
| bb77765056 | |||
| 0aab2ed553 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
|
||||
@@ -2,7 +2,7 @@
|
||||
description: "Bump AI sdk dependencies minor / patch versions only"
|
||||
---
|
||||
|
||||
Please read @package.json and @packages/core/package.json.
|
||||
Please read @package.json and @packages/opencode/package.json.
|
||||
|
||||
Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes).
|
||||
|
||||
|
||||
@@ -6,7 +6,15 @@ subtask: true
|
||||
|
||||
commit and push
|
||||
|
||||
Use `type(scope): summary` with one of these types: `feat`, `fix`, `docs`, `chore`, `refactor`, or `test`. The scope is optional.
|
||||
make sure it includes a prefix like
|
||||
docs:
|
||||
tui:
|
||||
core:
|
||||
ci:
|
||||
ignore:
|
||||
wip:
|
||||
|
||||
For anything in the packages/web use the docs: prefix.
|
||||
|
||||
prefer to explain WHY something was done from an end user perspective instead of
|
||||
WHAT was done.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description: Remove AI code slop
|
||||
---
|
||||
|
||||
Check the diff against `origin/v2`, and remove all AI generated slop introduced in this branch.
|
||||
Check the diff against dev, and remove all AI generated slop introduced in this branch.
|
||||
|
||||
This includes:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: effect
|
||||
description: Work with Effect v4 TypeScript code in this repo
|
||||
description: Work with Effect v4 / effect-smol TypeScript code in this repo
|
||||
---
|
||||
|
||||
# Effect
|
||||
@@ -9,10 +9,10 @@ This codebase uses Effect for typed, composable TypeScript services, schemas, an
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
Use the current Effect v4 source, not memory or older Effect v2/v3 examples.
|
||||
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
|
||||
|
||||
1. If `.opencode/references/effect` is missing, clone `https://github.com/Effect-TS/effect` there. Do this in the project, not in the skill folder.
|
||||
2. Search `.opencode/references/effect` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
|
||||
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
|
||||
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
|
||||
3. Also inspect existing repo code for local house style before introducing new patterns.
|
||||
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
|
||||
|
||||
@@ -27,12 +27,12 @@ Use the current Effect v4 source, not memory or older Effect v2/v3 examples.
|
||||
- Keep layer composition explicit. Avoid broad hidden provisioning that makes missing dependencies hard to see.
|
||||
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
|
||||
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
|
||||
- Do not answer from memory. Verify against `.opencode/references/effect` or nearby code first.
|
||||
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
- Use `testEffect(...)` from `packages/core/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
|
||||
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
|
||||
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
|
||||
- Run tests from package directories such as `packages/core`; never run package tests from the repo root.
|
||||
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
|
||||
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
|
||||
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
@@ -166,23 +166,23 @@ const table = sqliteTable("session", {
|
||||
|
||||
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (for example, `packages/core`), never `tsc` directly.
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
- `session.instructions.updated` stores changed source keys and content hashes and may freeze rendered chronological update text. Blob values live once in `instruction_blob`; the projected `instruction_state` row is the normal boundary-processing source of current and initial values. Request assembly renders the epoch baseline from stored values, while later frozen updates enter history as durable System messages. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Forks adopt the parent's newest instruction values even when copied message history ends at an earlier boundary. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
|
||||
+223
-63
@@ -1,112 +1,272 @@
|
||||
# Contributing to OpenCode
|
||||
|
||||
The changes most likely to be accepted are:
|
||||
We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged:
|
||||
|
||||
- Bug fixes
|
||||
- Additional LSPs and formatters
|
||||
- LLM performance improvements
|
||||
- Environment-specific fixes
|
||||
- Additional LSPs / Formatters
|
||||
- Improvements to LLM performance
|
||||
- Support for new providers
|
||||
- Fixes for environment-specific quirks
|
||||
- Missing standard behavior
|
||||
- Documentation improvements
|
||||
|
||||
UI and core product features require design review before implementation. If you are unsure whether a change fits, ask a maintainer or choose an issue labeled [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted), [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22), [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug), or [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22).
|
||||
However, any UI or core product feature must go through a design review with the core team before implementation.
|
||||
|
||||
Want to take on an issue? Leave a comment and a maintainer may assign it unless it is already being worked on.
|
||||
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels:
|
||||
|
||||
- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
|
||||
- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
|
||||
- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
|
||||
- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
|
||||
|
||||
> [!NOTE]
|
||||
> PRs that ignore these guardrails will likely be closed.
|
||||
|
||||
## Adding Providers
|
||||
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
|
||||
|
||||
New providers should rarely require OpenCode changes. Add the provider to [models.dev](https://github.com/anomalyco/models.dev) first.
|
||||
## Adding New Providers
|
||||
|
||||
## Development
|
||||
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
|
||||
https://github.com/anomalyco/models.dev
|
||||
|
||||
OpenCode requires Bun 1.3 or newer. From the repository root:
|
||||
## Developing OpenCode
|
||||
|
||||
- Requirements: Bun 1.3+
|
||||
- Install dependencies and start the dev server from the repo root:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Running against a different directory
|
||||
|
||||
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun dev [directory]
|
||||
bun dev <directory>
|
||||
```
|
||||
|
||||
`bun dev` runs the V2 CLI and TUI. Pass a directory to open another project, or `.` to open this repository.
|
||||
|
||||
To test a development TUI against your installed OpenCode V2 background service and live sessions:
|
||||
To run OpenCode in the root of the opencode repo itself:
|
||||
|
||||
```bash
|
||||
bun run dev:live [directory]
|
||||
bun dev .
|
||||
```
|
||||
|
||||
For web development, run the backend and app in separate terminals. Other interfaces have root scripts:
|
||||
### Building a "localcode"
|
||||
|
||||
To compile a standalone executable:
|
||||
|
||||
```bash
|
||||
bun dev serve --port 4096
|
||||
bun run dev:web
|
||||
bun run dev:desktop
|
||||
bun run dev:www
|
||||
./packages/opencode/script/build.ts --single
|
||||
```
|
||||
|
||||
### Packages
|
||||
|
||||
- `packages/schema`: shared wire and storage contracts
|
||||
- `packages/core`: domain behavior and persistence
|
||||
- `packages/protocol`: public API definitions
|
||||
- `packages/server`: HTTP server and runtime composition
|
||||
- `packages/client`: generated TypeScript clients
|
||||
- `packages/cli`: command-line entrypoint and service lifecycle
|
||||
- `packages/tui`: terminal interface
|
||||
- `packages/app`: shared web interface
|
||||
- `packages/desktop`: Electron desktop application
|
||||
- `packages/plugin`: plugin API
|
||||
|
||||
### Verification
|
||||
|
||||
Run typechecks, and tests where defined, from the affected package rather than the repository root:
|
||||
Then run it with:
|
||||
|
||||
```bash
|
||||
cd packages/core
|
||||
bun run test
|
||||
bun typecheck
|
||||
./packages/opencode/dist/opencode-<platform>/bin/opencode
|
||||
```
|
||||
|
||||
Follow package-specific instructions in nearby `AGENTS.md` files. After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`; never edit generated client files directly.
|
||||
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
|
||||
|
||||
Follow the repository [style guide](./AGENTS.md).
|
||||
- Core pieces:
|
||||
- `packages/opencode`: OpenCode core business logic & server.
|
||||
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
|
||||
- `packages/app`: The shared web UI components, written in SolidJS
|
||||
- `packages/desktop`: The native desktop app, built with Electron (wraps `packages/app`)
|
||||
- `packages/plugin`: Source for `@opencode-ai/plugin`
|
||||
|
||||
## Pull Requests
|
||||
### Understanding bun dev vs opencode
|
||||
|
||||
### Link Issues When Required
|
||||
During development, `bun dev` is the local equivalent of the built `opencode` command. Both run the same CLI interface:
|
||||
|
||||
Bug fixes, chores, and tests must reference an existing issue. Documentation, refactor, and feature PRs are exempt from the automated linked-issue check. When required, use `Fixes #123` or `Closes #123` in the PR description.
|
||||
```bash
|
||||
# Development (from project root)
|
||||
bun dev --help # Show all available commands
|
||||
bun dev serve # Start headless API server
|
||||
bun dev web # Start server + open web interface
|
||||
bun dev <directory> # Start TUI in specific directory
|
||||
|
||||
Before implementing new functionality, open a feature request describing the problem, why it belongs in OpenCode, and your proposed approach if you have one. Wait for design approval before opening the implementation PR.
|
||||
# Production
|
||||
opencode --help # Show all available commands
|
||||
opencode serve # Start headless API server
|
||||
opencode web # Start server + open web interface
|
||||
opencode <directory> # Start TUI in specific directory
|
||||
```
|
||||
|
||||
Base branches on `v2`, not `dev`, and complete the provided pull request template.
|
||||
### Running the API Server
|
||||
|
||||
### Keep It Focused
|
||||
To start the OpenCode headless API server:
|
||||
|
||||
- Keep PRs small and focused.
|
||||
- Explain the problem and why the change fixes it.
|
||||
- Check whether the functionality already exists.
|
||||
- For UI changes, include before-and-after screenshots or video.
|
||||
- For logic changes, explain what you tested and how a reviewer can verify it.
|
||||
```bash
|
||||
bun dev serve
|
||||
```
|
||||
|
||||
### Keep It Brief
|
||||
This starts the headless server on port 4096 by default. You can specify a different port:
|
||||
|
||||
Long, AI-generated PR descriptions and issues may be ignored. Write a short explanation in your own words. If the change cannot be explained briefly, the PR may be too large.
|
||||
```bash
|
||||
bun dev serve --port 8080
|
||||
```
|
||||
|
||||
### Use Conventional Titles
|
||||
### Running the Web App
|
||||
|
||||
Use `type(scope): summary`. Supported types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. The scope is optional.
|
||||
To test UI changes during development:
|
||||
|
||||
1. **First, start the OpenCode server** (see [Running the API Server](#running-the-api-server) section above)
|
||||
2. **Then run the web app:**
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/app dev
|
||||
```
|
||||
|
||||
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here, but the server must be running for full functionality.
|
||||
|
||||
### Running the Desktop App
|
||||
|
||||
The desktop app is an Electron application that wraps the web UI.
|
||||
|
||||
To run the desktop app in development:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop dev
|
||||
```
|
||||
|
||||
To create a production build and package the app:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop build
|
||||
bun run --cwd packages/desktop package
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
|
||||
|
||||
Please try to follow the [style guide](./AGENTS.md)
|
||||
|
||||
### Setting up a Debugger
|
||||
|
||||
Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.
|
||||
|
||||
The most reliable way to debug OpenCode is to run it manually in a terminal via `bun run --inspect=<url> dev ...` and attach
|
||||
your debugger via that URL. Other methods can result in breakpoints being mapped incorrectly, at least in VSCode (YMMV).
|
||||
|
||||
Caveats:
|
||||
|
||||
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
|
||||
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
|
||||
- If `spawn` does not work for you, you can debug the server separately:
|
||||
- Debug server: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts serve --port 4096`,
|
||||
then attach TUI with `opencode attach http://localhost:4096`
|
||||
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode --conditions=browser ./src/index.ts`
|
||||
|
||||
Other tips and tricks:
|
||||
|
||||
- You might want to use `--inspect-wait` or `--inspect-brk` instead of `--inspect`, depending on your workflow
|
||||
- Specifying `--inspect=ws://localhost:6499/` on every invocation can be tiresome, you may want to `export BUN_OPTIONS=--inspect=ws://localhost:6499/` instead
|
||||
|
||||
#### VSCode Setup
|
||||
|
||||
If you use VSCode, you can use our example configurations [.vscode/settings.example.json](.vscode/settings.example.json) and [.vscode/launch.example.json](.vscode/launch.example.json).
|
||||
|
||||
Some debug methods that can be problematic:
|
||||
|
||||
- Debug configurations with `"request": "launch"` can have breakpoints incorrectly mapped and thus unusable
|
||||
- The same problem arises when running OpenCode in the VSCode `JavaScript Debug Terminal`
|
||||
|
||||
With that said, you may want to try these methods, as they might work for you.
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
### Issue First Policy
|
||||
|
||||
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
|
||||
|
||||
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
|
||||
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
|
||||
|
||||
### General Requirements
|
||||
|
||||
- Keep pull requests small and focused
|
||||
- Explain the issue and why your change fixes it
|
||||
- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
|
||||
|
||||
### UI Changes
|
||||
|
||||
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
|
||||
|
||||
### Logic Changes
|
||||
|
||||
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
|
||||
|
||||
- What did you test?
|
||||
- How can a reviewer reproduce/confirm the fix?
|
||||
|
||||
### No AI-Generated Walls of Text
|
||||
|
||||
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
|
||||
|
||||
- Write short, focused descriptions
|
||||
- Explain what changed and why in your own words
|
||||
- If you can't explain it briefly, your PR might be too large
|
||||
|
||||
### PR Titles
|
||||
|
||||
PR titles should follow conventional commit standards:
|
||||
|
||||
- `feat:` new feature or functionality
|
||||
- `fix:` bug fix
|
||||
- `docs:` documentation or README changes
|
||||
- `chore:` maintenance tasks, dependency updates, etc.
|
||||
- `refactor:` code refactoring without changing behavior
|
||||
- `test:` adding or updating tests
|
||||
|
||||
You can optionally include a scope to indicate which package is affected:
|
||||
|
||||
- `feat(app):` feature in the app package
|
||||
- `fix(desktop):` bug fix in the desktop package
|
||||
- `chore(opencode):` maintenance in the opencode package
|
||||
|
||||
Examples:
|
||||
|
||||
- `docs: update contributing guide`
|
||||
- `fix(tui): restore scroll position`
|
||||
- `feat(app): add workspace search`
|
||||
- `docs: update contributing guidelines`
|
||||
- `fix: resolve crash on startup`
|
||||
- `feat: add dark mode support`
|
||||
- `feat(app): add dark mode support`
|
||||
- `fix(desktop): resolve crash on startup`
|
||||
- `chore: bump dependency versions`
|
||||
|
||||
## Issues
|
||||
### Style Preferences
|
||||
|
||||
Bug reports and feature requests must use their issue templates. Blank issues are not allowed; ask support and how-to questions in the [Discord community](https://discord.gg/opencode).
|
||||
These are not strictly enforced, they are just general guidelines:
|
||||
|
||||
Automated checks flag missing templates, placeholder text, AI-generated walls of text, and missing meaningful content. You have two hours to correct a flagged issue before it closes automatically. Ask a maintainer if an issue was flagged incorrectly.
|
||||
- **Functions:** Keep logic within a single function unless breaking it out adds clear reuse or composition benefits.
|
||||
- **Destructuring:** Do not do unnecessary destructuring of variables.
|
||||
- **Control flow:** Avoid `else` statements.
|
||||
- **Error handling:** Prefer `.catch(...)` instead of `try`/`catch` when possible.
|
||||
- **Types:** Reach for precise types and avoid `any`.
|
||||
- **Variables:** Stick to immutable patterns and avoid `let`.
|
||||
- **Naming:** Choose concise single-word identifiers when they remain descriptive.
|
||||
- **Runtime APIs:** Use Bun helpers such as `Bun.file()` when they fit the use case.
|
||||
|
||||
## Feature Requests
|
||||
|
||||
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
||||
|
||||
## Issue Requirements
|
||||
|
||||
All issues **must** use one of our issue templates:
|
||||
|
||||
- **Bug report** — for reporting bugs (requires a description)
|
||||
- **Feature request** — for suggesting enhancements (requires verification checkbox and description)
|
||||
- **Question** — for asking questions (requires the question)
|
||||
|
||||
Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed.
|
||||
|
||||
Issues may be flagged for:
|
||||
|
||||
- Not using a template
|
||||
- Required fields left empty or filled with placeholder text
|
||||
- AI-generated walls of text
|
||||
- Missing meaningful content
|
||||
|
||||
If you believe your issue was incorrectly flagged, let a maintainer know.
|
||||
|
||||
@@ -568,7 +568,6 @@
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
# Service Lifecycle: Election, Restart, and Reconnect
|
||||
|
||||
Status: in progress
|
||||
|
||||
Incident: [#36688](https://github.com/anomalyco/opencode/issues/36688)
|
||||
|
||||
## Summary
|
||||
|
||||
The managed V2 service keeps its current update policy: the background updater
|
||||
may install a new package, but only a freshly launched TUI activates that update
|
||||
after finding an older running service. Existing TUIs never replace a service;
|
||||
they only reconnect.
|
||||
|
||||
The restart path changes in three places:
|
||||
|
||||
1. A process-held OS lock, not the HTTP port or registration file, elects
|
||||
exactly one server owner for its lifetime.
|
||||
2. The elected process binds and registers a minimal lifecycle surface before
|
||||
it initializes the application, so clients can distinguish a slow winner
|
||||
from an absent server.
|
||||
3. TUIs rediscover and reconnect indefinitely. Transport loss is never a
|
||||
terminal error by itself.
|
||||
|
||||
Several clients may spawn small contenders during a restart. This is safe and
|
||||
intentional: one contender acquires the lock and initializes, while every loser
|
||||
exits before expensive server boot. The design does not require clients to
|
||||
agree on a single initiator.
|
||||
|
||||
This proposal does not introduce a supervisor process, warm candidate server,
|
||||
protocol negotiation, idle background restart, or general execution-recovery
|
||||
framework.
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```text
|
||||
╭───────────────────╮
|
||||
│ CLI ServiceConfig │
|
||||
╰─────────┬─────────╯
|
||||
│
|
||||
▼
|
||||
╭──────────────────────╮
|
||||
│ CLI ServerConnection │
|
||||
╰───────────┬──────────╯
|
||||
╭──────────────────╰───────────────────╮
|
||||
▼ ▼
|
||||
╭──────────────────────────╮ ╭─────────────────────────╮
|
||||
│ Client Service lifecycle │ │ CLI runPromiseWith seam │
|
||||
╰─────────────┬────────────╯ ╰─────────────┬───────────╯
|
||||
╰─────╮ │
|
||||
▼ ▼
|
||||
╭────────────────────────────╮ ╭─────────────╮
|
||||
│ Background service process │ │ TUI / Solid │
|
||||
╰──────────────┬─────────────╯ ╰──────┬──────╯
|
||||
│ │
|
||||
╰────────────◀────────────────────╯
|
||||
╭───────────────────────╮
|
||||
│ Server HTTP transport │
|
||||
╰───────────┬───────────╯
|
||||
│
|
||||
▼
|
||||
╭──────────────────╮
|
||||
│ Core application │
|
||||
╰──────────────────╯
|
||||
```
|
||||
|
||||
| Owner | Responsibility |
|
||||
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `packages/client/src/effect/service.ts` | Effect-native discovery, start, and stop lifecycle operations |
|
||||
| `packages/cli/src/services/service-config.ts` | CLI registration path, installed version, and daemon command |
|
||||
| `packages/cli/src/services/server-connection.ts` | Resolve an endpoint and, only for the shared service, grouped reconnect and restart Effects |
|
||||
| `packages/cli/src/server-process.ts` | Daemon election, registration, and server process boot |
|
||||
| `packages/server/src/process.ts` | HTTP lifecycle shell and application transport |
|
||||
| `packages/core` | Application behavior behind the transport |
|
||||
| CLI default handler | Convert lifecycle Effects with the outer `FileSystem` context and pass grouped Promise capabilities |
|
||||
| `packages/tui` Solid client context | Own event-stream reconnect, endpoint replacement, status, and user-triggered restart UI |
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Area | State |
|
||||
| ------------------------- | --------------------------------------------------------------------- |
|
||||
| Lifetime ownership | Implemented on this branch with a scoped OS lock |
|
||||
| Contender behavior | Implemented; losers exit before the server module is imported |
|
||||
| Registration repair | Implemented; the owner reasserts deleted or corrupt discovery |
|
||||
| Channel isolation | Implemented with no-clobber migration for legacy preview discovery |
|
||||
| Client startup waiting | Implemented; slow winners are not killed and waiting is indefinite |
|
||||
| Lifecycle shell | Implemented; the owner binds and registers before application boot |
|
||||
| Failed-state latching | Implemented; deterministic boot failure stays bound and actionable |
|
||||
| Recovery diagnostics | Implemented; the TUI shows status instead of transport internals |
|
||||
| Cross-platform validation | macOS runtime verified; Linux and Windows run in the unit-test matrix |
|
||||
|
||||
## Context
|
||||
|
||||
The V2 CLI runs a shared managed service that owns Sessions, location graphs,
|
||||
plugins, permissions, and tool execution. The service updater can replace the
|
||||
installed package while the current process continues running the old image.
|
||||
A later TUI launch then detects the version mismatch and replaces the service.
|
||||
|
||||
Incident #36688 showed four failures in that replacement path:
|
||||
|
||||
- Multiple TUIs spawned heavyweight server contenders.
|
||||
- A winner remained unobservable while it cold-booted, so another wave treated
|
||||
it as absent and displaced it.
|
||||
- A fresh TUI exhausted its reconnect budget and crashed with an unhandled
|
||||
transport defect.
|
||||
- A losing contender remained alive and consumed about 1 GB of RSS.
|
||||
|
||||
The `origin/v2` baseline serializes service startup with `EffectFlock`. A
|
||||
contender acquires a three-second heartbeat lease, checks whether another
|
||||
service became discoverable, and only the winner crosses the application-boot
|
||||
boundary. This already prevents simultaneous heavy boots and makes startup
|
||||
losers exit.
|
||||
|
||||
The lease is released immediately after registration, however, so it is not
|
||||
lifetime ownership. Registration then reverts to last-writer-wins authority: a
|
||||
deleted or corrupt registration can admit a second boot, a displaced server
|
||||
terminates itself through its 10-second registration self-check, and a stalled
|
||||
lease holder can be displaced after the three-second service staleness timeout.
|
||||
|
||||
`Flock` and `EffectFlock` live in `packages/core/src/util` and are also used for
|
||||
config writes, MCP auth, npm installs, and repository caching. Despite the
|
||||
name, the primitive is an atomic-mkdir lease with heartbeat and staleness
|
||||
takeover, not an OS-held lock. It remains appropriate for bounded critical
|
||||
sections, including today's startup fence, but is not lifetime service
|
||||
ownership.
|
||||
|
||||
The current implementation also mixes three different concepts:
|
||||
|
||||
- **Ownership:** which process is allowed to be the managed server.
|
||||
- **Discovery:** where clients can reach that process.
|
||||
- **Lifecycle:** whether that process is starting, ready, stopping, or failed.
|
||||
|
||||
This design gives each concept one authority.
|
||||
|
||||
```definitions
|
||||
[
|
||||
{
|
||||
"term": "Owner",
|
||||
"definition": "The one process holding the process-held OS service lock."
|
||||
},
|
||||
{
|
||||
"term": "Contender",
|
||||
"definition": "A small serve process attempting to acquire the service lock. It must not initialize the application before winning."
|
||||
},
|
||||
{
|
||||
"term": "Registration",
|
||||
"definition": "An atomic discovery record containing the elected owner's identity and endpoint. Registration never grants ownership."
|
||||
},
|
||||
{
|
||||
"term": "Lifecycle shell",
|
||||
"definition": "The minimal HTTP surface bound by the elected process before application initialization. It serves health and retryable startup responses."
|
||||
},
|
||||
{
|
||||
"term": "Application",
|
||||
"definition": "The full server routes and global or location-scoped modules used for normal OpenCode work."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Goals
|
||||
|
||||
- At most one process initializes and serves the managed application.
|
||||
- Losing contenders exit before database, route, plugin, MCP, or location boot.
|
||||
- A slow winner becomes observable before expensive initialization.
|
||||
- Existing and freshly launched TUIs survive retryable service unavailability.
|
||||
- Reconnect follows service state instead of displaying retry counts or raw
|
||||
transport failures.
|
||||
- Version-mismatch replacement remains triggered by a fresh TUI launch.
|
||||
- A stale or malformed registration cannot create a second owner.
|
||||
- An unresponsive owner is never killed automatically by an arbitrary TUI.
|
||||
- Every spawned contender has a bounded path to ownership or exit.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Restarting automatically when a background update finds an idle window.
|
||||
- Running old and candidate application servers concurrently.
|
||||
- Adding a permanent steward, proxy, or supervisor process.
|
||||
- Zero-downtime worker handoff or automatic rollback.
|
||||
- Application protocol negotiation or automatic TUI self-restart.
|
||||
- General hard-crash recovery for active Sessions.
|
||||
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
|
||||
permissions, questions, or background jobs.
|
||||
- Automatically killing a frozen owner.
|
||||
- Bounding concurrent location cold boots after clients reconnect.
|
||||
- Multi-machine or clustered service placement.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. **The service lock is ownership.** Exactly one process may hold the OS lock
|
||||
for one installation channel and service profile.
|
||||
2. **Ownership precedes boot.** A contender performs no expensive application
|
||||
initialization before it acquires the lock.
|
||||
3. **Ownership lasts for the process lifetime.** The owner holds an open lock
|
||||
handle until the managed server exits. The OS releases it on process death
|
||||
without a cleanup callback.
|
||||
4. **The port is transport, not election.** The owner may select a dynamic port
|
||||
after acquiring the lock.
|
||||
5. **Registration is discovery, not election.** Deleting, corrupting, or
|
||||
replacing registration does not invalidate a live owner's lock.
|
||||
6. **Only a fresh launch enforces package version.** Existing TUIs reconnect to
|
||||
the current owner without initiating version replacement.
|
||||
7. **Transport loss is retryable.** It never terminates a TUI without a separate
|
||||
diagnosed, non-retryable cause.
|
||||
8. **Clients do not kill an unresponsive owner automatically.** Destructive
|
||||
recovery requires the explicit `service restart` command.
|
||||
9. **Lifecycle does not promise execution semantics.** Graceful replacement
|
||||
invokes Session suspension and resumption hooks, but tool-level continuity
|
||||
belongs to a separate design.
|
||||
|
||||
## System Model
|
||||
|
||||
```text
|
||||
╭───────────────────────╮ ╭──────────────────────────────╮
|
||||
│ Fresh or existing TUI │ │ Process-held OS service lock │
|
||||
╰───────────┬───────────╯ ╰───────────────┬──────────────╯
|
||||
╰─────┬ normal requests observe ───────────────────────╮ │
|
||||
│ discover │ ├──╯ authorizes one owner
|
||||
▼ │ ▼
|
||||
╭───────────────────╮ │ ╭─────────────────╮
|
||||
│ Registration file │ │ │ Lifecycle shell │
|
||||
╰───────────────────╯ │ ╰────────┬────────╯
|
||||
│ │
|
||||
├────────────────────────╯
|
||||
▼
|
||||
╭──────────────────────╮
|
||||
│ OpenCode application │
|
||||
╰──────────────────────╯
|
||||
```
|
||||
|
||||
The lifecycle shell and application run in the same process. The distinction is
|
||||
initialization order and responsibility, not process topology.
|
||||
|
||||
## Service Status
|
||||
|
||||
The server reports one small status value:
|
||||
|
||||
```typescript
|
||||
type ServiceStatus =
|
||||
| {
|
||||
type: "starting"
|
||||
}
|
||||
| {
|
||||
type: "ready"
|
||||
}
|
||||
| {
|
||||
type: "stopping"
|
||||
targetVersion?: string
|
||||
}
|
||||
| {
|
||||
type: "failed"
|
||||
message: string
|
||||
action: string
|
||||
}
|
||||
```
|
||||
|
||||
The client adds only the discovery states needed by callers:
|
||||
|
||||
```typescript
|
||||
type Status = { type: "missing" } | { type: "unreachable" } | { type: "unresponsive" } | ServiceStatus
|
||||
```
|
||||
|
||||
The health response retains the existing fields for old clients and adds the
|
||||
status discriminant:
|
||||
|
||||
```typescript
|
||||
type ServiceHealth = {
|
||||
healthy: true
|
||||
version: string
|
||||
pid: number
|
||||
instanceID: string
|
||||
status: ServiceStatus
|
||||
}
|
||||
```
|
||||
|
||||
`healthy: true` means the registered lifecycle shell is responding and its
|
||||
identity matches registration. New clients use `status.type === "ready"` as
|
||||
the application-readiness signal.
|
||||
|
||||
During `starting` or `stopping`, application requests are not held in memory.
|
||||
They receive an immediate retryable response:
|
||||
|
||||
```http
|
||||
HTTP/1.1 503 Service Unavailable
|
||||
Retry-After: 1
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
`stopping` uses `service_stopping`. A failed application boot uses
|
||||
`service_failed` and includes a safe diagnostic message.
|
||||
|
||||
A failed owner remains bound and keeps holding the service lock. Exiting on
|
||||
failure would let every waiting client's `ensureRunning` loop elect a new
|
||||
contender that repeats the same heavy failing boot, so staying bound turns a
|
||||
deterministic boot failure into one observable `failed` state instead of a
|
||||
client-driven respawn loop. Recovery still works: a fresh launch observes the
|
||||
failed instance through the stop path, and explicit `service restart` replaces
|
||||
it.
|
||||
|
||||
## Registration Contract
|
||||
|
||||
Registration contains only discovery identity:
|
||||
|
||||
```typescript
|
||||
type ServiceRegistration = {
|
||||
schema: 1
|
||||
instanceID: string
|
||||
version: string
|
||||
url: string
|
||||
pid: number
|
||||
}
|
||||
```
|
||||
|
||||
Authentication continues to use the existing private service credential
|
||||
storage. The registration schema does not change that policy.
|
||||
|
||||
The owner writes registration only after the lifecycle shell has bound:
|
||||
|
||||
1. Bind the lifecycle shell.
|
||||
2. Write a temporary registration file with mode `0600`.
|
||||
3. Atomically rename it over the old registration.
|
||||
4. Serve lifecycle health as `starting`.
|
||||
|
||||
On shutdown, the owner removes registration only if the current file still has
|
||||
its `instanceID`. An old finalizer can never remove a successor's registration.
|
||||
|
||||
While running, the owner periodically asserts its registration. Because the
|
||||
lock guarantees exactly one live owner, any registration that does not name the
|
||||
owner is stale or corrupt, and the owner rewrites it. A deleted or clobbered
|
||||
registration therefore heals within one assertion interval instead of leaving
|
||||
clients waiting on absent discovery. This inverts today's self-check loop,
|
||||
which terminates the displaced process instead of repairing discovery.
|
||||
|
||||
Legacy registration shapes are decoded by a compatibility adapter. The new
|
||||
domain type does not make fields optional to represent old formats.
|
||||
|
||||
## Election
|
||||
|
||||
This design promotes today's startup fence into lifetime ownership.
|
||||
Last-writer-wins registration is replaced by a process-held OS lock that is
|
||||
acquired before any expensive boot work and held for the entire service
|
||||
lifetime.
|
||||
|
||||
A heartbeat-and-staleness lease, including the existing `Flock` utility, is not
|
||||
sufficient for service ownership: the service configures a three-second stale
|
||||
timeout, after which its lock can be broken and recreated. An event-loop stall,
|
||||
a suspended machine, or a debugger pause can therefore make a live owner appear
|
||||
stale and allow a contender to displace it. Service ownership requires a
|
||||
process-held OS lock: `flock` on Unix and an exclusively bound named pipe on
|
||||
Windows. It cannot be broken because a heartbeat exceeded a timeout. Process
|
||||
death releases the lock through the OS.
|
||||
|
||||
Neither Bun nor Node exposes `flock` directly, the existing `Flock` utility is
|
||||
an mkdir-plus-heartbeat lease rather than an OS-held lock, and the common
|
||||
lockfile packages are staleness-based leases as well. The platform layer uses
|
||||
`bun:ffi` to call `flock` on POSIX and Node's named-pipe server support on
|
||||
Windows, where Bun FFI is not available on every shipped architecture. It lives
|
||||
alongside the existing utility in `packages/core/src/util`. This primitive is
|
||||
the foundation of the design, so the delivery sequence spikes it first.
|
||||
|
||||
```text
|
||||
Contender Lock Lifecycle Application
|
||||
│ │ │ │
|
||||
├─ try acquire ───▶ │ │
|
||||
│ │ │ │
|
||||
╭─ alt: lock held ────────────────────────────────────────────────╮
|
||||
│ │ │ │ │ │
|
||||
│ ◀─ busy ──────────┤ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─────────╮ │ │ │ │
|
||||
│ │ exit │ │ │ │ │
|
||||
│ ◀─────────╯ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
├─ else: lock acquired ───────────────────────────────────────────┤
|
||||
│ │ │ │ │ │
|
||||
│ ◀─ owner ─────────┤ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─ bind, register, starting ────────▶ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─ initialize ──────────────────────────────────────────────▶ │
|
||||
│ │ │ │ │ │
|
||||
│╭─ alt: boot succeeds ──────────────────────────────────────────╮│
|
||||
││ │ │ │ │ ││
|
||||
││ │ │ ◀─ ready ───────────────┤ ││
|
||||
││ │ │ │ │ ││
|
||||
│├─ else: boot fails ────────────────────────────────────────────┤│
|
||||
││ │ │ │ │ ││
|
||||
││ │ │ ◀─ failed, stay bound ──┤ ││
|
||||
││ │ │ │ │ ││
|
||||
│╰───────────────────────────────────────────────────────────────╯│
|
||||
│ │ │ │ │ │
|
||||
╰─────────────────────────────────────────────────────────────────╯
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
Lock acquisition by a contender is nonblocking or tightly bounded. A loser
|
||||
must exit before constructing application routes or importing startup-heavy
|
||||
modules.
|
||||
|
||||
Several clients may spawn contenders concurrently. The design guarantees one
|
||||
heavy winner, not one process spawn. If the winner crashes during startup, the
|
||||
OS releases the lock and a later client retry starts another election.
|
||||
|
||||
The lock is scoped by installation channel and service profile. Local, preview,
|
||||
and stable installations cannot displace one another.
|
||||
|
||||
## Update Activation
|
||||
|
||||
Background update behavior remains unchanged:
|
||||
|
||||
1. The running service checks for an update.
|
||||
2. The updater installs the package in the background.
|
||||
3. The running process continues using its existing process image.
|
||||
4. No idle check or automatic restart occurs.
|
||||
|
||||
A fresh TUI launch activates the installed update:
|
||||
|
||||
1. Read registration and authenticate the responding service.
|
||||
2. If its package version matches the fresh client, attach normally.
|
||||
3. If the version differs, request graceful stop of that exact registered
|
||||
instance using the existing authenticated stop path.
|
||||
4. Re-check instance identity before every signal or escalation in that path.
|
||||
5. Wait for the old process to exit and release the service lock.
|
||||
6. Call `ensureRunning` until a compatible service becomes ready.
|
||||
|
||||
Concurrent fresh launchers may all observe the same old instance. Stopping that
|
||||
exact instance must be idempotent. Once registration names a different instance,
|
||||
a stale launcher stops signaling and returns to discovery.
|
||||
|
||||
No durable restart-transition record is introduced. The initiating fresh TUI
|
||||
already knows the source and target versions and can display its update
|
||||
preflight. Existing TUIs may display `Updating...` if they observed `stopping`;
|
||||
otherwise `Waiting for background service...` is the honest fallback.
|
||||
|
||||
## Fresh Launch Versus Reconnect
|
||||
|
||||
Fresh launch and reconnect deliberately have different version policies:
|
||||
|
||||
```typescript
|
||||
type ManagedConnection =
|
||||
| {
|
||||
type: "launch"
|
||||
requiredVersion: string
|
||||
}
|
||||
| {
|
||||
type: "reconnect"
|
||||
}
|
||||
```
|
||||
|
||||
- `launch` requires the installed package version and may activate replacement.
|
||||
- `reconnect` accepts the current owner and never activates replacement.
|
||||
|
||||
This preserves today's permissive reconnect behavior. Explicit application
|
||||
protocol negotiation and automatic TUI re-exec remain follow-ups.
|
||||
|
||||
## Client Reconnect
|
||||
|
||||
Fresh and existing TUIs use the same status loop after startup:
|
||||
|
||||
1. Read registration on every attempt. Do not retry a stale URL indefinitely.
|
||||
2. If registration is absent, call `ensureRunning` and continue waiting.
|
||||
3. If registration is unreachable, call `ensureRunning`. A live owner prevents
|
||||
contenders from acquiring the lock; a dead owner does not.
|
||||
4. If status is `starting` or `stopping`, wait.
|
||||
5. If status is `failed`, show its actionable message.
|
||||
6. If status is `ready`, rebuild HTTP and event-stream clients for the new
|
||||
endpoint and perform authoritative state reconciliation.
|
||||
|
||||
Retry cadence is internal policy. Retry counts are telemetry, not user-facing
|
||||
state. The TUI waits until the service is ready or the user exits.
|
||||
|
||||
Transport failures are handled at the TUI run boundary. A raw client transport
|
||||
error or Effect defect must not escape to the terminal. Hard exit is reserved
|
||||
for diagnosed causes such as invalid local configuration, failed authentication,
|
||||
or a foreign process occupying an explicitly configured port.
|
||||
|
||||
The UI derives text from status:
|
||||
|
||||
| Status | User-facing state |
|
||||
| ------------------------ | ----------------------------------- |
|
||||
| No registration | `Starting background service...` |
|
||||
| Registration unreachable | `Waiting for background service...` |
|
||||
| `starting` | `Starting OpenCode vX...` |
|
||||
| `stopping` | `Updating to vX...` |
|
||||
| `failed` | Actionable failure message |
|
||||
| `ready` | Normal TUI |
|
||||
|
||||
## Graceful Session Continuity
|
||||
|
||||
Version-mismatch replacement uses the existing graceful Session suspension and
|
||||
resumption hooks:
|
||||
|
||||
1. The old server snapshots active Session IDs during graceful teardown.
|
||||
2. The successor schedules those Sessions for continuation.
|
||||
3. The runner reloads durable Session history before continuing.
|
||||
|
||||
This lifecycle design does not define what an interrupted physical provider
|
||||
attempt or tool invocation means. It does not promise that external side effects
|
||||
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
|
||||
recover process-local background work.
|
||||
|
||||
Those concerns require a separate execution-continuity design covering tools,
|
||||
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
|
||||
recovery.
|
||||
|
||||
## Unresponsive Owner
|
||||
|
||||
An unreachable registration does not prove that the owner is dead. A contender
|
||||
attempts the service lock:
|
||||
|
||||
- If the lock is free, the contender starts a replacement.
|
||||
- If the lock is held, the contender exits and the client keeps waiting.
|
||||
|
||||
After a bounded diagnostic threshold, the client may show:
|
||||
|
||||
```text
|
||||
The background service owns the service lock but is not responding.
|
||||
Run `opencode service restart` to recover it.
|
||||
```
|
||||
|
||||
Only explicit `service restart` may perform destructive recovery. It verifies
|
||||
the complete registration and process instance before signaling, waits for
|
||||
graceful exit, re-checks identity before escalation, and refuses to kill a
|
||||
process it cannot positively identify.
|
||||
|
||||
Automatic frozen-owner recovery is deferred.
|
||||
|
||||
## Failure Walkthroughs
|
||||
|
||||
### Update with open TUIs
|
||||
|
||||
1. The old service installs vNext but keeps running.
|
||||
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
|
||||
3. The old service reports `stopping`, suspends active Sessions, and exits.
|
||||
4. Open TUIs enter their indefinite status loops.
|
||||
5. One or more clients spawn contenders.
|
||||
6. One contender acquires the service lock. Losers exit before heavy boot.
|
||||
7. The winner binds and registers the lifecycle shell as `starting`.
|
||||
8. Clients stop spawning and wait on the observable winner.
|
||||
9. The winner initializes the application and reports `ready`.
|
||||
10. TUIs rebuild clients, reconcile state, and resume.
|
||||
|
||||
### Server crashes while ready
|
||||
|
||||
1. The endpoint becomes unreachable and registration may remain stale.
|
||||
2. Clients call `ensureRunning`.
|
||||
3. Process death has released the service lock.
|
||||
4. One contender wins, replaces registration, and starts normally.
|
||||
5. Detailed active-execution recovery is outside this design.
|
||||
|
||||
### Winner crashes during startup
|
||||
|
||||
1. Clients observed `starting` and remain alive.
|
||||
2. Process death releases the service lock.
|
||||
3. A later reconnect attempt starts another election.
|
||||
4. One new contender wins; all other contenders exit.
|
||||
|
||||
### Registration is deleted while the owner is healthy
|
||||
|
||||
1. Clients may call `ensureRunning` because discovery is absent.
|
||||
2. Every contender fails to acquire the owner's lock and exits.
|
||||
3. No second application initializes.
|
||||
4. The owner's next registration assertion republishes discovery.
|
||||
|
||||
### Owner is alive but unresponsive
|
||||
|
||||
1. Health fails, but the process still holds the service lock.
|
||||
2. Contenders fail lock acquisition and exit.
|
||||
3. Clients wait and eventually show explicit recovery guidance.
|
||||
4. No TUI kills the owner automatically.
|
||||
|
||||
## TDD Verification
|
||||
|
||||
Implementation should proceed test-first with real subprocesses and real locks.
|
||||
Mocks cannot establish process death, lock release, loser cleanup, or port
|
||||
behavior.
|
||||
|
||||
### Election tests
|
||||
|
||||
| Scenario | Required result |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Ten contenders start simultaneously | Exactly one crosses the application-boot boundary |
|
||||
| Winner pauses after lock acquisition | No loser initializes or remains alive |
|
||||
| Winner event loop pauses beyond the old stale timeout | Ownership is not displaced |
|
||||
| Winner crashes before bind | Lock releases; a later attempt wins |
|
||||
| Winner crashes after bind but before registration | Lock releases; a later attempt replaces stale discovery |
|
||||
| Registration is deleted while owner runs | No second owner initializes |
|
||||
| Registration is malformed | Lock still prevents a second owner |
|
||||
| Registration names a dead PID | New contender can acquire the released lock |
|
||||
| Two installation channels start | Each elects an independent owner |
|
||||
| Explicit configured port is foreign-owned | Fail diagnostically; do not kill the foreign process |
|
||||
|
||||
The fixture records a marker immediately before application initialization. The
|
||||
tests assert that only one process writes that marker and that every loser exits
|
||||
within a bounded interval. The harness should also assert that a loser's peak
|
||||
RSS stays an order of magnitude below an application boot, since import weight
|
||||
was the observed incident cost.
|
||||
|
||||
### Lifecycle tests
|
||||
|
||||
| Scenario | Required result |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| Winner owns lock but application boot is paused | Health reports `starting` |
|
||||
| Application request arrives during startup | Immediate retryable `503` |
|
||||
| Application becomes ready | Status changes once from `starting` to `ready` |
|
||||
| Graceful replacement begins | Status reports `stopping` before disconnect |
|
||||
| Application initialization fails | Actionable `failed` status; owner stays bound and holds the lock |
|
||||
| Registration is deleted while owner runs | Owner republishes it within one assertion interval |
|
||||
| Owner exits | Registration is removed only if it still names that owner |
|
||||
|
||||
### Update tests
|
||||
|
||||
| Scenario | Required result |
|
||||
| -------------------------------------- | -------------------------------------------------------- |
|
||||
| Background update installs vNext | Running vOld service does not restart |
|
||||
| Fresh vNext launch finds vOld | Exact old instance stops; vNext eventually becomes ready |
|
||||
| Two fresh vNext launches race | One heavy successor; both clients attach |
|
||||
| Existing vOld TUI reconnects to vNext | It never requests replacement |
|
||||
| Stale launcher observes a new instance | It does not signal the new instance |
|
||||
|
||||
### Reconnect tests
|
||||
|
||||
| Scenario | Required result |
|
||||
| --------------------------------------------------- | -------------------------------------------------- |
|
||||
| Endpoint disappears and changes port | TUI rediscovers and rebuilds clients |
|
||||
| Service remains unavailable beyond old retry budget | TUI remains alive |
|
||||
| Event stream reconnects | Client performs authoritative state reconciliation |
|
||||
| Transport returns an unexpected defect | TUI formats it; no raw stack escapes |
|
||||
| Owner remains unresponsive | TUI waits and shows explicit restart guidance |
|
||||
|
||||
## Delivery Sequence
|
||||
|
||||
1. **Spike the lock primitive.** Prove a nonblocking, process-held OS lock
|
||||
under Bun on macOS, Linux, and Windows (`bun:ffi` to `flock` on POSIX and a
|
||||
named pipe on Windows), including release on hard kill and behavior across
|
||||
containers and network filesystems used in CI.
|
||||
2. **Expand the subprocess test harness.** Begin from the baseline
|
||||
two-contender test and cover ten contenders, lock release on crash, a paused
|
||||
winner, deleted or corrupt registration, and bounded loser exit before
|
||||
changing ownership.
|
||||
3. **Contain client failure.** Make transport loss nonterminal, rediscover on
|
||||
every cycle, and format unexpected failures at the TUI boundary.
|
||||
4. **Promote the startup fence to process-held ownership.** Preserve the
|
||||
existing pre-boot acquisition seam, replace its lease with the OS lock, hold
|
||||
it until process exit, and invert the registration self-check from
|
||||
self-termination to reassertion.
|
||||
5. **Bind the lifecycle shell first.** Publish registration and `starting`,
|
||||
return retryable `503` for application requests, then initialize the app.
|
||||
The health contract change is public API: regenerate clients from
|
||||
`packages/client` with `bun run generate`.
|
||||
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
|
||||
reconnect never activates replacement.
|
||||
7. **Integrate graceful replacement.** Preserve current background-install and
|
||||
fresh-launch activation behavior while invoking Session continuity hooks.
|
||||
8. **Harden explicit recovery.** Verify exact process identity during explicit
|
||||
`service restart`; never automatically kill an unresponsive owner.
|
||||
9. **Run the full multi-process suite.** Include repeated restart cycles and
|
||||
assert that no contender or child process remains afterward.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Ten concurrent restart observers produce one application initialization.
|
||||
- No losing contender survives or builds a location graph.
|
||||
- A 30-second application boot remains continuously observable as `starting`.
|
||||
- A TUI remains alive through a service outage longer than the previous retry
|
||||
budget.
|
||||
- A service endpoint change does not require restarting an existing TUI.
|
||||
- Background installation alone does not restart the service.
|
||||
- A fresh mismatched TUI eventually attaches to the installed service version.
|
||||
- Existing reconnecting TUIs never replace the current owner.
|
||||
- Registration corruption cannot produce two owners.
|
||||
- A deleted registration heals without restarting the owner or any client.
|
||||
- An unresponsive owner is not killed without an explicit recovery command.
|
||||
- Raw transport defects never escape to the terminal.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Idle background update activation with an admission fence.
|
||||
- Application protocol compatibility and automatic local TUI re-exec.
|
||||
- Durable execution recovery for provider attempts and tools.
|
||||
- Shell, sub-agent, permission, question, and background-job continuity.
|
||||
- Automatic recovery for a positively identified frozen owner.
|
||||
- Cold-boot concurrency limits and interaction-prioritized location loading.
|
||||
- A steward or socket-handoff architecture if zero-downtime replacement becomes
|
||||
a real requirement.
|
||||
a real requirement.
|
||||
@@ -0,0 +1,298 @@
|
||||
# V1 to V2 Database Migration
|
||||
|
||||
## Approach
|
||||
|
||||
- Use the `dev` branch database schema and migration registry as the V1 baseline.
|
||||
- Remove migrations that exist only on the V2 branch.
|
||||
- Generate one canonical migration from the `dev` schema to the final V2 schema.
|
||||
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
|
||||
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
|
||||
- Show committed session progress while the endpoint runs.
|
||||
|
||||
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
|
||||
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
|
||||
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
|
||||
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
|
||||
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
|
||||
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
|
||||
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
|
||||
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
|
||||
and help flows do not trigger the backfill.
|
||||
|
||||
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
|
||||
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
|
||||
the status check and spinner presentation.
|
||||
|
||||
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
|
||||
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
|
||||
for the current single elected server process.
|
||||
|
||||
## Preserve
|
||||
|
||||
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
|
||||
|
||||
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
|
||||
workspace relationships.
|
||||
|
||||
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
|
||||
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
|
||||
ID, model ID, and variant, normalizing an absent variant to `default`.
|
||||
|
||||
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
|
||||
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
|
||||
cache-write token totals with those sums.
|
||||
|
||||
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
|
||||
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
|
||||
parts, and file history.
|
||||
|
||||
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
|
||||
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
|
||||
|
||||
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
|
||||
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
|
||||
unmanaged legacy storage.
|
||||
|
||||
## Per-Session Replacement
|
||||
|
||||
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
|
||||
hold SQLite's writer lock long enough to block the running TUI.
|
||||
|
||||
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
|
||||
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
|
||||
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
|
||||
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
|
||||
remain untouched.
|
||||
|
||||
## Message Backfill
|
||||
|
||||
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
|
||||
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
|
||||
V2 session APIs, which read `session_message`.
|
||||
|
||||
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
|
||||
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
|
||||
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
|
||||
untouched in the V1 tables.
|
||||
|
||||
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
|
||||
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
|
||||
leave skipped source rows unchanged.
|
||||
|
||||
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
|
||||
avoid rewriting other persisted state that may refer to a message.
|
||||
|
||||
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
|
||||
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
|
||||
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
|
||||
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
|
||||
|
||||
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
|
||||
contiguous `session_message.seq` values starting at `0`.
|
||||
|
||||
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
|
||||
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
|
||||
payload.
|
||||
|
||||
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
|
||||
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
|
||||
stable. Omit only explicitly dropped internal concepts and undecodable messages.
|
||||
|
||||
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
|
||||
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
|
||||
part mappings must be decided explicitly before implementing the backfill.
|
||||
|
||||
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
|
||||
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
|
||||
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
|
||||
Keep all source rows unchanged in the V1 `message` and `part` tables.
|
||||
|
||||
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
|
||||
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
|
||||
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
|
||||
|
||||
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
|
||||
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
|
||||
state has no start time. Set the error to type `tool.interrupted` with message
|
||||
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
|
||||
|
||||
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
|
||||
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
|
||||
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
|
||||
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
|
||||
`[Old tool result content cleared]` as the only output and omit attachments.
|
||||
|
||||
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
|
||||
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
|
||||
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
|
||||
|
||||
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
|
||||
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
|
||||
`tokens.total` because it is derivable and V2 does not persist it.
|
||||
|
||||
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
|
||||
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
|
||||
|
||||
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
|
||||
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
|
||||
|
||||
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
|
||||
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
|
||||
`message` row.
|
||||
|
||||
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
|
||||
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
|
||||
finish values in metadata.
|
||||
|
||||
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
|
||||
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
|
||||
preserve the message, and discard V1-only retryability and raw provider details.
|
||||
|
||||
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
|
||||
useful enough to preserve. The original retry rows remain in the V1 `part` table.
|
||||
|
||||
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
|
||||
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
|
||||
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
|
||||
markers without snapshots.
|
||||
|
||||
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
|
||||
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
|
||||
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
|
||||
removed.
|
||||
|
||||
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
|
||||
a blocker for the V1 migration, which should target the current storage contract.
|
||||
|
||||
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
|
||||
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
|
||||
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
|
||||
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
|
||||
|
||||
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
|
||||
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
|
||||
|
||||
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
|
||||
metadata. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
|
||||
must not affect future V2 execution. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
|
||||
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
|
||||
format only in the V1 `message` row.
|
||||
|
||||
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
|
||||
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
|
||||
row.
|
||||
|
||||
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
|
||||
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
|
||||
Omit `agents` when there are no agent parts.
|
||||
|
||||
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
|
||||
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
|
||||
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
|
||||
|
||||
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
|
||||
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
|
||||
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
|
||||
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
|
||||
|
||||
For a non-embedded V1 file, do not create a V2 file attachment. Append
|
||||
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
|
||||
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
|
||||
the preserved V1 `part` row.
|
||||
|
||||
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
|
||||
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
|
||||
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
|
||||
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
|
||||
source user row. Entirely synthetic messages continue to reuse their original message ID.
|
||||
|
||||
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
|
||||
admitted compaction input ID and preserves references to the initiating message.
|
||||
|
||||
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
|
||||
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
|
||||
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
|
||||
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
|
||||
assistant row.
|
||||
|
||||
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
|
||||
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
|
||||
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
|
||||
|
||||
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
|
||||
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
|
||||
history. The migrated session's prior `event` rows are removed in the same transaction.
|
||||
|
||||
## Drop
|
||||
|
||||
Drop these pre-launch V2 tables without preserving or transforming their rows:
|
||||
|
||||
- `session_input`
|
||||
- `session_context_epoch`
|
||||
- `data_migration`
|
||||
|
||||
Do not transfer `session_input` rows into `session_pending`.
|
||||
|
||||
## Create Empty
|
||||
|
||||
Let the generated migration create these tables empty:
|
||||
|
||||
- `instruction_blob`
|
||||
- `instruction_entry`
|
||||
- `instruction_state`
|
||||
- `session_pending`
|
||||
- `kv`
|
||||
|
||||
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
|
||||
|
||||
## Fork Storage
|
||||
|
||||
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
|
||||
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
|
||||
|
||||
- `before`: copy messages before the identified message.
|
||||
- `through`: copy messages through the identified message.
|
||||
|
||||
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
|
||||
schema.
|
||||
|
||||
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
|
||||
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
|
||||
|
||||
## Execution
|
||||
|
||||
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
|
||||
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
|
||||
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
|
||||
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
|
||||
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
|
||||
|
||||
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
|
||||
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
|
||||
|
||||
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
|
||||
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
|
||||
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
|
||||
exists.
|
||||
|
||||
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
|
||||
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
|
||||
seed migration state specially.
|
||||
|
||||
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
|
||||
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
|
||||
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
|
||||
cursor. Mark the migration complete after the final session and return immediately on later calls.
|
||||
|
||||
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
|
||||
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
|
||||
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
|
||||
successfully committed session advances the cursor.
|
||||
|
||||
## Testing
|
||||
|
||||
Detailed migration test design is deferred until after the canonical migration is implemented.
|
||||
@@ -23,9 +23,14 @@ Per-type constructors live on the type, not as top-level re-exports. Use `Messag
|
||||
|
||||
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
|
||||
|
||||
Session integration lives in `packages/core/src/session`: `runner/llm.ts` owns orchestration, `model-request.ts` lowers Session state into `LLMRequest`, and `model-transport.ts` selects transport behavior.
|
||||
Primary in-repo integration point:
|
||||
|
||||
Keep this package independent of Session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in Core.
|
||||
- `packages/opencode/src/session/llm.ts` is the session-owned orchestration layer that decides whether a request uses AI SDK or this package's native route runtime.
|
||||
- `packages/opencode/src/session/llm/native-request.ts` is the lowering adapter from opencode's session/AI SDK-shaped data into this package's `LLMRequest` model.
|
||||
- `packages/opencode/src/session/llm/native-runtime.ts` is the execution adapter that calls raw `LLMClient.stream(request)` and bridges one provider turn of opencode tool calls through this package's typed dispatcher.
|
||||
- `packages/opencode/src/session/llm/ai-sdk.ts` keeps the default AI SDK path compatible by converting AI SDK stream parts into this package's shared `LLMEvent`s.
|
||||
|
||||
Keep this package independent of session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in `packages/opencode/src/session/llm.ts` and its local adapters.
|
||||
|
||||
### Request Flow
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
- `opencode dev web` proxies `https://app.opencode.ai`, so local UI/CSS changes will not show there.
|
||||
- For local UI changes, run the backend and app dev servers separately.
|
||||
- Backend (from the repository root): `bun dev serve --port 4096`
|
||||
- Backend (from `packages/opencode`): `bun run --conditions=browser ./src/index.ts serve --port 4096`
|
||||
- App (from `packages/app`): `bun dev -- --port 4444`
|
||||
- Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`).
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ async function writeProtocolStream(session: CDPSession, handle: string, file: st
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await session.send("IO.read", { handle })
|
||||
await (chunk.base64Encoded ? output.write(Buffer.from(chunk.data, "base64")) : output.write(chunk.data))
|
||||
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
|
||||
if (chunk.eof) break
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -125,20 +125,17 @@ export async function installTimelineStreamProbe(
|
||||
const scrollTo = Element.prototype.scrollTo
|
||||
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
|
||||
if (profileVisual) {
|
||||
function measuredScrollTo(this: Element, options?: ScrollToOptions): void
|
||||
function measuredScrollTo(this: Element, x: number, y: number): void
|
||||
function measuredScrollTo(this: Element, first?: number | ScrollToOptions, second?: number) {
|
||||
Element.prototype.scrollTo = function (...args) {
|
||||
state.scroll.calls += 1
|
||||
const top = typeof first === "object" ? first?.top : second
|
||||
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
|
||||
if (typeof top === "number") {
|
||||
const target = Math.min(top, this.scrollHeight - this.clientHeight)
|
||||
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
|
||||
}
|
||||
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
|
||||
state.scroll.lastCallFrame = state.scroll.frame
|
||||
Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
|
||||
return scrollTo.apply(this, args)
|
||||
}
|
||||
Element.prototype.scrollTo = measuredScrollTo
|
||||
Object.defineProperty(Element.prototype, "scrollTop", {
|
||||
configurable: true,
|
||||
get: scrollTop.get,
|
||||
|
||||
@@ -267,19 +267,18 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
userMessage(childID, index + 2000, 120),
|
||||
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
||||
]).flat()
|
||||
const messages: Record<string, Message[]> = {
|
||||
[sourceID]: sourceMessages,
|
||||
[targetID]: targetMessages,
|
||||
[childID]: childMessages,
|
||||
}
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function orderedParts(message: Message) {
|
||||
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
directory,
|
||||
project: {
|
||||
@@ -334,7 +333,7 @@ export const fixture = {
|
||||
sourceID,
|
||||
targetID,
|
||||
childID,
|
||||
messages,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
@@ -346,12 +345,16 @@ export const fixture = {
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
|
||||
targetPartIDs: targetMessages.flatMap((message) =>
|
||||
orderedParts(message)
|
||||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||
const messages = fixture.messages[sessionID] ?? []
|
||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
@@ -361,6 +364,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,8 +220,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
}
|
||||
if (url.pathname === "/api/project/current")
|
||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
|
||||
@@ -82,7 +82,6 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
||||
file: "src/retry.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
|
||||
const assistant = (completed: boolean, tool = false, childID?: string): SessionMessageAssistant => ({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: tool
|
||||
? [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
|
||||
time: { created: 2 },
|
||||
},
|
||||
]
|
||||
: [{ type: "text", text: "Working" }],
|
||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||
})
|
||||
const assistant = (completed: boolean, tool = false, childID?: string) =>
|
||||
({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: tool
|
||||
? [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
|
||||
time: { created: 2 },
|
||||
},
|
||||
]
|
||||
: [{ type: "text", text: "Working" }],
|
||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||
}) satisfies SessionMessageInfo
|
||||
|
||||
test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
const ownerWarnings: string[] = []
|
||||
|
||||
@@ -280,7 +280,6 @@ function summaryDiff(index: number) {
|
||||
file: `src/diff-${index}.ts`,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ test("groups singleton and separated context operations at correct boundaries",
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
|
||||
@@ -131,7 +131,6 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
|
||||
file: "src/summary.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -83,40 +83,6 @@ test("labels all web search provider variants", async ({ page }) => {
|
||||
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("labels V2 read tools from their path input", async ({ page }) => {
|
||||
const id = "prt_read_path"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
|
||||
})
|
||||
|
||||
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
|
||||
const pending = "prt_skill_id"
|
||||
const completed = "prt_skill_name"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(pending, "skill", "running", { id: "sample-skill" }),
|
||||
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"sample-skill",
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"OpenCode",
|
||||
)
|
||||
})
|
||||
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type EventPayload = {
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||
|
||||
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
@@ -57,6 +57,7 @@ test("animates todo opening without replaying it across session tabs", async ({
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
sessionStatus: { [sourceID]: { type: "busy" } },
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
|
||||
@@ -90,8 +90,7 @@ async function mockServer(page: Page) {
|
||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||
return new Promise(() => {})
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
|
||||
@@ -227,24 +227,25 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
]).flat()
|
||||
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function currentPartIDs(message: Message) {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.parts.flatMap((part) => {
|
||||
if (!renderable(part)) return []
|
||||
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
||||
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
||||
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
||||
return []
|
||||
})
|
||||
return message.parts
|
||||
.flatMap((part) => {
|
||||
if (!renderable(part)) return []
|
||||
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
||||
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
||||
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
||||
return []
|
||||
})
|
||||
.sort()
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
@@ -291,7 +292,7 @@ export const fixture = {
|
||||
],
|
||||
sourceID,
|
||||
targetID,
|
||||
messages,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
@@ -305,7 +306,7 @@ export const fixture = {
|
||||
}
|
||||
|
||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||
const messages = fixture.messages[sessionID] ?? []
|
||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
@@ -315,6 +316,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ test.describe("smoke: session timeline", () => {
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
await page.addInitScript(
|
||||
@@ -188,11 +188,7 @@ test.describe("smoke: session timeline", () => {
|
||||
const bottom = root
|
||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||
?.getBoundingClientRect()
|
||||
samples.push({
|
||||
ids: visible,
|
||||
last: visible.includes(last),
|
||||
bottomError: bottom ? bottom.bottom - view.bottom : undefined,
|
||||
})
|
||||
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
||||
if (
|
||||
!firstPaint &&
|
||||
visible.includes(last) &&
|
||||
@@ -267,7 +263,7 @@ test.describe("smoke: session timeline", () => {
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
await page.addInitScript(
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": true,
|
||||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
|
||||
"include": [
|
||||
"./performance/timeline-stability/**/*.spec.ts",
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./reproduction/timeline-suspense/**/*.ts",
|
||||
"./reproduction/timeline-suspense/**/*.tsx",
|
||||
"../src/types.ts",
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
|
||||
const worktree = path.match(/^\/api\/experimental\/project\/([^/]+)\/worktree$/)?.[1]
|
||||
if (worktree && route.request().method() === "GET")
|
||||
return json(route, [
|
||||
{ directory: config.directory },
|
||||
@@ -294,7 +294,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}
|
||||
if (worktree && route.request().method() === "DELETE")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
|
||||
if (/^\/api\/experimental\/project\/[^/]+\/worktree\/refresh$/.test(path))
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (path === "/api/permission/request")
|
||||
return json(route, {
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
|
||||
describe("buildPromptRequest", () => {
|
||||
test("builds text, files, and agents from the prompt", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/foo.ts",
|
||||
content: "@src/foo.ts",
|
||||
start: 5,
|
||||
end: 16,
|
||||
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
||||
},
|
||||
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
||||
]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.text).toContain("hello @src/foo.ts @planner")
|
||||
expect(result.text).toContain("check this")
|
||||
expect(result.displayText).toBe("hello @src/foo.ts @planner")
|
||||
expect(result.comments).toMatchObject([{ path: "src/bar.ts", comment: "check this" }])
|
||||
expect(result.agents).toEqual([{ name: "planner", mention: { start: 16, end: 24, text: "@planner" } }])
|
||||
expect(result.files.some((file) => file.uri.startsWith("file:///repo/src/foo.ts"))).toBe(true)
|
||||
expect(result.files.find((file) => file.uri.startsWith("file:///repo/src/foo.ts"))?.mention).toEqual({
|
||||
start: 5,
|
||||
end: 16,
|
||||
text: "@src/foo.ts",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps multiple uploaded attachments in order", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{
|
||||
type: "image",
|
||||
id: "img_2",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
dataUrl: "data:application/pdf;base64,BBB",
|
||||
},
|
||||
],
|
||||
text: "check these",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const uploads = result.files.filter((file) => file.uri.startsWith("data:"))
|
||||
|
||||
expect(uploads).toHaveLength(2)
|
||||
expect(uploads.map((file) => file.name)).toEqual(["a.png", "b.pdf"])
|
||||
})
|
||||
|
||||
test("preserves an external attachment source path for the model", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [],
|
||||
context: [],
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
id: "img_external",
|
||||
filename: "opencode.global.dat",
|
||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
mime: "text/plain",
|
||||
dataUrl: "data:text/plain;base64,AAA",
|
||||
},
|
||||
],
|
||||
text: "inspect this",
|
||||
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
||||
})
|
||||
|
||||
expect(result.files[0]?.name).toBe(
|
||||
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves reference aliases as directory files", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [
|
||||
{
|
||||
type: "file",
|
||||
path: "/repo/../docs",
|
||||
content: "@docs",
|
||||
start: 0,
|
||||
end: 5,
|
||||
mime: "application/x-directory",
|
||||
filename: "docs",
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@docs",
|
||||
sessionDirectory: "/repo/app",
|
||||
})
|
||||
|
||||
expect(result.files[0]).toEqual({
|
||||
uri: "file:///repo/../docs",
|
||||
mime: "application/x-directory",
|
||||
name: "docs",
|
||||
mention: { start: 0, end: 5, text: "@docs" },
|
||||
})
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||
],
|
||||
images: [],
|
||||
text: "@src/foo.ts",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const fooFiles = result.files.filter((file) => file.uri.startsWith("file:///repo/src/foo.ts"))
|
||||
|
||||
expect(fooFiles).toHaveLength(2)
|
||||
expect(result.text).toContain("focus here")
|
||||
})
|
||||
|
||||
test("adds files for @mentions inside comment text", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||
context: [
|
||||
{
|
||||
key: "ctx:comment-mention",
|
||||
type: "file",
|
||||
path: "src/review.ts",
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
text: "look",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.files).toHaveLength(2)
|
||||
expect(result.files.some((file) => file.uri === "file:///repo/src/review.ts")).toBe(true)
|
||||
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\foo.ts",
|
||||
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// URL should be parseable
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
// Should not have encoded backslashes in wrong place
|
||||
expect(file!.uri).not.toContain("%5C")
|
||||
// Should have normalized to forward slashes
|
||||
expect(file!.uri).toContain("/src/foo.ts")
|
||||
})
|
||||
|
||||
test("handles Windows absolute path with special characters", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@file#name.txt",
|
||||
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// URL should be parseable
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
// Special chars should be encoded
|
||||
expect(file!.uri).toContain("file%23name.txt")
|
||||
// Should have Windows drive letter properly encoded
|
||||
expect(file!.uri).toMatch(/file:\/\/\/[A-Z]:/)
|
||||
})
|
||||
|
||||
test("handles Linux absolute paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src/app.ts",
|
||||
sessionDirectory: "/home/user/project",
|
||||
})
|
||||
|
||||
expect(result.files[0]?.uri).toBe("file:///home/user/project/src/app.ts")
|
||||
})
|
||||
|
||||
test("handles macOS paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@README.md",
|
||||
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
||||
})
|
||||
|
||||
expect(result.files[0]?.uri).toBe("file:///Users/kelvin/Projects/opencode/README.md")
|
||||
})
|
||||
|
||||
test("handles context files with Windows paths", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [],
|
||||
context: [
|
||||
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
||||
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
||||
],
|
||||
images: [],
|
||||
text: "test",
|
||||
sessionDirectory: "D:\\workspace\\app",
|
||||
})
|
||||
|
||||
expect(result.files).toHaveLength(2)
|
||||
|
||||
// All file URLs should be valid
|
||||
result.files.forEach((file) => {
|
||||
expect(() => new URL(file.uri)).not.toThrow()
|
||||
expect(file.uri).not.toContain("%5C") // No encoded backslashes
|
||||
})
|
||||
})
|
||||
|
||||
test("handles absolute Windows paths (user manually specifies full path)", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
|
||||
]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@D:\\other\\project\\file.ts",
|
||||
sessionDirectory: "C:\\current\\project",
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// Should handle absolute path that differs from sessionDirectory
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
expect(file!.uri).toContain("/D:/other/project/file.ts")
|
||||
})
|
||||
|
||||
test("handles selection with query parameters on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
type: "file",
|
||||
path: "src\\App.tsx",
|
||||
content: "@src\\App.tsx",
|
||||
start: 0,
|
||||
end: 11,
|
||||
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\App.tsx",
|
||||
sessionDirectory: "C:\\project",
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// Should have query parameters
|
||||
expect(file!.uri).toContain("?start=10&end=20")
|
||||
// Should be valid URL
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
// Query params should parse correctly
|
||||
const url = new URL(file!.uri)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
})
|
||||
|
||||
test("handles file paths with dots and special segments on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
|
||||
]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@..\\..\\shared\\util.ts",
|
||||
sessionDirectory: "C:\\projects\\myapp\\src",
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// Should be valid URL
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
// Should preserve .. segments (backend normalizes)
|
||||
expect(file!.uri).toContain("/..")
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
|
||||
|
||||
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
|
||||
type PromptRequest = {
|
||||
text: string
|
||||
displayText: string
|
||||
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
|
||||
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
comments: PromptComment[]
|
||||
}
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
type BuildPromptRequestInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||
text: string
|
||||
sessionDirectory: string
|
||||
}
|
||||
|
||||
const absolute = (directory: string, path: string) => {
|
||||
if (path.startsWith("/")) return path
|
||||
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
||||
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
||||
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
||||
}
|
||||
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||
|
||||
const parseCommentMentions = (comment: string) => {
|
||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||
if (!path) return []
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
return {
|
||||
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
mime: attachment.mime ?? "text/plain",
|
||||
name: attachment.filename ?? getFilename(attachment.path),
|
||||
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
|
||||
}
|
||||
})
|
||||
|
||||
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => ({
|
||||
name: attachment.name,
|
||||
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
|
||||
}))
|
||||
|
||||
const used = new Set(files.map((file) => file.uri))
|
||||
const comments: PromptComment[] = []
|
||||
const context = input.context.flatMap((item) => {
|
||||
const path = absolute(input.sessionDirectory, item.path)
|
||||
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment && used.has(uri)) return []
|
||||
used.add(uri)
|
||||
|
||||
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
|
||||
if (!comment) return [file]
|
||||
|
||||
comments.push({
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment,
|
||||
preview: item.preview,
|
||||
origin: item.commentOrigin,
|
||||
})
|
||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(uri)) return []
|
||||
used.add(uri)
|
||||
return [{ uri, mime: "text/plain", name: getFilename(path) }]
|
||||
})
|
||||
return [file, ...mentions]
|
||||
})
|
||||
|
||||
const images = input.images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
mime: attachment.mime,
|
||||
name: attachment.sourcePath ?? attachment.filename,
|
||||
}))
|
||||
|
||||
return {
|
||||
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
|
||||
displayText: input.text,
|
||||
files: [...files, ...context, ...images],
|
||||
agents,
|
||||
comments,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
|
||||
describe("buildRequestParts", () => {
|
||||
test("builds typed request and optimistic parts without cast path", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/foo.ts",
|
||||
content: "@src/foo.ts",
|
||||
start: 5,
|
||||
end: 16,
|
||||
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
||||
},
|
||||
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.requestParts[0]?.type).toBe("text")
|
||||
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
|
||||
expect(
|
||||
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
||||
).toBe(true)
|
||||
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
||||
expect(
|
||||
result.requestParts.some(
|
||||
(part) =>
|
||||
part.type === "text" &&
|
||||
part.synthetic &&
|
||||
part.metadata?.opencodeComment &&
|
||||
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps multiple uploaded attachments in order", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{
|
||||
type: "image",
|
||||
id: "img_2",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
dataUrl: "data:application/pdf;base64,BBB",
|
||||
},
|
||||
],
|
||||
text: "check these",
|
||||
messageID: "msg_multi",
|
||||
sessionID: "ses_multi",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
||||
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||
})
|
||||
|
||||
test("preserves an external attachment source path for the model", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [],
|
||||
context: [],
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
id: "img_external",
|
||||
filename: "opencode.global.dat",
|
||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
mime: "text/plain",
|
||||
dataUrl: "data:text/plain;base64,AAA",
|
||||
},
|
||||
],
|
||||
text: "inspect this",
|
||||
messageID: "msg_external",
|
||||
sessionID: "ses_external",
|
||||
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
||||
})
|
||||
|
||||
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
|
||||
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves reference aliases as directory file parts", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [
|
||||
{
|
||||
type: "file",
|
||||
path: "/repo/../docs",
|
||||
content: "@docs",
|
||||
start: 0,
|
||||
end: 5,
|
||||
mime: "application/x-directory",
|
||||
filename: "docs",
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@docs",
|
||||
messageID: "msg_reference",
|
||||
sessionID: "ses_reference",
|
||||
sessionDirectory: "/repo/app",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
expect(filePart.mime).toBe("application/x-directory")
|
||||
expect(filePart.filename).toBe("docs")
|
||||
expect(filePart.url).toBe("file:///repo/../docs")
|
||||
expect(filePart.source?.type).toBe("file")
|
||||
if (filePart.source?.type === "file") {
|
||||
expect(filePart.source.path).toBe("/repo/../docs")
|
||||
expect(filePart.source.text.value).toBe("@docs")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||
],
|
||||
images: [],
|
||||
text: "@src/foo.ts",
|
||||
messageID: "msg_2",
|
||||
sessionID: "ses_2",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const fooFiles = result.requestParts.filter(
|
||||
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
|
||||
)
|
||||
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
|
||||
|
||||
expect(fooFiles).toHaveLength(2)
|
||||
expect(synthetic).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("adds file parts for @mentions inside comment text", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||
context: [
|
||||
{
|
||||
key: "ctx:comment-mention",
|
||||
type: "file",
|
||||
path: "src/review.ts",
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
text: "look",
|
||||
messageID: "msg_comment_mentions",
|
||||
sessionID: "ses_comment_mentions",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\foo.ts",
|
||||
messageID: "msg_win_1",
|
||||
sessionID: "ses_win_1",
|
||||
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
||||
})
|
||||
|
||||
// Should create valid file URLs
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should not have encoded backslashes in wrong place
|
||||
expect(filePart.url).not.toContain("%5C")
|
||||
// Should have normalized to forward slashes
|
||||
expect(filePart.url).toContain("/src/foo.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles Windows absolute path with special characters", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@file#name.txt",
|
||||
messageID: "msg_win_2",
|
||||
sessionID: "ses_win_2",
|
||||
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Special chars should be encoded
|
||||
expect(filePart.url).toContain("file%23name.txt")
|
||||
// Should have Windows drive letter properly encoded
|
||||
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
|
||||
}
|
||||
})
|
||||
|
||||
test("handles Linux absolute paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src/app.ts",
|
||||
messageID: "msg_linux_1",
|
||||
sessionID: "ses_linux_1",
|
||||
sessionDirectory: "/home/user/project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should be a normal Unix path
|
||||
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles macOS paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@README.md",
|
||||
messageID: "msg_mac_1",
|
||||
sessionID: "ses_mac_1",
|
||||
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should be a normal Unix path
|
||||
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles context files with Windows paths", () => {
|
||||
const prompt: Prompt = []
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
||||
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
||||
],
|
||||
images: [],
|
||||
text: "test",
|
||||
messageID: "msg_win_ctx",
|
||||
sessionID: "ses_win_ctx",
|
||||
sessionDirectory: "D:\\workspace\\app",
|
||||
})
|
||||
|
||||
const fileParts = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(fileParts).toHaveLength(2)
|
||||
|
||||
// All file URLs should be valid
|
||||
fileParts.forEach((part) => {
|
||||
if (part.type === "file") {
|
||||
expect(() => new URL(part.url)).not.toThrow()
|
||||
expect(part.url).not.toContain("%5C") // No encoded backslashes
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("handles absolute Windows paths (user manually specifies full path)", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@D:\\other\\project\\file.ts",
|
||||
messageID: "msg_abs",
|
||||
sessionID: "ses_abs",
|
||||
sessionDirectory: "C:\\current\\project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should handle absolute path that differs from sessionDirectory
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
expect(filePart.url).toContain("/D:/other/project/file.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles selection with query parameters on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
type: "file",
|
||||
path: "src\\App.tsx",
|
||||
content: "@src\\App.tsx",
|
||||
start: 0,
|
||||
end: 11,
|
||||
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\App.tsx",
|
||||
messageID: "msg_sel",
|
||||
sessionID: "ses_sel",
|
||||
sessionDirectory: "C:\\project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should have query parameters
|
||||
expect(filePart.url).toContain("?start=10&end=20")
|
||||
// Should be valid URL
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Query params should parse correctly
|
||||
const url = new URL(filePart.url)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles file paths with dots and special segments on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@..\\..\\shared\\util.ts",
|
||||
messageID: "msg_dots",
|
||||
sessionID: "ses_dots",
|
||||
sessionDirectory: "C:\\projects\\myapp\\src",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should be valid URL
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should preserve .. segments (backend normalizes)
|
||||
expect(filePart.url).toContain("/..")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||
|
||||
type PromptRequestPart =
|
||||
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
type BuildRequestPartsInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||
text: string
|
||||
messageID: string
|
||||
sessionID: string
|
||||
sessionDirectory: string
|
||||
}
|
||||
|
||||
const absolute = (directory: string, path: string) => {
|
||||
if (path.startsWith("/")) return path
|
||||
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
||||
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
||||
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
||||
}
|
||||
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||
|
||||
const parseCommentMentions = (comment: string) => {
|
||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||
if (!path) return []
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
|
||||
if (part.type === "text") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
text: part.text,
|
||||
synthetic: part.synthetic,
|
||||
ignored: part.ignored,
|
||||
time: part.time,
|
||||
metadata: part.metadata,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
if (part.type === "file") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: part.source,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: part.id,
|
||||
type: "agent",
|
||||
name: part.name,
|
||||
source: part.source,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
const requestParts: PromptRequestPart[] = input.text.trim()
|
||||
? [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input.text,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
const source = attachment.source
|
||||
? {
|
||||
...attachment.source,
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path,
|
||||
}
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: attachment.mime ?? "text/plain",
|
||||
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
filename: attachment.filename ?? getFilename(attachment.path),
|
||||
source,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "agent",
|
||||
name: attachment.name,
|
||||
source: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
const used = new Set(files.map((part) => part.url))
|
||||
const context = input.context.flatMap((item) => {
|
||||
const path = absolute(input.sessionDirectory, item.path)
|
||||
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment && used.has(url)) return []
|
||||
used.add(url)
|
||||
|
||||
const filePart = {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(item.path),
|
||||
} satisfies PromptRequestPart
|
||||
|
||||
if (!comment) return [filePart]
|
||||
|
||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(url)) return []
|
||||
used.add(url)
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(path),
|
||||
} satisfies PromptRequestPart,
|
||||
]
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
|
||||
synthetic: true,
|
||||
metadata: createCommentMetadata({
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment,
|
||||
preview: item.preview,
|
||||
origin: item.commentOrigin,
|
||||
}),
|
||||
} satisfies PromptRequestPart,
|
||||
filePart,
|
||||
...mentions,
|
||||
]
|
||||
})
|
||||
|
||||
const images = input.images.map((attachment) => {
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.sourcePath ?? attachment.filename,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
requestParts.push(...files, ...context, ...agents, ...images)
|
||||
|
||||
return {
|
||||
requestParts,
|
||||
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,15 @@ type SessionCreateInput = {
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}
|
||||
const admitted: Array<{
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
comments: unknown[]
|
||||
sessionID?: string
|
||||
message: {
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const confirmed: unknown[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const sentShellDirectories: string[] = []
|
||||
@@ -37,11 +35,9 @@ const switchedModels: Array<{
|
||||
const sessionRequestOrder: string[] = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const admittedServers: string[] = []
|
||||
const optimisticServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
let serverSessionSyncs = 0
|
||||
let restoredPrompts = 0
|
||||
let clearEchoCalls = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
@@ -51,8 +47,6 @@ let createSessionGate: Promise<void> | undefined
|
||||
let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let locationFailure: Error | undefined
|
||||
let promptFailure: Error | undefined
|
||||
let clearEchoResult = true
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
@@ -80,7 +74,7 @@ const prompt = {
|
||||
set: () => undefined,
|
||||
},
|
||||
reset: () => undefined,
|
||||
set: () => restoredPrompts++,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
@@ -122,16 +116,7 @@ const clientFor = (directory: string) => {
|
||||
sessionRequestOrder.push("prompt")
|
||||
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||
promptInputs.push(input)
|
||||
if (promptFailure) throw promptFailure
|
||||
const prompt = input as { sessionID: string; id: string; text: string }
|
||||
return {
|
||||
id: prompt.id,
|
||||
sessionID: prompt.sessionID,
|
||||
timeCreated: 1,
|
||||
type: "user" as const,
|
||||
delivery: "steer" as const,
|
||||
payload: { text: prompt.text },
|
||||
}
|
||||
return { data: undefined }
|
||||
},
|
||||
switchAgent: async (input: { sessionID: string; agent: string }) => {
|
||||
sessionRequestOrder.push("agent")
|
||||
@@ -250,27 +235,16 @@ beforeAll(async () => {
|
||||
return {
|
||||
data: { command: commands, project: "project" },
|
||||
session: {
|
||||
inbox: {
|
||||
echo: (value: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
comments: unknown[]
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
admittedServers.push(server)
|
||||
admitted.push(value)
|
||||
},
|
||||
confirm: (value: unknown) => {
|
||||
confirmed.push(value)
|
||||
},
|
||||
clearEcho: () => {
|
||||
clearEchoCalls++
|
||||
return clearEchoResult
|
||||
optimisticServers.push(server)
|
||||
optimistic.push(value)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
@@ -330,8 +304,7 @@ beforeAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
createdSessions.length = 0
|
||||
admitted.length = 0
|
||||
confirmed.length = 0
|
||||
optimistic.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
@@ -341,10 +314,8 @@ beforeEach(() => {
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
syncedServers.length = 0
|
||||
admittedServers.length = 0
|
||||
optimisticServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
restoredPrompts = 0
|
||||
clearEchoCalls = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
@@ -362,8 +333,6 @@ beforeEach(() => {
|
||||
createWorktreeGate = undefined
|
||||
worktreeFailure = undefined
|
||||
locationFailure = undefined
|
||||
promptFailure = undefined
|
||||
clearEchoResult = true
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
@@ -452,7 +421,7 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(admittedServers).toEqual(["server-a"])
|
||||
expect(optimisticServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
@@ -472,15 +441,13 @@ describe("prompt submit worktree selection", () => {
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(admitted).toHaveLength(1)
|
||||
expect(admitted[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
text: "ls",
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
expect(optimistic).toHaveLength(1)
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
},
|
||||
})
|
||||
expect(admitted[0]?.messageID).toStartWith("msg_")
|
||||
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
|
||||
expect(sentPrompts).toEqual(["/repo/main"])
|
||||
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
||||
expect(switchedModels).toEqual([
|
||||
@@ -499,22 +466,6 @@ describe("prompt submit worktree selection", () => {
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
})
|
||||
|
||||
test("keeps a confirmed echo when the prompt response is lost", async () => {
|
||||
params = { id: "session-1" }
|
||||
promptFailure = new Error("connection lost")
|
||||
clearEchoResult = false
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
|
||||
})
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(admitted).toHaveLength(1)
|
||||
expect(clearEchoCalls).toBe(1)
|
||||
expect(restoredPrompts).toBe(0)
|
||||
})
|
||||
|
||||
test("submits slash commands through the current session API", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Message } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { startTransition, type Accessor } from "solid-js"
|
||||
import { batch, startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -14,7 +15,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
@@ -99,22 +100,43 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
const request = buildPromptRequest({
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: input.draft.prompt,
|
||||
context: input.draft.context,
|
||||
images: encodedImages,
|
||||
text,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
sessionDirectory: input.draft.sessionDirectory,
|
||||
})
|
||||
|
||||
setBusy()
|
||||
input.sync.session.inbox.echo({
|
||||
directory: input.draft.sessionDirectory,
|
||||
const message: Message = {
|
||||
id: messageID,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.draft.agent,
|
||||
model: { ...input.draft.model, variant: input.draft.variant },
|
||||
...request,
|
||||
}
|
||||
|
||||
const add = () =>
|
||||
input.sync.session.optimistic.add({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
message,
|
||||
parts: optimisticParts,
|
||||
})
|
||||
|
||||
const remove = () =>
|
||||
input.sync.session.optimistic.remove({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
})
|
||||
|
||||
batch(() => {
|
||||
setBusy()
|
||||
add()
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -137,23 +159,40 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
})
|
||||
}
|
||||
|
||||
const admitted = await input.api.prompt({
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
text: request.text,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
agents: request.agents,
|
||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: requestParts.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const text = part.source?.text
|
||||
return [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
||||
},
|
||||
]
|
||||
}),
|
||||
agents: requestParts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
mention: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
})
|
||||
input.sync.session.inbox.confirm(admitted)
|
||||
return true
|
||||
} catch (err) {
|
||||
const failed = input.sync.session.inbox.clearEcho({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
batch(() => {
|
||||
setIdle()
|
||||
remove()
|
||||
})
|
||||
if (!failed) return true
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -499,6 +538,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
submissionSync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
@@ -518,6 +565,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -119,11 +119,9 @@ export function createProviderConnectionController(options: {
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
const directory = options.directory()
|
||||
const key = directory ? pathKey(directory) : null
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
|
||||
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
|
||||
])
|
||||
await queryClient
|
||||
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
|
||||
.catch(() => undefined)
|
||||
if (polling.disposed) return
|
||||
options.onComplete()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { useIntegrations } from "@/hooks/use-integrations"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
@@ -41,9 +40,7 @@ export const SettingsProvidersV2: Component<{
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => props.directory)
|
||||
const integrations = useIntegrations(() => props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
@@ -76,14 +73,7 @@ export const SettingsProvidersV2: Component<{
|
||||
return items
|
||||
})
|
||||
|
||||
// Connection state comes from the integration list like the TUI: credential
|
||||
// connections mean an API key or OAuth grant, env connections mean detected
|
||||
// environment variables, and a connectionless integration is config-provided.
|
||||
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||
const current = integration(item.id)
|
||||
if (current?.connections.some((connection) => connection.type === "credential")) return "api"
|
||||
if (current?.connections.some((connection) => connection.type === "env")) return "env"
|
||||
if (current) return "config"
|
||||
if (!("source" in item)) return
|
||||
const value = item.source
|
||||
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||
@@ -102,11 +92,7 @@ export const SettingsProvidersV2: Component<{
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) => {
|
||||
const current = integration(item.id)
|
||||
if (current) return current.connections.some((connection) => connection.type === "credential")
|
||||
return source(item) !== "env" && !isConfigCustom(item.id)
|
||||
}
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
|
||||
@@ -68,8 +68,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
|
||||
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 4px;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
@@ -87,8 +86,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"],
|
||||
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to left,
|
||||
black 0,
|
||||
@@ -105,7 +103,8 @@
|
||||
);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
|
||||
[data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Message, Part } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createMemo } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { PromptEcho } from "./server-session"
|
||||
import type { State } from "./global-sync/types"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
@@ -82,17 +82,35 @@ export const createDirSyncContext = (
|
||||
const session = serverSync.session.get(sessionID)
|
||||
if (session?.location.directory === directory) return session
|
||||
},
|
||||
inbox: {
|
||||
echo(input: PromptEcho & { directory?: string }) {
|
||||
serverSync.session.inbox.echo(input)
|
||||
optimistic: {
|
||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||
serverSync.session.optimistic.add(input)
|
||||
},
|
||||
confirm(input: SessionInboxInfo) {
|
||||
return serverSync.session.inbox.confirm(input)
|
||||
},
|
||||
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
return serverSync.session.inbox.clearEcho(input)
|
||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
serverSync.session.optimistic.remove(input)
|
||||
},
|
||||
},
|
||||
addOptimisticMessage(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
parts: Part[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}) {
|
||||
serverSync.session.optimistic.add({
|
||||
sessionID: input.sessionID,
|
||||
message: {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: { ...input.model, variant: input.variant },
|
||||
},
|
||||
parts: input.parts,
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string, options?: { force?: boolean }) {
|
||||
await serverSync.session.sync(sessionID, options)
|
||||
index(sessionID)
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("encodeFilePath", () => {
|
||||
})
|
||||
|
||||
test("should handle mixed separator path (Windows + Unix)", () => {
|
||||
// This is what happens in build-prompt-request.ts when concatenating paths
|
||||
// This is what happens in build-request-parts.ts when concatenating paths
|
||||
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
|
||||
const result = encodeFilePath(mixedPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
@@ -287,8 +287,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
)
|
||||
return
|
||||
|
||||
const directory = event.current?.location?.directory
|
||||
if (!directory) return
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.execution.failed") {
|
||||
handleSessionError(directory, event, time)
|
||||
|
||||
@@ -194,7 +194,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
const handlePermission = (e: PermissionEvent) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
void respondPending(event.properties, event.current?.location?.directory)
|
||||
void respondPending(event.properties, e.name)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.event.listen((event) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent, coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
@@ -45,21 +45,23 @@ describe("adaptServerEvent", () => {
|
||||
})
|
||||
|
||||
describe("current event buffering", () => {
|
||||
const delta = (id: string, value: string, ordinal = 0) =>
|
||||
adaptServerEvent({
|
||||
const delta = (id: string, value: string, ordinal = 0) => ({
|
||||
directory: "/repo",
|
||||
payload: adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||
} as OpenCodeEvent)
|
||||
} as OpenCodeEvent),
|
||||
})
|
||||
|
||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
expect(result[0]?.properties).toMatchObject({ delta: "hello world" })
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
||||
})
|
||||
|
||||
test("coalesces current tool input deltas by tool ID", () => {
|
||||
@@ -72,19 +74,26 @@ describe("current event buffering", () => {
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
current("evt_1", "call_1", "{"),
|
||||
current("evt_2", "call_1", "}"),
|
||||
current("evt_3", "call_2", "[]"),
|
||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]?.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
})
|
||||
|
||||
test("preserves boundaries between distinct delta streams", () => {
|
||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||
|
||||
expect(coalesceServerEvents(events).map((event) => event.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||
})
|
||||
|
||||
test("preserves current event order when enqueuing", () => {
|
||||
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
||||
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
||||
|
||||
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ServerScope } from "@/utils/server-scope"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||
type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||
@@ -22,17 +22,22 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: ServerEvent[]) {
|
||||
const output: ServerEvent[] = []
|
||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||
queue.push(event)
|
||||
return true
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
events.forEach((event) => {
|
||||
const current = currentDelta(event.current)
|
||||
const current = currentDelta(event.payload.current)
|
||||
if (current) {
|
||||
const previous = output[output.length - 1]
|
||||
const prior = currentDelta(previous?.current)
|
||||
const prior = currentDelta(previous?.payload.current)
|
||||
if (
|
||||
previous &&
|
||||
prior &&
|
||||
prior.location?.directory === current.location?.directory &&
|
||||
previous.directory === event.directory &&
|
||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
||||
) {
|
||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||
@@ -41,10 +46,13 @@ export function coalesceServerEvents(events: ServerEvent[]) {
|
||||
? { ...current.data, text: fragment }
|
||||
: { ...current.data, delta: fragment }
|
||||
output[output.length - 1] = {
|
||||
...event,
|
||||
properties: data,
|
||||
current: { ...current, data } as CurrentDelta,
|
||||
} as ServerEvent
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: data,
|
||||
current: { ...current, data } as CurrentDelta,
|
||||
} as ServerEvent,
|
||||
}
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
@@ -81,8 +89,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
|
||||
start()
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
|
||||
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
@@ -97,9 +104,6 @@ type ServerSDKBase = {
|
||||
event: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
location: {
|
||||
on: ServerLocationEventEmitter["on"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,16 +123,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
})()
|
||||
|
||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||
const emitter = createGlobalEmitter<ServerEventMap>()
|
||||
const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: ServerEvent
|
||||
}>()
|
||||
|
||||
type Queued = QueuedServerEvent
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const CONNECT_TIMEOUT_MS = 2_000
|
||||
const RECONNECT_DELAY_MS = 1_000
|
||||
|
||||
let queue: ServerEvent[] = []
|
||||
let buffer: ServerEvent[] = []
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
@@ -146,11 +152,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => {
|
||||
emitter.emit(event.type, event)
|
||||
const directory = event.current?.location?.directory
|
||||
if (directory) locations.emit(directory, event)
|
||||
})
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
@@ -163,8 +165,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
|
||||
function publish(event: OpenCodeEvent) {
|
||||
queue.push(adaptServerEvent(event))
|
||||
schedule()
|
||||
const directory = event.location?.directory ?? "global"
|
||||
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
@@ -311,7 +313,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
queue = []
|
||||
buffer = []
|
||||
emitter.clear()
|
||||
locations.clear()
|
||||
})
|
||||
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
@@ -329,9 +330,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
location: {
|
||||
on: locations.on.bind(locations),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -367,7 +365,7 @@ export type DirectorySDK = {
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.event.location.on(directory, (event) => {
|
||||
const unsub = serverSDK.event.on(directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
@@ -6,32 +6,6 @@ const event = (input: object) => input as OpenCodeEvent
|
||||
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
|
||||
|
||||
describe("v2 session reducer", () => {
|
||||
test("moves a repeated inbox payload to the current event position", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
const result = reducer.reduce(
|
||||
[
|
||||
{ id: "msg_user", type: "user", text: "local", time: { created: 0 } },
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
|
||||
],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.inbox.enqueued",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inboxID: "msg_user",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "durable" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toEqual([
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
|
||||
{ id: "msg_user", type: "user", text: "durable", time: { created: 1 } },
|
||||
])
|
||||
expect(result?.touched).toEqual(["msg_user"])
|
||||
})
|
||||
|
||||
test("projects promoted input and streaming assistant content", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
OpenCodeEvent,
|
||||
SessionInboxInfo,
|
||||
SessionInboxItem,
|
||||
SessionInfo,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||
@@ -35,14 +29,12 @@ export function createV2SessionReducer() {
|
||||
})
|
||||
const append = (message: SessionMessageInfo) =>
|
||||
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
||||
const replace = (message: SessionMessageInfo) =>
|
||||
result([...source.filter((item) => item.id !== message.id), message], [message.id])
|
||||
|
||||
switch (event.type) {
|
||||
case "session.inbox.enqueued":
|
||||
pending.set(key(sessionID, event.data.inboxID), event.data.item)
|
||||
if (event.data.item.type === "user")
|
||||
return replace({
|
||||
return append({
|
||||
id: event.data.inboxID,
|
||||
type: "user",
|
||||
metadata: event.data.item.payload.metadata,
|
||||
@@ -52,7 +44,7 @@ export function createV2SessionReducer() {
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (event.data.item.type !== "synthetic") return result([...source])
|
||||
return replace({
|
||||
return append({
|
||||
id: event.data.inboxID,
|
||||
type: "synthetic",
|
||||
metadata: event.data.item.payload.metadata,
|
||||
@@ -488,9 +480,6 @@ export function createV2SessionReducer() {
|
||||
|
||||
return {
|
||||
reduce,
|
||||
confirm(item: SessionInboxInfo) {
|
||||
pending.set(key(item.sessionID, item.id), item)
|
||||
},
|
||||
clear(sessionID: string) {
|
||||
for (const id of pending.keys()) {
|
||||
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
|
||||
|
||||
@@ -185,16 +185,6 @@ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart =>
|
||||
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
|
||||
})
|
||||
|
||||
const promptEcho = (messageID: string, text = "hello") => ({
|
||||
sessionID: "child",
|
||||
messageID,
|
||||
text,
|
||||
displayText: text,
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
comments: [],
|
||||
})
|
||||
|
||||
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
|
||||
data,
|
||||
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
||||
@@ -309,26 +299,6 @@ function setup(sessions: Record<string, SessionInfo>) {
|
||||
}
|
||||
|
||||
describe("server session", () => {
|
||||
test("hydrates session info after a native session.created event", async () => {
|
||||
const ctx = setup({ created: session("created") })
|
||||
|
||||
ctx.store.apply({
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID: "created",
|
||||
projectID: "project",
|
||||
location: { directory: "/repo" },
|
||||
slug: "created",
|
||||
version: "test",
|
||||
},
|
||||
})
|
||||
|
||||
expect(ctx.store.get("created")).toBeUndefined()
|
||||
await ctx.store.resolve("created")
|
||||
expect(ctx.store.get("created")?.location.directory).toBe("/repo")
|
||||
expect(ctx.get).toEqual([{ sessionID: "created" }])
|
||||
})
|
||||
|
||||
test("projects V2 session events into current and legacy message state", () => {
|
||||
const ctx = setup({ child: session("child") })
|
||||
ctx.store.remember(session("child"))
|
||||
@@ -370,38 +340,14 @@ describe("server session", () => {
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
|
||||
})
|
||||
apply({
|
||||
id: "evt_tool_z",
|
||||
created: 5,
|
||||
type: "session.tool.input.started",
|
||||
durable: { aggregateID: "child", seq: 3, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_z", name: "shell" },
|
||||
})
|
||||
apply({
|
||||
id: "evt_tool_a",
|
||||
created: 6,
|
||||
type: "session.tool.input.started",
|
||||
durable: { aggregateID: "child", seq: 4, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_a", name: "shell" },
|
||||
})
|
||||
|
||||
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
|
||||
id: "msg_2_assistant",
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "world" },
|
||||
{ type: "tool", id: "call_z" },
|
||||
{ type: "tool", id: "call_a" },
|
||||
],
|
||||
content: [{ type: "text", text: "world" }],
|
||||
})
|
||||
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
|
||||
expect(ctx.store.data.part.msg_2_assistant?.map((part) => part.id)).toEqual([
|
||||
"msg_2_assistant:text:0",
|
||||
"call_z",
|
||||
"call_a",
|
||||
])
|
||||
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
|
||||
})
|
||||
|
||||
test("projects V2 pending inputs and forms", () => {
|
||||
@@ -660,45 +606,6 @@ describe("server session", () => {
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
test("preserves assistant content order from message history", async () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "inspect it", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{ type: "text", text: "I will inspect it." },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_z",
|
||||
name: "shell",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_a",
|
||||
name: "shell",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: 3 },
|
||||
},
|
||||
],
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const messageApi = {
|
||||
list: async () => ({ data: source.toReversed(), cursor: { previous: null, next: null } }),
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession({} as SessionApi, messageApi)
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(store.data.part.msg_assistant?.map((part) => part.id)).toEqual(["msg_assistant:text:0", "call_z", "call_a"])
|
||||
})
|
||||
|
||||
test("extends a current page to include the user for split assistant turns", async () => {
|
||||
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
|
||||
const assistant = (id: string, created: number) => ({
|
||||
@@ -803,17 +710,19 @@ describe("server session", () => {
|
||||
expect(store.data.part[parent.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not let an admitted user suppress initial root backfill", async () => {
|
||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const part = textPart(user.id)
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.inbox.echo(promptEcho(user.id, "text"))
|
||||
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
|
||||
|
||||
await store.sync("child")
|
||||
store.optimistic.remove({ sessionID: "child", messageID: user.id })
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
@@ -874,6 +783,28 @@ describe("server session", () => {
|
||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||
})
|
||||
|
||||
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
|
||||
const refreshed = { ...confirmed, text: "fresh" }
|
||||
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(fresh, [refreshed])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
||||
})
|
||||
|
||||
test("uses a parent received by SSE during the replacement load", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
@@ -1109,6 +1040,30 @@ describe("server session", () => {
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves optimistic parts re-added after removal during a refresh", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const part = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [] }]), pending.promise, response()),
|
||||
)
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
pending.resolve(response([{ info: message, parts: [stale] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("drops stale event content omitted by a complete initial page", async () => {
|
||||
const stale = userMessage("stale")
|
||||
const store = createServerSession(messageClient(response()))
|
||||
@@ -1130,309 +1085,170 @@ describe("server session", () => {
|
||||
expect(store.data.message.child).toEqual([live, fetched])
|
||||
})
|
||||
|
||||
test("echoes a prompt without changing durable message order", () => {
|
||||
const store = setup({ child: session("child") }).store
|
||||
test("does not restore removed optimistic content on refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "removed" })
|
||||
const kept = { ...message, id: "kept" }
|
||||
const keptPart = { ...part, id: "kept-part", messageID: kept.id }
|
||||
const store = createServerSession(messageClient(response([{ info: kept, parts: [] }])))
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] })
|
||||
|
||||
store.inbox.echo({
|
||||
...promptEcho("msg_prompt"),
|
||||
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
|
||||
files: [{ uri: "file:///repo/src/foo.ts", mime: "text/plain", name: "foo.ts" }],
|
||||
agents: [{ name: "explore" }],
|
||||
comments: [
|
||||
{
|
||||
path: "src/foo.ts",
|
||||
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
|
||||
comment: "check this",
|
||||
preview: "const value = 1",
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
|
||||
})
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.pending.child).toMatchObject([{ id: "msg_prompt", type: "user", delivery: "steer" }])
|
||||
expect(store.data.input.child).toEqual(["msg_prompt"])
|
||||
expect(store.data.session_message.child).toBeUndefined()
|
||||
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
|
||||
expect(store.data.part.msg_prompt).toMatchObject([
|
||||
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
|
||||
{ id: "msg_prompt:file:0", type: "file", filename: "foo.ts" },
|
||||
{ id: "msg_prompt:agent:0", type: "agent", name: "explore" },
|
||||
{
|
||||
id: "msg_prompt:comment:0",
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/foo.ts",
|
||||
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
|
||||
comment: "check this",
|
||||
preview: "const value = 1",
|
||||
origin: "review",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 2,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: {
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: {
|
||||
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
|
||||
expect(store.data.part.msg_prompt).toMatchObject([
|
||||
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
|
||||
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
|
||||
])
|
||||
expect(store.data.message.child).toEqual([kept])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part[kept.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves a local echo while message history omits pending input", async () => {
|
||||
const store = createServerSession(messageClient(response()))
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.inbox.confirm({
|
||||
id: "msg_prompt",
|
||||
sessionID: "child",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "hello" },
|
||||
})
|
||||
test("replaces confirmed optimistic content with the initial page", async () => {
|
||||
const optimistic = userMessage("message")
|
||||
const fetched = { ...optimistic, time: { created: 2 } }
|
||||
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
|
||||
store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
|
||||
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
|
||||
expect(store.data.message.child).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("preserves local comment presentation through message refresh", async () => {
|
||||
const note = "The user made the following comment regarding line 4 of src/foo.ts: check this"
|
||||
const message = userMessage("msg_prompt")
|
||||
test("replaces a confirmed optimistic part with fetched content", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const optimistic = textPart(message.id, { text: "optimistic" })
|
||||
const fetched = { ...optimistic, text: "fetched" }
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
pending.resolve(response([{ info: message, parts: [fetched] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("rolls back only unconfirmed optimistic parts", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
|
||||
pending.resolve(response([{ info: message, parts: [confirmed] }]))
|
||||
await loading
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([confirmed])
|
||||
})
|
||||
|
||||
test("updates confirmed optimistic parts from later pages", async () => {
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
|
||||
const updated = { ...confirmed, text: "updated" }
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [textPart(message.id, { text: note })] }])),
|
||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
|
||||
)
|
||||
store.inbox.echo({
|
||||
...promptEcho(message.id),
|
||||
text: `hello\n${note}`,
|
||||
comments: [
|
||||
{
|
||||
path: "src/foo.ts",
|
||||
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
|
||||
comment: "check this",
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.part.msg_prompt).toMatchObject([
|
||||
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
|
||||
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
|
||||
])
|
||||
await store.sync("child", { force: true })
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([updated])
|
||||
})
|
||||
|
||||
test("retires an admitted echo absent from authoritative reconnect state", async () => {
|
||||
const store = createServerSession(messageClient(response()))
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.inbox.confirm({
|
||||
id: "msg_prompt",
|
||||
sessionID: "child",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "hello" },
|
||||
test("does not restore a confirmed optimistic part after its removal event", async () => {
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
|
||||
)
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
|
||||
})
|
||||
|
||||
await Promise.all([store.sync("child"), store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))])
|
||||
store.inbox.reconcile("child")
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||
expect(store.data.part[message.id]).toEqual([pendingPart])
|
||||
})
|
||||
|
||||
test("retires a stale enqueued message when inbox hydration finishes after history", async () => {
|
||||
const store = createServerSession(messageClient(response()))
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 1,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
test("clears delta buffers when removing optimistic content", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "optimistic" })
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
await store.sync("child")
|
||||
await store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))
|
||||
store.inbox.reconcile("child")
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("removes projected messages when rolling back optimistic content", () => {
|
||||
const message = userMessage("message")
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [] })
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.session_message.child).toEqual([])
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
test("deduplicates the durable admission event against its local echo", () => {
|
||||
test("does not remove content confirmed by a message event", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
||||
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 2,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.pending.child).toHaveLength(1)
|
||||
expect(store.data.input.child).toEqual(["msg_prompt"])
|
||||
expect(store.data.session_message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
|
||||
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
|
||||
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses the prompt response when the admission event was missed", () => {
|
||||
test("does not remove parts confirmed by part events", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.inbox.confirm({
|
||||
id: "msg_prompt",
|
||||
sessionID: "child",
|
||||
timeCreated: 2,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "hello" },
|
||||
})
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
store.applyV2({
|
||||
id: "evt_delivered",
|
||||
created: Date.now() + 1,
|
||||
type: "session.inbox.delivered",
|
||||
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||
data: { sessionID: "child", inboxID: "msg_prompt" },
|
||||
} as OpenCodeEvent)
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.input.child).toEqual([])
|
||||
expect(store.data.session_message.child).toMatchObject([{ id: "msg_prompt", type: "user", text: "hello" }])
|
||||
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
|
||||
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("keeps a durable admission when the HTTP request later fails", () => {
|
||||
test("treats a part event as confirmation when it precedes the message event", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 2,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(false)
|
||||
expect(store.data.pending.child).toHaveLength(1)
|
||||
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
|
||||
})
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
test("places durable admission after delayed selection events", () => {
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.remember(session("child"))
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.applyV2({
|
||||
id: "evt_agent",
|
||||
created: 1,
|
||||
type: "session.agent.selected",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: { sessionID: "child", agent: "review" },
|
||||
} as OpenCodeEvent)
|
||||
store.applyV2({
|
||||
id: "evt_model",
|
||||
created: 2,
|
||||
type: "session.model.selected",
|
||||
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||
data: { sessionID: "child", model: { id: "new-model", providerID: "new-provider" } },
|
||||
} as OpenCodeEvent)
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 3,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 3, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
|
||||
expect(store.data.session_message.child?.map((message) => message.type)).toEqual([
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"user",
|
||||
])
|
||||
expect(store.data.message.child?.find((message) => message.id === "msg_prompt")).toMatchObject({
|
||||
agent: "review",
|
||||
model: { providerID: "new-provider", modelID: "new-model" },
|
||||
})
|
||||
})
|
||||
|
||||
test("removes an echoed prompt when submission fails", () => {
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
|
||||
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(true)
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.input.child).toEqual([])
|
||||
expect(store.data.session_message.child).toBeUndefined()
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
test("removes a response-confirmed echo when the server cancels it", () => {
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.inbox.confirm({
|
||||
id: "msg_prompt",
|
||||
sessionID: "child",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "hello" },
|
||||
})
|
||||
|
||||
store.applyV2({
|
||||
id: "evt_cancelled",
|
||||
created: 2,
|
||||
type: "session.inbox.cancelled",
|
||||
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||
data: { sessionID: "child", inboxID: "msg_prompt" },
|
||||
} as OpenCodeEvent)
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("clears stale parts when the initial page has none", async () => {
|
||||
@@ -1653,6 +1469,28 @@ describe("server session", () => {
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves optimistic re-adds across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [stale] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([optimistic])
|
||||
})
|
||||
|
||||
test("accepts part omission from a successful retry after an earlier delta", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
@@ -1816,6 +1654,33 @@ describe("server session", () => {
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not cache skipped optimistic parts", () => {
|
||||
const message = userMessage("message")
|
||||
const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const }
|
||||
const store = setup({ child: session("child") }).store
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([])
|
||||
})
|
||||
|
||||
test("clears stale delta buffers when replacing optimistic parts", () => {
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [stale] })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
|
||||
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves removals during history prepend", async () => {
|
||||
const pending = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
@@ -2041,7 +1906,24 @@ describe("server session", () => {
|
||||
test("preserves pinned session content under server-wide cache pressure", () => {
|
||||
const ctx = setup({})
|
||||
ctx.store.pin("active")
|
||||
ctx.store.inbox.echo({ ...promptEcho("message", "keep"), sessionID: "active" })
|
||||
ctx.store.optimistic.add({
|
||||
sessionID: "active",
|
||||
message: {
|
||||
id: "message",
|
||||
sessionID: "active",
|
||||
role: "assistant",
|
||||
time: { created: 1 },
|
||||
parentID: "parent",
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
mode: "build",
|
||||
agent: "agent",
|
||||
path: { cwd: "/repo", root: "/repo" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
parts: [],
|
||||
})
|
||||
|
||||
for (let index = 0; index < 50; index++) {
|
||||
ctx.store.remember(session(`session-${index}`))
|
||||
|
||||
@@ -18,13 +18,6 @@ import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/s
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import {
|
||||
createCommentMetadata,
|
||||
formatCommentNote,
|
||||
parseCommentNote,
|
||||
readCommentMetadata,
|
||||
type PromptComment,
|
||||
} from "@/utils/comment-note"
|
||||
|
||||
type MessageApi = ServerApi["message"]
|
||||
|
||||
@@ -35,6 +28,31 @@ const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
function projectMessageSource(message: Message): SessionMessageInfo[] {
|
||||
if (message.role === "user") {
|
||||
return [
|
||||
{ id: `${message.id}:agent`, type: "agent-switched", agent: message.agent, time: message.time },
|
||||
{
|
||||
id: `${message.id}:model`,
|
||||
type: "model-switched",
|
||||
model: { id: message.model.modelID, providerID: message.model.providerID, variant: message.model.variant },
|
||||
time: message.time,
|
||||
},
|
||||
{ id: message.id, type: "user", text: "", time: message.time },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: message.id,
|
||||
type: "assistant",
|
||||
agent: message.agent ?? message.mode,
|
||||
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
|
||||
content: [],
|
||||
time: message.time,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||
const boundary = source.find(
|
||||
(message) =>
|
||||
@@ -46,6 +64,13 @@ function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||
return boundary?.type === "assistant"
|
||||
}
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
confirmedParts?: Part[]
|
||||
confirmedMessage?: boolean
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
@@ -56,18 +81,6 @@ type MessagePage = {
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
export type PromptEcho = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
files?: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
|
||||
agents?: { name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
comments: PromptComment[]
|
||||
}
|
||||
|
||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||
type MessageLoadState = {
|
||||
touchedMessages: Set<string>
|
||||
@@ -77,6 +90,7 @@ type MessageLoadState = {
|
||||
deltaParts: Map<string, Set<string>>
|
||||
carriedDeltaParts: Map<string, Set<string>>
|
||||
removedParts: Map<string, Set<string>>
|
||||
optimisticParts: Map<string, Set<string>>
|
||||
orphanParents: Set<string>
|
||||
clearedMessageParts: Set<string>
|
||||
touchedSource: Set<string>
|
||||
@@ -87,6 +101,34 @@ type MessageLoadBaseline = Pick<
|
||||
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
|
||||
>
|
||||
|
||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||
const observed: { messageID: string; parts: Part[] }[] = []
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, messageKey(item.message), messageKey)
|
||||
const found = result.found
|
||||
if (!found) session.splice(result.index, 0, item.message)
|
||||
const current = part.get(item.message.id)
|
||||
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
|
||||
if (found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||
part.set(
|
||||
item.message.id,
|
||||
merge(
|
||||
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||
item.parts.filter((part) => !confirmed.includes(part)),
|
||||
),
|
||||
)
|
||||
}
|
||||
return {
|
||||
...page,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
||||
observed,
|
||||
}
|
||||
}
|
||||
|
||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||
const pending = map.get(key)
|
||||
if (pending) return pending
|
||||
@@ -161,7 +203,6 @@ export function createServerSession(
|
||||
input: {} as Record<string, string[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
session_message: {} as Record<string, SessionMessageInfo[]>,
|
||||
// Part order is semantic and follows SessionMessageAssistant.content; IDs identify parts only.
|
||||
part: {} as Record<string, Part[]>,
|
||||
part_text_accum_delta: {} as Record<string, string>,
|
||||
session_working(id: string) {
|
||||
@@ -171,6 +212,7 @@ export function createServerSession(
|
||||
const requests = new Map<string, Promise<SessionInfo>>()
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const v2 = createV2SessionReducer()
|
||||
const pendingRevision = new Map<string, number>()
|
||||
const formRevision = new Map<string, number>()
|
||||
@@ -181,45 +223,7 @@ export function createServerSession(
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
const orphanParts = new Map<string, Set<string>>()
|
||||
const removedMessages = new Map<string, Set<string>>()
|
||||
const echoes = new Map<string, Map<string, "sending" | "admitted">>()
|
||||
const messageSnapshots = new Map<string, Set<string>>()
|
||||
const settledInputs = new Map<string, Set<string>>()
|
||||
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||
const markEcho = (sessionID: string, messageID: string) => {
|
||||
const messages = echoes.get(sessionID) ?? new Map<string, "sending" | "admitted">()
|
||||
messages.set(messageID, "sending")
|
||||
echoes.set(sessionID, messages)
|
||||
}
|
||||
const confirmEcho = (sessionID: string, messageID: string) => {
|
||||
const messages = echoes.get(sessionID)
|
||||
if (!messages?.has(messageID)) return false
|
||||
messages.set(messageID, "admitted")
|
||||
return true
|
||||
}
|
||||
const releaseEcho = (sessionID: string, messageID: string) => {
|
||||
const messages = echoes.get(sessionID)
|
||||
const state = messages?.get(messageID)
|
||||
if (!messages || !state) return
|
||||
messages.delete(messageID)
|
||||
if (messages.size === 0) echoes.delete(sessionID)
|
||||
return state
|
||||
}
|
||||
const present = (messageID: string, parts: Part[]) => {
|
||||
const local = data.part[messageID] ?? []
|
||||
const comments = local.filter(
|
||||
(part) =>
|
||||
part.type === "text" &&
|
||||
part.synthetic &&
|
||||
(readCommentMetadata(part.metadata) !== undefined || parseCommentNote(part.text) !== undefined),
|
||||
)
|
||||
if (!comments.length) return parts
|
||||
const text = local.find((part) => part.type === "text" && !part.synthetic)
|
||||
const projected = parts.flatMap((part) => {
|
||||
if (part.id !== `${messageID}:text:0` || part.type !== "text") return [part]
|
||||
return text?.type === "text" && text.text ? [{ ...part, text: text.text }] : []
|
||||
})
|
||||
return [...projected, ...comments]
|
||||
}
|
||||
const deleteMessageParts = (
|
||||
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||
messageID: string,
|
||||
@@ -249,6 +253,18 @@ export function createServerSession(
|
||||
at: {} as Record<string, number | undefined>,
|
||||
})
|
||||
|
||||
const indexProjectedMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
const projected = projectMessageSource(message)
|
||||
const projectedIDs = new Set(projected.map((item) => item.id))
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
|
||||
)
|
||||
}
|
||||
|
||||
const remember = (session: SessionInfo) => {
|
||||
setData("info", session.id, reconcile(session))
|
||||
infoSeen.delete(session.id)
|
||||
@@ -260,7 +276,7 @@ export function createServerSession(
|
||||
...inflight.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...echoes.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -336,6 +352,65 @@ export function createServerSession(
|
||||
return { session, root }
|
||||
}
|
||||
|
||||
const clearOptimistic = (sessionID: string, messageID?: string) => {
|
||||
if (!messageID) {
|
||||
optimistic.delete(sessionID)
|
||||
return
|
||||
}
|
||||
const items = optimistic.get(sessionID)
|
||||
if (!items) return
|
||||
items.delete(messageID)
|
||||
if (items.size === 0) optimistic.delete(sessionID)
|
||||
}
|
||||
|
||||
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((part) => part.id !== partID)
|
||||
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||
}
|
||||
|
||||
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((value) => value.id !== part.id)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], [part]),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const confirmed = new Set(confirmedParts.map((part) => part.id))
|
||||
const parts = item.parts.filter((part) => !confirmed.has(part.id))
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
||||
const load = messageLoads.get(sessionID)
|
||||
if (!load) return
|
||||
@@ -373,6 +448,14 @@ export function createServerSession(
|
||||
const messages = data.message[sessionID]
|
||||
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.optimisticParts) {
|
||||
load.removedMessages.delete(messageID)
|
||||
load.clearedMessageParts.add(messageID)
|
||||
load.touchedMessages.add(messageID)
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
}
|
||||
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
|
||||
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
|
||||
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
|
||||
@@ -403,9 +486,7 @@ export function createServerSession(
|
||||
sessionIDs.forEach((sessionID) => {
|
||||
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
|
||||
generations.delete(sessionID)
|
||||
echoes.delete(sessionID)
|
||||
messageSnapshots.delete(sessionID)
|
||||
settledInputs.delete(sessionID)
|
||||
clearOptimistic(sessionID)
|
||||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
@@ -440,7 +521,7 @@ export function createServerSession(
|
||||
...inflight.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...echoes.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -475,7 +556,9 @@ export function createServerSession(
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
session: normalized.messages.sort(compareMessages),
|
||||
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
source,
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
projectSource: true,
|
||||
@@ -515,10 +598,9 @@ export function createServerSession(
|
||||
) => {
|
||||
for (const item of items) {
|
||||
if (!messageIDs.has(item.id)) continue
|
||||
const fetched = present(
|
||||
item.id,
|
||||
load?.clearedMessageParts.has(item.id) ? [] : item.part.filter((part) => !SKIP_PARTS.has(part.type)),
|
||||
)
|
||||
const fetched = load?.clearedMessageParts.has(item.id)
|
||||
? []
|
||||
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
||||
const pending = pendingParts.get(sessionID)?.get(item.id)
|
||||
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
||||
@@ -569,56 +651,47 @@ export function createServerSession(
|
||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||
cleanupOrphans: boolean,
|
||||
) => {
|
||||
if (page.sourceMode === "latest")
|
||||
messageSnapshots.set(sessionID, new Set((page.source ?? []).map((message) => message.id)))
|
||||
page.source?.forEach((message) => releaseEcho(sessionID, message.id))
|
||||
const source = page.source
|
||||
? (() => {
|
||||
const incoming = new Map(page.source.map((message) => [message.id, message]))
|
||||
const existing = data.session_message[sessionID] ?? []
|
||||
const boundary = Math.min(...page.source.map((message) => message.time.created))
|
||||
const inbox = new Set(data.input[sessionID] ?? [])
|
||||
const current = existing.filter(
|
||||
(message) =>
|
||||
!incoming.has(message.id) &&
|
||||
!inbox.has(message.id) &&
|
||||
(page.sourceMode === "older" ||
|
||||
load?.touchedSource.has(message.id) ||
|
||||
(!page.complete && message.time.created < boundary)),
|
||||
)
|
||||
// message.list never returns admitted-but-undelivered inbox entries; keep them after the
|
||||
// fetched history until a delivered or cancelled event resolves them.
|
||||
const admitted = existing.filter((message) => !incoming.has(message.id) && inbox.has(message.id))
|
||||
const combined =
|
||||
page.sourceMode === "older"
|
||||
? [...page.source, ...current, ...admitted]
|
||||
: [...current, ...page.source, ...admitted]
|
||||
const live = new Map(existing.map((message) => [message.id, message]))
|
||||
return combined.map((message) =>
|
||||
load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message,
|
||||
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
|
||||
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
|
||||
)
|
||||
})()
|
||||
: undefined
|
||||
const merged =
|
||||
const projected =
|
||||
page.projectSource && source
|
||||
? (() => {
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
...page,
|
||||
session: normalized.messages.sort(compareMessages),
|
||||
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
}
|
||||
})()
|
||||
: page
|
||||
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
merged.observed.forEach((item) => {
|
||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||
})
|
||||
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
|
||||
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||
touched: touchedMessages,
|
||||
retained: load?.retainedMessages,
|
||||
removed: load?.removedMessages,
|
||||
preserveUnfetched: (message) =>
|
||||
echoes.get(sessionID)?.has(message.id) === true ||
|
||||
preserveUnfetched === true ||
|
||||
(typeof preserveUnfetched === "function" && preserveUnfetched(message)),
|
||||
preserveUnfetched,
|
||||
compare: compareMessages,
|
||||
})
|
||||
batch(() => {
|
||||
@@ -650,6 +723,7 @@ export function createServerSession(
|
||||
deltaParts: new Map(),
|
||||
carriedDeltaParts: new Map(),
|
||||
removedParts: new Map(),
|
||||
optimisticParts: new Map(),
|
||||
orphanParents: new Set(),
|
||||
clearedMessageParts: new Set(),
|
||||
touchedSource: new Set(),
|
||||
@@ -670,7 +744,11 @@ export function createServerSession(
|
||||
const users = new Set([
|
||||
...page.session.filter((message) => message.role === "user").map((message) => message.id),
|
||||
...(data.message[sessionID] ?? [])
|
||||
.filter((message) => message.role === "user" && load.touchedMessages.has(message.id))
|
||||
.filter((message) => {
|
||||
if (message.role !== "user") return false
|
||||
const item = optimistic.get(sessionID)?.get(message.id)
|
||||
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
|
||||
})
|
||||
.map((message) => message.id),
|
||||
])
|
||||
const parentIDs = [
|
||||
@@ -815,12 +893,12 @@ export function createServerSession(
|
||||
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
||||
}
|
||||
for (const messageID of touched) {
|
||||
const next = present(messageID, normalized.parts.get(messageID) ?? [])
|
||||
const next = normalized.parts.get(messageID) ?? []
|
||||
const nextIDs = new Set(next.map((part) => part.id))
|
||||
for (const part of next) {
|
||||
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
|
||||
}
|
||||
for (const part of [...(data.part[messageID] ?? [])]) {
|
||||
for (const part of data.part[messageID] ?? []) {
|
||||
if (nextIDs.has(part.id)) continue
|
||||
apply({
|
||||
type: "message.part.removed",
|
||||
@@ -848,67 +926,6 @@ export function createServerSession(
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const removeEcho = (sessionID: string, messageID: string) => {
|
||||
if (!releaseEcho(sessionID, messageID)) return false
|
||||
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
|
||||
const load = messageLoads.get(sessionID)
|
||||
load?.touchedMessages.add(messageID)
|
||||
load?.removedMessages.add(messageID)
|
||||
load?.clearedMessageParts.add(messageID)
|
||||
batch(() => {
|
||||
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== messageID))
|
||||
setData("input", sessionID, (items) => items?.filter((id) => id !== messageID))
|
||||
setData("message", sessionID, (messages) => messages?.filter((message) => message.id !== messageID))
|
||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const confirmInbox = (item: SessionInboxInfo) => {
|
||||
if (!confirmEcho(item.sessionID, item.id)) return false
|
||||
v2.confirm(item)
|
||||
pendingRevision.set(item.sessionID, (pendingRevision.get(item.sessionID) ?? 0) + 1)
|
||||
const current = data.pending[item.sessionID] ?? []
|
||||
const index = current.findIndex((entry) => entry.id === item.id)
|
||||
if (index < 0) setData("pending", item.sessionID, [...current, item])
|
||||
if (index >= 0) setData("pending", item.sessionID, index, reconcile(item))
|
||||
return true
|
||||
}
|
||||
|
||||
const reconcileInbox = (sessionID: string) => {
|
||||
const pending = new Set((data.pending[sessionID] ?? []).map((item) => item.id))
|
||||
const fetched = messageSnapshots.get(sessionID) ?? new Set<string>()
|
||||
const removed = [...(settledInputs.get(sessionID) ?? [])].filter(
|
||||
(messageID) => !pending.has(messageID) && !fetched.has(messageID),
|
||||
)
|
||||
settledInputs.delete(sessionID)
|
||||
if (removed.length) {
|
||||
const ids = new Set(removed)
|
||||
const source = data.session_message[sessionID] ?? []
|
||||
projectV2({
|
||||
sessionID,
|
||||
messages: source.filter((message) => !ids.has(message.id)),
|
||||
touched: [],
|
||||
removed: source.filter((message) => ids.has(message.id)).map((message) => message.id),
|
||||
})
|
||||
}
|
||||
|
||||
const messages = echoes.get(sessionID)
|
||||
if (!messages) return
|
||||
const projected = new Set((data.session_message[sessionID] ?? []).map((message) => message.id))
|
||||
for (const [messageID, state] of messages) {
|
||||
if (projected.has(messageID)) {
|
||||
releaseEcho(sessionID, messageID)
|
||||
continue
|
||||
}
|
||||
if (pending.has(messageID)) {
|
||||
confirmEcho(sessionID, messageID)
|
||||
continue
|
||||
}
|
||||
if (state === "admitted") removeEcho(sessionID, messageID)
|
||||
}
|
||||
}
|
||||
|
||||
const applyV2 = (event: OpenCodeEvent) => {
|
||||
if (event.type === "form.created") {
|
||||
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
|
||||
@@ -932,9 +949,6 @@ export function createServerSession(
|
||||
}
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.inbox.enqueued" || event.type === "session.inbox.delivered")
|
||||
releaseEcho(sessionID, event.data.inboxID)
|
||||
if (event.type === "session.inbox.cancelled") removeEcho(sessionID, event.data.inboxID)
|
||||
if (
|
||||
event.type === "session.inbox.enqueued" ||
|
||||
event.type === "session.inbox.delivery.changed" ||
|
||||
@@ -946,10 +960,11 @@ export function createServerSession(
|
||||
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
|
||||
if (event.type === "session.inbox.enqueued") {
|
||||
const current = data.pending[sessionID] ?? []
|
||||
const item = { id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }
|
||||
const index = current.findIndex((entry) => entry.id === event.data.inboxID)
|
||||
if (index < 0) setData("pending", sessionID, [...current, item])
|
||||
if (index >= 0) setData("pending", sessionID, index, reconcile(item))
|
||||
if (!current.some((item) => item.id === event.data.inboxID))
|
||||
setData("pending", sessionID, [
|
||||
...current,
|
||||
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
|
||||
])
|
||||
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
|
||||
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
|
||||
}
|
||||
@@ -1086,9 +1101,16 @@ export function createServerSession(
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = (event.properties as { info: Message }).info
|
||||
indexProjectedMessage(info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
const items = optimistic.get(info.sessionID)
|
||||
const item = items?.get(info.id)
|
||||
if (items && item) {
|
||||
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
|
||||
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
|
||||
}
|
||||
const orphans = orphanParts.get(info.sessionID)
|
||||
orphans?.delete(info.id)
|
||||
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
||||
@@ -1101,18 +1123,13 @@ export function createServerSession(
|
||||
return
|
||||
}
|
||||
const result = Binary.search(messages, messageKey(info), messageKey)
|
||||
if (result.found) {
|
||||
setData("message", info.sessionID, result.index, reconcile(info))
|
||||
return
|
||||
}
|
||||
// Delivery rewrites time.created, changing the sort key; reposition instead of duplicating.
|
||||
setData("message", info.sessionID, (value = []) => {
|
||||
const next = value.slice()
|
||||
const moved = next.findIndex((message) => message.id === info.id)
|
||||
if (moved >= 0) next.splice(moved, 1)
|
||||
next.splice(moved >= 0 && moved < result.index ? result.index - 1 : result.index, 0, info)
|
||||
return next
|
||||
})
|
||||
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
|
||||
if (!result.found)
|
||||
setData("message", info.sessionID, (value = []) => {
|
||||
const next = value.slice()
|
||||
next.splice(result.index, 0, info)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
case "message.removed": {
|
||||
@@ -1127,11 +1144,13 @@ export function createServerSession(
|
||||
load?.deltaParts.delete(props.messageID)
|
||||
load?.carriedDeltaParts.delete(props.messageID)
|
||||
load?.removedParts.delete(props.messageID)
|
||||
load?.optimisticParts.delete(props.messageID)
|
||||
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
||||
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
||||
removedMessagesForSession.add(props.messageID)
|
||||
removedMessages.set(props.sessionID, removedMessagesForSession)
|
||||
clearOptimistic(props.sessionID, props.messageID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[props.sessionID]
|
||||
@@ -1177,8 +1196,12 @@ export function createServerSession(
|
||||
pending?.delete(part.id)
|
||||
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
||||
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
||||
const optimistic = load?.optimisticParts.get(part.messageID)
|
||||
optimistic?.delete(part.id)
|
||||
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
|
||||
deltaBases.delete(part.id)
|
||||
trackPartChange(part.sessionID, part.messageID, part.id)
|
||||
confirmOptimisticPart(part.sessionID, part.messageID, part)
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => void delete draft[part.id]),
|
||||
@@ -1188,9 +1211,14 @@ export function createServerSession(
|
||||
setData("part", part.messageID, [part])
|
||||
return
|
||||
}
|
||||
const index = parts.findIndex((item) => item.id === part.id)
|
||||
if (index >= 0) setData("part", part.messageID, index, reconcile(part))
|
||||
if (index < 0) setData("part", part.messageID, (value = []) => [...value, part])
|
||||
const result = Binary.search(parts, part.id, (item) => item.id)
|
||||
if (result.found) setData("part", part.messageID, result.index, reconcile(part))
|
||||
if (!result.found)
|
||||
setData("part", part.messageID, (value = []) => {
|
||||
const next = value.slice()
|
||||
next.splice(result.index, 0, part)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
case "message.part.removed": {
|
||||
@@ -1212,16 +1240,20 @@ export function createServerSession(
|
||||
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
load.removedParts.set(props.messageID, parts)
|
||||
const optimistic = load.optimisticParts.get(props.messageID)
|
||||
optimistic?.delete(props.partID)
|
||||
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
|
||||
}
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[props.partID]
|
||||
deltaBases.delete(props.partID)
|
||||
const parts = draft.part[props.messageID]
|
||||
if (!parts) return
|
||||
const index = parts.findIndex((part) => part.id === props.partID)
|
||||
if (index >= 0) parts.splice(index, 1)
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
if (result.found) parts.splice(result.index, 1)
|
||||
if (parts.length === 0) delete draft.part[props.messageID]
|
||||
}),
|
||||
)
|
||||
@@ -1237,8 +1269,8 @@ export function createServerSession(
|
||||
}
|
||||
const parts = data.part[props.messageID]
|
||||
if (!parts) return
|
||||
const index = parts.findIndex((part) => part.id === props.partID)
|
||||
if (index < 0) return
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
if (!result.found) return
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
if (load) {
|
||||
@@ -1250,7 +1282,7 @@ export function createServerSession(
|
||||
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
||||
}
|
||||
const field = props.field as keyof (typeof parts)[number]
|
||||
const current = parts[index]?.[field]
|
||||
const current = parts[result.index]?.[field]
|
||||
if (!deltaBases.has(props.partID) && typeof current === "string")
|
||||
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
||||
setData(
|
||||
@@ -1263,7 +1295,7 @@ export function createServerSession(
|
||||
props.messageID,
|
||||
produce((draft) => {
|
||||
if (!draft) return
|
||||
const part = draft[index]
|
||||
const part = draft[result.index]
|
||||
const field = props.field as keyof typeof part
|
||||
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
|
||||
}),
|
||||
@@ -1322,30 +1354,25 @@ export function createServerSession(
|
||||
while (true) {
|
||||
const pendingAt = pendingRevision.get(sessionID) ?? 0
|
||||
const formAt = formRevision.get(sessionID) ?? 0
|
||||
const previous = new Set(data.input[sessionID] ?? [])
|
||||
const result = await load()
|
||||
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
|
||||
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
|
||||
if (pendingStable) {
|
||||
const current = new Set(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id))
|
||||
const settled = settledInputs.get(sessionID) ?? new Set<string>()
|
||||
previous.forEach((messageID) => {
|
||||
if (!current.has(messageID)) settled.add(messageID)
|
||||
})
|
||||
if (settled.size) settledInputs.set(sessionID, settled)
|
||||
result.pending.forEach(v2.confirm)
|
||||
setData("pending", sessionID, reconcile(result.pending))
|
||||
setData("input", sessionID, reconcile([...current]))
|
||||
setData(
|
||||
"input",
|
||||
sessionID,
|
||||
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
)
|
||||
}
|
||||
if (formStable) setData("form", sessionID, reconcile(result.forms))
|
||||
if (pendingStable && formStable) return
|
||||
}
|
||||
},
|
||||
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
|
||||
const sessions = [...pinned.keys()]
|
||||
return Promise.all(
|
||||
sessions.flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
|
||||
).then(() => sessions.forEach(reconcileInbox))
|
||||
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
|
||||
).then(() => undefined)
|
||||
},
|
||||
invalidate() {
|
||||
invalidationRevision += 1
|
||||
@@ -1363,72 +1390,68 @@ export function createServerSession(
|
||||
fresh(sessionID: string, ttl: number) {
|
||||
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
|
||||
},
|
||||
inbox: {
|
||||
echo(input: PromptEcho) {
|
||||
const created = Date.now()
|
||||
const files = input.files?.map((file) => ({
|
||||
data: "",
|
||||
mime: file.mime,
|
||||
source: { type: "uri" as const, uri: file.uri },
|
||||
name: file.name,
|
||||
mention: file.mention,
|
||||
}))
|
||||
const item: SessionInboxInfo = {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: created,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: input.text, files, agents: input.agents },
|
||||
optimistic: {
|
||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
||||
const parts = input.parts
|
||||
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const load = messageLoads.get(input.sessionID)
|
||||
if (load?.clearedMessageParts.has(input.message.id)) {
|
||||
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
|
||||
parts.forEach((part) => touched.add(part.id))
|
||||
load.touchedParts.set(input.message.id, touched)
|
||||
}
|
||||
const projected = normalizeSessionMessages(input.sessionID, [
|
||||
{ id: `${input.messageID}:agent`, type: "agent-switched", agent: input.agent, time: { created } },
|
||||
{
|
||||
id: `${input.messageID}:model`,
|
||||
type: "model-switched",
|
||||
model: {
|
||||
id: input.model.modelID,
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
},
|
||||
time: { created },
|
||||
},
|
||||
{
|
||||
id: input.messageID,
|
||||
type: "user",
|
||||
text: input.displayText,
|
||||
files,
|
||||
agents: input.agents,
|
||||
time: { created },
|
||||
},
|
||||
])
|
||||
const message = projected.messages[0]!
|
||||
const comments: Part[] = input.comments.map((comment, index) => ({
|
||||
id: `${input.messageID}:comment:${index}`,
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
type: "text",
|
||||
text: formatCommentNote(comment),
|
||||
synthetic: true,
|
||||
metadata: createCommentMetadata(comment),
|
||||
}))
|
||||
const parts = [...(projected.parts.get(input.messageID) ?? []), ...comments]
|
||||
removedMessages.get(input.sessionID)?.delete(input.messageID)
|
||||
markEcho(input.sessionID, input.messageID)
|
||||
pendingRevision.set(input.sessionID, (pendingRevision.get(input.sessionID) ?? 0) + 1)
|
||||
batch(() => {
|
||||
setData("pending", input.sessionID, (items = []) => [...items.filter((entry) => entry.id !== item.id), item])
|
||||
if (!data.input[input.sessionID]?.includes(input.messageID))
|
||||
setData("input", input.sessionID, [...(data.input[input.sessionID] ?? []), input.messageID])
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [message]).sort(compareMessages))
|
||||
setData("part", input.messageID, parts)
|
||||
})
|
||||
if (load) {
|
||||
load.removedMessages.delete(input.message.id)
|
||||
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
|
||||
}
|
||||
const items = optimistic.get(input.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(input.sessionID)
|
||||
removedMessagesForSession?.delete(input.message.id)
|
||||
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
|
||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
indexProjectedMessage(input.message)
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => {
|
||||
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
|
||||
delete draft[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
setData("part", input.message.id, parts)
|
||||
},
|
||||
confirm: confirmInbox,
|
||||
reconcile: reconcileInbox,
|
||||
clearEcho(input: { sessionID: string; messageID: string }) {
|
||||
if (echoes.get(input.sessionID)?.get(input.messageID) !== "sending") return false
|
||||
return removeEcho(input.sessionID, input.messageID)
|
||||
remove(input: { sessionID: string; messageID: string }) {
|
||||
const item = optimistic.get(input.sessionID)?.get(input.messageID)
|
||||
if (!item) return
|
||||
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
|
||||
clearOptimistic(input.sessionID, input.messageID)
|
||||
if (item.confirmedMessage) {
|
||||
const partIDs = new Set(item.parts.map((part) => part.id))
|
||||
setData(
|
||||
produce((draft) => {
|
||||
for (const part of item.parts) {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
const parts = draft.part[input.messageID]
|
||||
if (!parts) return
|
||||
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
|
||||
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
const projectedIDs = new Set(projectMessageSource(item.message).map((message) => message.id))
|
||||
setData("session_message", input.sessionID, (messages) =>
|
||||
messages?.filter((message) => !projectedIDs.has(message.id)),
|
||||
)
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
},
|
||||
async todo(sessionID: string, request?: { force?: boolean }) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpResourceCatalogInput,
|
||||
OpenCodeEvent,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
@@ -16,13 +15,11 @@ import {
|
||||
loadMcpResourcesQuery,
|
||||
reconcileActiveSessionStatuses,
|
||||
seedActiveSessionStatuses,
|
||||
sessionListEventDirectories,
|
||||
shouldRefreshWorkspaceSessions,
|
||||
} from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
|
||||
@@ -217,23 +214,6 @@ describe("workspace session inventory", () => {
|
||||
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
|
||||
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
|
||||
})
|
||||
|
||||
test("invalidates both locations when a session moves", () => {
|
||||
const event = adaptServerEvent({
|
||||
id: "evt_moved",
|
||||
created: 1,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||
location: { directory: "/source" },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
location: { directory: "/destination" },
|
||||
projectID: "project_2",
|
||||
},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
|
||||
|
||||
expect(sessionListEventDirectories(event)).toEqual(["/source", "/destination"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("canDisposeDirectory", () => {
|
||||
|
||||
@@ -88,12 +88,6 @@ const SESSION_LIST_EVENTS = new Set([
|
||||
"session.usage.updated",
|
||||
])
|
||||
|
||||
export function sessionListEventDirectories(event: ServerEvent) {
|
||||
if (!SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) return []
|
||||
const destination = event.current?.type === "session.moved" ? event.current.data.location.directory : undefined
|
||||
return [...new Set([event.current?.location?.directory, destination].filter((item): item is string => !!item))]
|
||||
}
|
||||
|
||||
type McpListApi = {
|
||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||
}
|
||||
@@ -237,10 +231,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return { pending, forms }
|
||||
})
|
||||
}
|
||||
const hydrateSession = async (sessionID: string) => {
|
||||
await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
||||
session.inbox.reconcile(sessionID)
|
||||
}
|
||||
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [
|
||||
@@ -560,11 +551,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
type: "session.updated",
|
||||
properties: { sessionID: info.id, info },
|
||||
})
|
||||
const markSessionListsChanged = (event: ServerEvent) => {
|
||||
sessionListEventDirectories(event).forEach((directory) => {
|
||||
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
|
||||
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
|
||||
const key = directoryKey(directory)
|
||||
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
||||
})
|
||||
}
|
||||
if (!previousDirectory || previousDirectory === directory) return
|
||||
const key = directoryKey(previousDirectory)
|
||||
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
||||
}
|
||||
const toDirectoryEvent = (event: ServerEvent) => {
|
||||
if (event.current?.type === "session.created") return
|
||||
@@ -575,10 +569,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const directory = event.current?.location?.directory
|
||||
const eventType: string = event.type
|
||||
markSessionListsChanged(event)
|
||||
const previousDirectory =
|
||||
event.current?.type === "session.moved"
|
||||
? session.get(event.current.data.sessionID)?.location.directory
|
||||
: undefined
|
||||
markSessionListChanged(event, directory, previousDirectory)
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (event.current?.type === "session.moved") {
|
||||
@@ -630,9 +629,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
catalog.handleEvent({ type: eventType, directory })
|
||||
connection.handleEvent({ type: eventType })
|
||||
connection.handleEvent({ type: eventType, directory })
|
||||
|
||||
if (!directory) {
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
@@ -645,7 +644,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = directoryKey(directory)
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
|
||||
@@ -42,7 +42,7 @@ test("invalidates global and active catalogs after connection", async () => {
|
||||
load: async () => {},
|
||||
})
|
||||
|
||||
catalog.handleEvent({ type: "server.connected" })
|
||||
catalog.handleEvent({ type: "server.connected", directory: "global" })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { pathKey, type PathKey } from "@/utils/path-key"
|
||||
|
||||
type CatalogEvent = {
|
||||
type: string
|
||||
directory?: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export function createCatalogSync(input: {
|
||||
@@ -24,7 +24,7 @@ export function createCatalogSync(input: {
|
||||
event.type === "integration.updated" ||
|
||||
event.type === "integration.connection.updated"
|
||||
) {
|
||||
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
|
||||
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
|
||||
connected: () => calls.push("connected"),
|
||||
})
|
||||
|
||||
connection.handleEvent({ type: "server.connected" })
|
||||
connection.handleEvent({ type: "server.connected", directory: "global" })
|
||||
expect(calls).toContain("connected")
|
||||
connection.handleEvent({ type: "server.connected", directory: "/repo" })
|
||||
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
|
||||
setStatus("connected")
|
||||
return dispose
|
||||
})
|
||||
|
||||
@@ -12,8 +12,8 @@ export function createConnectionSync(input: {
|
||||
})
|
||||
|
||||
let connectedOnce = false
|
||||
function handleEvent(event: { type: string }) {
|
||||
if (event.type !== "server.connected") return
|
||||
function handleEvent(event: { type: string; directory: string }) {
|
||||
if (event.directory !== "global" || event.type !== "server.connected") return
|
||||
input.connected({ reconnect: connectedOnce })
|
||||
connectedOnce = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
|
||||
|
||||
type Text = Extract<Part, { type: "text" }>
|
||||
|
||||
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created },
|
||||
agent: "assistant",
|
||||
model: { providerID: "openai", modelID: "gpt" },
|
||||
})
|
||||
|
||||
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
|
||||
id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: id,
|
||||
})
|
||||
|
||||
describe("sync optimistic reducers", () => {
|
||||
test("applyOptimisticAdd inserts by creation time", () => {
|
||||
const sessionID = "ses_1"
|
||||
const draft = {
|
||||
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
|
||||
part: {} as Record<string, Part[] | undefined>,
|
||||
}
|
||||
|
||||
applyOptimisticAdd(draft, {
|
||||
sessionID,
|
||||
message: userMessage("msg_a", sessionID, 2),
|
||||
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
|
||||
})
|
||||
|
||||
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
|
||||
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
||||
})
|
||||
|
||||
test("applyOptimisticRemove removes message and part entries", () => {
|
||||
const sessionID = "ses_1"
|
||||
const draft = {
|
||||
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
|
||||
part: {
|
||||
msg_1: [textPart("prt_1", sessionID, "msg_1")],
|
||||
msg_2: [textPart("prt_2", sessionID, "msg_2")],
|
||||
} as Record<string, Part[] | undefined>,
|
||||
}
|
||||
|
||||
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
|
||||
|
||||
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
|
||||
expect(draft.part.msg_1).toBeUndefined()
|
||||
expect(draft.part.msg_2).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
|
||||
const sessionID = "ses_1"
|
||||
const page = mergeOptimisticPage(
|
||||
{
|
||||
session: [userMessage("msg_z", sessionID, 1)],
|
||||
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
|
||||
complete: true,
|
||||
},
|
||||
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
|
||||
)
|
||||
|
||||
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
|
||||
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
|
||||
expect(page.confirmed).toEqual([])
|
||||
expect(page.complete).toBe(true)
|
||||
})
|
||||
|
||||
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
|
||||
const sessionID = "ses_1"
|
||||
const page = mergeOptimisticPage(
|
||||
{
|
||||
session: [userMessage("msg_z", sessionID, 1)],
|
||||
part: [],
|
||||
complete: true,
|
||||
},
|
||||
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
|
||||
)
|
||||
|
||||
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
|
||||
})
|
||||
|
||||
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
|
||||
const sessionID = "ses_1"
|
||||
const page = mergeOptimisticPage(
|
||||
{
|
||||
session: [userMessage("msg_2", sessionID)],
|
||||
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
|
||||
complete: true,
|
||||
},
|
||||
[
|
||||
{
|
||||
message: userMessage("msg_2", sessionID),
|
||||
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
||||
expect(page.confirmed).toEqual([])
|
||||
})
|
||||
|
||||
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
|
||||
const sessionID = "ses_1"
|
||||
const page = mergeOptimisticPage(
|
||||
{
|
||||
session: [userMessage("msg_2", sessionID)],
|
||||
part: [
|
||||
{
|
||||
id: "msg_2",
|
||||
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
|
||||
},
|
||||
],
|
||||
complete: true,
|
||||
},
|
||||
[
|
||||
{
|
||||
message: userMessage("msg_2", sessionID),
|
||||
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(page.confirmed).toEqual(["msg_2"])
|
||||
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
|
||||
{ id: "prt_1", type: "text", text: "server" },
|
||||
{ id: "prt_2", type: "text", text: "prt_2" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,114 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { messageKey } from "@/utils/session-message"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
}
|
||||
|
||||
type OptimisticAddInput = {
|
||||
sessionID: string
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type OptimisticRemoveInput = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
}
|
||||
|
||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return sortParts(want)
|
||||
const next = [...parts]
|
||||
let changed = false
|
||||
for (const part of want) {
|
||||
const result = Binary.search(next, part.id, (item) => item.id)
|
||||
if (result.found) continue
|
||||
next.splice(result.index, 0, part)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return parts
|
||||
return next
|
||||
}
|
||||
|
||||
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
|
||||
const confirmed: string[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, messageKey(item.message), messageKey)
|
||||
const found = result.found
|
||||
if (!found) session.splice(result.index, 0, item.message)
|
||||
|
||||
const current = part.get(item.message.id)
|
||||
if (found && hasParts(current, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
|
||||
part.set(item.message.id, mergeParts(current, item.parts))
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: page.cursor,
|
||||
complete: page.complete,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
|
||||
confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageKey(input.message), messageKey)
|
||||
messages.splice(result.index, 0, input.message)
|
||||
} else {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
draft.part[input.message.id] = sortParts(input.parts)
|
||||
}
|
||||
|
||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const index = messages.findIndex((message) => message.id === input.messageID)
|
||||
if (index >= 0) messages.splice(index, 1)
|
||||
}
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
|
||||
export const useSync = () => {
|
||||
const serverSync = useServerSync()
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQueryOptions } from "@/context/server-sync"
|
||||
import { Iterable, pipe } from "effect"
|
||||
import { type Accessor } from "solid-js"
|
||||
import { emptyProviderCatalog } from "./provider-catalog"
|
||||
import { useIntegrations } from "./use-integrations"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
@@ -24,7 +23,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
const dir = directory()
|
||||
return queryOpts.providers(dir ? pathKey(dir) : null)
|
||||
})
|
||||
const integrations = useIntegrations(directory)
|
||||
|
||||
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
|
||||
|
||||
@@ -32,22 +30,13 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
ready: () => providersQuery.isSuccess,
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
// V2 servers list only available providers, so the connectable catalog
|
||||
// comes from the integration list, with the provider catalog as fallback.
|
||||
popular: () => {
|
||||
const catalog = integrations
|
||||
.list()
|
||||
.filter((integration) => popularProviderSet.has(integration.id))
|
||||
.map((integration) => ({ id: integration.id, name: integration.name }))
|
||||
const seen = new Set(catalog.map((integration) => integration.id))
|
||||
return pipe(
|
||||
popular: () =>
|
||||
pipe(
|
||||
providers().all,
|
||||
Iterable.map(([, p]) => p),
|
||||
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
|
||||
Iterable.map((p) => ({ id: p.id, name: p.name })),
|
||||
(v) => [...catalog, ...v],
|
||||
)
|
||||
},
|
||||
Iterable.filter((p) => popularProviderSet.has(p.id)),
|
||||
(v) => Array.from(v),
|
||||
),
|
||||
connected: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return pipe(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
normalizeNewSessionWorktree,
|
||||
resolveNewSessionBranch,
|
||||
resolveNewSessionGit,
|
||||
resolveNewSessionWorktree,
|
||||
} from "./new-session-workspace-controller"
|
||||
|
||||
@@ -48,10 +47,4 @@ describe("new session workspace selection", () => {
|
||||
)
|
||||
expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
})
|
||||
|
||||
test("uses location VCS state when the project inventory is stale", () => {
|
||||
expect(resolveNewSessionGit({ branch: "dev" })).toBe(true)
|
||||
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
|
||||
expect(resolveNewSessionGit({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,10 +39,6 @@ export function resolveNewSessionBranch(input: {
|
||||
return input.worktreeBranch(input.worktree) ?? input.local
|
||||
}
|
||||
|
||||
export function resolveNewSessionGit(input: { projectVcs?: string; branch?: string }) {
|
||||
return input.projectVcs === "git" || input.branch !== undefined
|
||||
}
|
||||
|
||||
export function createNewSessionWorkspaceController(input: {
|
||||
selected: () => string | undefined
|
||||
setSelected: (worktree: string | undefined) => void
|
||||
@@ -53,10 +49,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const settings = useSettings()
|
||||
const localVcs = createMemo(() => serverSync.child(sdk().directory)[0].vcs)
|
||||
const visible = createMemo(() =>
|
||||
resolveNewSessionGit({ projectVcs: sync().project?.vcs, branch: localVcs()?.branch }),
|
||||
)
|
||||
const visible = createMemo(() => sync().project?.vcs === "git")
|
||||
const selected = createMemo(() => {
|
||||
const project = sync().project
|
||||
const worktree = input.selected()
|
||||
@@ -117,7 +110,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const project = sync().project
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
git: visible,
|
||||
git: () => sync().project?.vcs === "git",
|
||||
openAll: input.onViewAll,
|
||||
},
|
||||
bar: {
|
||||
|
||||
@@ -4,7 +4,7 @@ export type UpdaterState =
|
||||
| { status: "disabled" }
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "downloading"; version: string }
|
||||
| { status: "downloading"; version: string; percent?: number }
|
||||
| { status: "ready"; version: string }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "installing"; version: string }
|
||||
|
||||
@@ -22,10 +22,7 @@ function blobUrl(id: string, blob: Blob) {
|
||||
}
|
||||
|
||||
async function blobID(blob: Blob) {
|
||||
const bytes = crypto.subtle
|
||||
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
|
||||
: crypto.getRandomValues(new Uint8Array(16))
|
||||
const id = Array.from(bytes)
|
||||
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
return id
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# CLI and TUI development guide
|
||||
# V2 CLI and TUI development guide
|
||||
|
||||
- Use `@opencode-ai/client` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
|
||||
## Migration context
|
||||
|
||||
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
|
||||
- Preserve established TUI behavior unless the task intentionally changes it.
|
||||
- Load the `opencode-dev` skill before interactively running, debugging, or verifying opencode's V2 CLI, TUI, or server.
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.restart,
|
||||
Effect.fn("cli.service.restart")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
const transport = yield* Service.ensure(options)
|
||||
process.stdout.write(transport.url + EOL)
|
||||
|
||||
@@ -3,10 +3,13 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.stop,
|
||||
Effect.fn("cli.service.stop")(function* () {
|
||||
yield* Service.stop(yield* ServiceConfig.options())
|
||||
const options = yield* ServiceConfig.options()
|
||||
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -56,12 +56,22 @@ function managedService(options: EnsureOptions) {
|
||||
reconnect: () => Service.ensure(reconnectOptions),
|
||||
restart: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
yield* Service.ensure(reconnectOptions)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const shutdownPersistentPty = Effect.fn("cli.server-connection.shutdown-persistent-pty")(function* (
|
||||
options: EnsureOptions,
|
||||
) {
|
||||
const endpoint = yield* Service.discover({ ...options, version: undefined })
|
||||
if (!endpoint) return
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
yield* Effect.tryPromise(() => client["server.persistentPty"].shutdown())
|
||||
})
|
||||
|
||||
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
|
||||
if (mismatch === "replace") return yield* Service.ensure(options)
|
||||
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("debug config command", () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "file", path: path.join(project, "opencode.json") },
|
||||
]
|
||||
let requested: URL | undefined
|
||||
const authorization: Array<string | null> = []
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import type { Command } from "@opencode-ai/schema/command"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import type { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode-ai/schema/reference"
|
||||
import type { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
@@ -1435,44 +1436,249 @@ export interface PtyApi<E = never> {
|
||||
readonly remove: PtyRemoveOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint21_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
|
||||
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
|
||||
export type Endpoint21_0Output = ReadonlyArray<{
|
||||
readonly id: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}>
|
||||
export type ServerPersistentPtyGroupListOperation<E = never> = () => Effect.Effect<Endpoint21_0Output, E>
|
||||
|
||||
export type Endpoint21_1Input = {
|
||||
readonly items?:
|
||||
| ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
| undefined
|
||||
}
|
||||
export type Endpoint21_1Output = {
|
||||
readonly id: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}
|
||||
export type ServerPersistentPtyGroupCreateOperation<E = never> = (
|
||||
input?: Endpoint21_1Input,
|
||||
) => Effect.Effect<Endpoint21_1Output, E>
|
||||
|
||||
export type Endpoint21_2Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
|
||||
export type Endpoint21_2Output = {
|
||||
readonly id: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}
|
||||
export type ServerPersistentPtyGroupGetOperation<E = never> = (
|
||||
input: Endpoint21_2Input,
|
||||
) => Effect.Effect<Endpoint21_2Output, E>
|
||||
|
||||
export type Endpoint21_3Input = {
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}
|
||||
export type Endpoint21_3Output = {
|
||||
readonly id: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}
|
||||
export type ServerPersistentPtyGroupSetOperation<E = never> = (
|
||||
input: Endpoint21_3Input,
|
||||
) => Effect.Effect<Endpoint21_3Output, E>
|
||||
|
||||
export type Endpoint21_4Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
|
||||
export type Endpoint21_4Output = void
|
||||
export type ServerPersistentPtyGroupRemoveOperation<E = never> = (
|
||||
input: Endpoint21_4Input,
|
||||
) => Effect.Effect<Endpoint21_4Output, E>
|
||||
|
||||
export type Endpoint21_5Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
|
||||
export type Endpoint21_5Output = ReadonlyArray<{
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}>
|
||||
export type ServerPersistentPtyListOperation<E = never> = (
|
||||
input: Endpoint21_5Input,
|
||||
) => Effect.Effect<Endpoint21_5Output, E>
|
||||
|
||||
export type Endpoint21_6Input = {
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number } | undefined
|
||||
}
|
||||
export type Endpoint21_6Output = {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type ServerPersistentPtyCreateOperation<E = never> = (
|
||||
input: Endpoint21_6Input,
|
||||
) => Effect.Effect<Endpoint21_6Output, E>
|
||||
|
||||
export type Endpoint21_7Output = void
|
||||
export type ServerPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<Endpoint21_7Output, E>
|
||||
|
||||
export type Endpoint21_8Input = { readonly ptyID: Pty.ID }
|
||||
export type Endpoint21_8Output = {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type ServerPersistentPtyGetOperation<E = never> = (
|
||||
input: Endpoint21_8Input,
|
||||
) => Effect.Effect<Endpoint21_8Output, E>
|
||||
|
||||
export type Endpoint21_9Input = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly attachmentID?: string | undefined
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
}
|
||||
export type Endpoint21_9Output = {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type ServerPersistentPtyUpdateOperation<E = never> = (
|
||||
input: Endpoint21_9Input,
|
||||
) => Effect.Effect<Endpoint21_9Output, E>
|
||||
|
||||
export type Endpoint21_10Input = { readonly ptyID: Pty.ID }
|
||||
export type Endpoint21_10Output = {
|
||||
readonly info: {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
readonly text: string
|
||||
readonly checkpoint: globalThis.Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
export type ServerPersistentPtySnapshotOperation<E = never> = (
|
||||
input: Endpoint21_10Input,
|
||||
) => Effect.Effect<Endpoint21_10Output, E>
|
||||
|
||||
export type Endpoint21_11Input = { readonly ptyID: Pty.ID }
|
||||
export type Endpoint21_11Output = void
|
||||
export type ServerPersistentPtyRemoveOperation<E = never> = (
|
||||
input: Endpoint21_11Input,
|
||||
) => Effect.Effect<Endpoint21_11Output, E>
|
||||
|
||||
export type Endpoint21_12Input = { readonly ptyID: Pty.ID }
|
||||
export type Endpoint21_12Output = PtyTicket.ConnectToken
|
||||
export type ServerPersistentPtyConnectTokenOperation<E = never> = (
|
||||
input: Endpoint21_12Input,
|
||||
) => Effect.Effect<Endpoint21_12Output, E>
|
||||
|
||||
export type Endpoint21_13Input = { readonly ptyID: Pty.ID }
|
||||
export type Endpoint21_13Output = boolean
|
||||
export type ServerPersistentPtyConnectOperation<E = never> = (
|
||||
input: Endpoint21_13Input,
|
||||
) => Effect.Effect<Endpoint21_13Output, E>
|
||||
|
||||
export interface ServerPersistentPtyApi<E = never> {
|
||||
readonly group: {
|
||||
readonly list: ServerPersistentPtyGroupListOperation<E>
|
||||
readonly create: ServerPersistentPtyGroupCreateOperation<E>
|
||||
readonly get: ServerPersistentPtyGroupGetOperation<E>
|
||||
readonly set: ServerPersistentPtyGroupSetOperation<E>
|
||||
readonly remove: ServerPersistentPtyGroupRemoveOperation<E>
|
||||
}
|
||||
readonly list: ServerPersistentPtyListOperation<E>
|
||||
readonly create: ServerPersistentPtyCreateOperation<E>
|
||||
readonly shutdown: ServerPersistentPtyShutdownOperation<E>
|
||||
readonly get: ServerPersistentPtyGetOperation<E>
|
||||
readonly update: ServerPersistentPtyUpdateOperation<E>
|
||||
readonly snapshot: ServerPersistentPtySnapshotOperation<E>
|
||||
readonly remove: ServerPersistentPtyRemoveOperation<E>
|
||||
readonly connectToken: ServerPersistentPtyConnectTokenOperation<E>
|
||||
readonly connect: ServerPersistentPtyConnectOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint22_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
|
||||
export type ShellListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
|
||||
|
||||
export type Endpoint22_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly command: string
|
||||
readonly cwd?: string | undefined
|
||||
readonly timeout: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
}
|
||||
export type Endpoint21_1Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||
export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effect.Effect<Endpoint21_1Output, E>
|
||||
export type Endpoint22_1Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||
export type ShellCreateOperation<E = never> = (input: Endpoint22_1Input) => Effect.Effect<Endpoint22_1Output, E>
|
||||
|
||||
export type Endpoint21_2Input = {
|
||||
export type Endpoint22_2Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
|
||||
export type Endpoint22_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||
export type ShellGetOperation<E = never> = (input: Endpoint22_2Input) => Effect.Effect<Endpoint22_2Output, E>
|
||||
|
||||
export type Endpoint21_3Input = {
|
||||
export type Endpoint22_3Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly timeout: number
|
||||
}
|
||||
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||
export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Effect.Effect<Endpoint21_3Output, E>
|
||||
export type Endpoint22_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||
export type ShellTimeoutOperation<E = never> = (input: Endpoint22_3Input) => Effect.Effect<Endpoint22_3Output, E>
|
||||
|
||||
export type Endpoint21_4Input = {
|
||||
export type Endpoint22_4Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly cursor?: number | undefined
|
||||
readonly limit?: number | undefined
|
||||
}
|
||||
export type Endpoint21_4Output = {
|
||||
export type Endpoint22_4Output = {
|
||||
readonly location: Location.Info
|
||||
readonly data: {
|
||||
readonly output: string
|
||||
@@ -1481,14 +1687,14 @@ export type Endpoint21_4Output = {
|
||||
readonly truncated: boolean
|
||||
}
|
||||
}
|
||||
export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effect.Effect<Endpoint21_4Output, E>
|
||||
export type ShellOutputOperation<E = never> = (input: Endpoint22_4Input) => Effect.Effect<Endpoint22_4Output, E>
|
||||
|
||||
export type Endpoint21_5Input = {
|
||||
export type Endpoint22_5Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint21_5Output = void
|
||||
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
|
||||
export type Endpoint22_5Output = void
|
||||
export type ShellRemoveOperation<E = never> = (input: Endpoint22_5Input) => Effect.Effect<Endpoint22_5Output, E>
|
||||
|
||||
export interface ShellApi<E = never> {
|
||||
readonly list: ShellListOperation<E>
|
||||
@@ -1499,41 +1705,41 @@ export interface ShellApi<E = never> {
|
||||
readonly remove: ShellRemoveOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint22_0Input = {
|
||||
export type Endpoint23_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
|
||||
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||
|
||||
export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint23_0Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint23_0Output = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||
export type Endpoint24_0Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_0Output = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
|
||||
export type Endpoint23_1Input = {
|
||||
export type Endpoint24_1Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type Endpoint23_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
|
||||
export type Endpoint24_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
|
||||
export type Endpoint23_2Input = {
|
||||
export type Endpoint24_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type Endpoint23_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E>
|
||||
export type Endpoint24_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
|
||||
export type Endpoint23_3Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint23_3Output = void
|
||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E>
|
||||
export type Endpoint24_3Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_3Output = void
|
||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
readonly list: WorktreeListOperation<E>
|
||||
@@ -1542,25 +1748,25 @@ export interface WorktreeApi<E = never> {
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint24_0Input = {
|
||||
export type Endpoint25_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||
|
||||
export type Endpoint24_1Input = {
|
||||
export type Endpoint25_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
export type Endpoint25_2Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
@@ -1568,20 +1774,20 @@ export interface VcsApi<E = never> {
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
|
||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
|
||||
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||
|
||||
export type Endpoint25_1Input = {
|
||||
export type Endpoint26_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_1Output = void
|
||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
export type Endpoint26_1Output = void
|
||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
||||
|
||||
export interface DebugApi<E = never> {
|
||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint26_0Output =
|
||||
export type Endpoint27_0Output =
|
||||
| { readonly status: "required" | "completed" }
|
||||
| {
|
||||
readonly status: "running"
|
||||
@@ -1592,36 +1798,36 @@ export type Endpoint26_0Output =
|
||||
}
|
||||
}
|
||||
| { readonly status: "error"; readonly error: string }
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
|
||||
|
||||
export interface MigrationApi<E = never> {
|
||||
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint27_0Input = {
|
||||
export type Endpoint28_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
||||
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||
|
||||
export type Endpoint27_1Input = {
|
||||
export type Endpoint28_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly providerID?: WebSearch.ID | undefined
|
||||
}
|
||||
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
||||
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
|
||||
|
||||
export interface WebsearchApi<E = never> {
|
||||
readonly providers: WebsearchProvidersOperation<E>
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint28_0Input = {
|
||||
export type Endpoint29_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint28_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||
|
||||
export interface ConfigApi<E = never> {
|
||||
readonly get: ConfigGetOperation<E>
|
||||
@@ -1649,6 +1855,7 @@ export interface AppApi<E = never> {
|
||||
readonly skill: SkillApi<E>
|
||||
readonly event: EventApi<E>
|
||||
readonly pty: PtyApi<E>
|
||||
readonly "server.persistentPty": ServerPersistentPtyApi<E>
|
||||
readonly shell: ShellApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly worktree: WorktreeApi<E>
|
||||
|
||||
@@ -186,7 +186,6 @@ import type {
|
||||
Endpoint20_3Output,
|
||||
Endpoint20_4Input,
|
||||
Endpoint20_4Output,
|
||||
Endpoint21_0Input,
|
||||
Endpoint21_0Output,
|
||||
Endpoint21_1Input,
|
||||
Endpoint21_1Output,
|
||||
@@ -198,32 +197,59 @@ import type {
|
||||
Endpoint21_4Output,
|
||||
Endpoint21_5Input,
|
||||
Endpoint21_5Output,
|
||||
Endpoint21_6Input,
|
||||
Endpoint21_6Output,
|
||||
Endpoint21_7Output,
|
||||
Endpoint21_8Input,
|
||||
Endpoint21_8Output,
|
||||
Endpoint21_9Input,
|
||||
Endpoint21_9Output,
|
||||
Endpoint21_10Input,
|
||||
Endpoint21_10Output,
|
||||
Endpoint21_11Input,
|
||||
Endpoint21_11Output,
|
||||
Endpoint21_12Input,
|
||||
Endpoint21_12Output,
|
||||
Endpoint21_13Input,
|
||||
Endpoint21_13Output,
|
||||
Endpoint22_0Input,
|
||||
Endpoint22_0Output,
|
||||
Endpoint22_1Input,
|
||||
Endpoint22_1Output,
|
||||
Endpoint22_2Input,
|
||||
Endpoint22_2Output,
|
||||
Endpoint22_3Input,
|
||||
Endpoint22_3Output,
|
||||
Endpoint22_4Input,
|
||||
Endpoint22_4Output,
|
||||
Endpoint22_5Input,
|
||||
Endpoint22_5Output,
|
||||
Endpoint23_0Input,
|
||||
Endpoint23_0Output,
|
||||
Endpoint23_1Input,
|
||||
Endpoint23_1Output,
|
||||
Endpoint23_2Input,
|
||||
Endpoint23_2Output,
|
||||
Endpoint23_3Input,
|
||||
Endpoint23_3Output,
|
||||
Endpoint24_0Input,
|
||||
Endpoint24_0Output,
|
||||
Endpoint24_1Input,
|
||||
Endpoint24_1Output,
|
||||
Endpoint24_2Input,
|
||||
Endpoint24_2Output,
|
||||
Endpoint24_3Input,
|
||||
Endpoint24_3Output,
|
||||
Endpoint25_0Input,
|
||||
Endpoint25_0Output,
|
||||
Endpoint25_1Input,
|
||||
Endpoint25_1Output,
|
||||
Endpoint25_2Input,
|
||||
Endpoint25_2Output,
|
||||
Endpoint26_0Output,
|
||||
Endpoint27_0Input,
|
||||
Endpoint26_1Input,
|
||||
Endpoint26_1Output,
|
||||
Endpoint27_0Output,
|
||||
Endpoint27_1Input,
|
||||
Endpoint27_1Output,
|
||||
Endpoint28_0Input,
|
||||
Endpoint28_0Output,
|
||||
Endpoint28_1Input,
|
||||
Endpoint28_1Output,
|
||||
Endpoint29_0Input,
|
||||
Endpoint29_0Output,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error.js"
|
||||
|
||||
@@ -1097,28 +1123,158 @@ const adaptGroup20 = (raw: RawClient["server.pty"]) => ({
|
||||
remove: Endpoint20_4(raw),
|
||||
})
|
||||
|
||||
const Endpoint21_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint21_0Input) =>
|
||||
const Endpoint21_0 = (raw: RawClient["server.persistentPty"]) => () =>
|
||||
preserveEffect<Endpoint21_0Output>()(
|
||||
raw["persistentPty.group.list"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_1 = (raw: RawClient["server.persistentPty"]) => (input?: Endpoint21_1Input) =>
|
||||
preserveEffect<Endpoint21_1Output>()(
|
||||
raw["persistentPty.group.create"]({ payload: { items: input?.["items"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_2 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_2Input) =>
|
||||
preserveEffect<Endpoint21_2Output>()(
|
||||
raw["persistentPty.group.get"]({ params: { groupID: input["groupID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_3 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_3Input) =>
|
||||
preserveEffect<Endpoint21_3Output>()(
|
||||
raw["persistentPty.group.set"]({ params: { groupID: input["groupID"] }, payload: { items: input["items"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_4 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_4Input) =>
|
||||
preserveEffect<Endpoint21_4Output>()(
|
||||
raw["persistentPty.group.remove"]({ params: { groupID: input["groupID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint21_5 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_5Input) =>
|
||||
preserveEffect<Endpoint21_5Output>()(
|
||||
raw["persistentPty.list"]({ params: { groupID: input["groupID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_6 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_6Input) =>
|
||||
preserveEffect<Endpoint21_6Output>()(
|
||||
raw["persistentPty.create"]({
|
||||
params: { groupID: input["groupID"] },
|
||||
payload: {
|
||||
command: input["command"],
|
||||
args: input["args"],
|
||||
cwd: input["cwd"],
|
||||
title: input["title"],
|
||||
env: input["env"],
|
||||
size: input["size"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_7 = (raw: RawClient["server.persistentPty"]) => () =>
|
||||
preserveEffect<Endpoint21_7Output>()(raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const Endpoint21_8 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_8Input) =>
|
||||
preserveEffect<Endpoint21_8Output>()(
|
||||
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_9 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_9Input) =>
|
||||
preserveEffect<Endpoint21_9Output>()(
|
||||
raw["persistentPty.update"]({
|
||||
params: { ptyID: input["ptyID"] },
|
||||
payload: { attachmentID: input["attachmentID"], size: input["size"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_10 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_10Input) =>
|
||||
preserveEffect<Endpoint21_10Output>()(
|
||||
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_11 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_11Input) =>
|
||||
preserveEffect<Endpoint21_11Output>()(
|
||||
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint21_12 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_12Input) =>
|
||||
preserveEffect<Endpoint21_12Output>()(
|
||||
raw["persistentPty.connectToken"]({ params: { ptyID: input["ptyID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_13 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_13Input) =>
|
||||
preserveEffect<Endpoint21_13Output>()(
|
||||
raw["persistentPty.connect"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup21 = (raw: RawClient["server.persistentPty"]) => ({
|
||||
group: {
|
||||
list: Endpoint21_0(raw),
|
||||
create: Endpoint21_1(raw),
|
||||
get: Endpoint21_2(raw),
|
||||
set: Endpoint21_3(raw),
|
||||
remove: Endpoint21_4(raw),
|
||||
},
|
||||
list: Endpoint21_5(raw),
|
||||
create: Endpoint21_6(raw),
|
||||
shutdown: Endpoint21_7(raw),
|
||||
get: Endpoint21_8(raw),
|
||||
update: Endpoint21_9(raw),
|
||||
snapshot: Endpoint21_10(raw),
|
||||
remove: Endpoint21_11(raw),
|
||||
connectToken: Endpoint21_12(raw),
|
||||
connect: Endpoint21_13(raw),
|
||||
})
|
||||
|
||||
const Endpoint22_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint22_0Input) =>
|
||||
preserveEffect<Endpoint22_0Output>()(
|
||||
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint21_1 = (raw: RawClient["server.shell"]) => (input: Endpoint21_1Input) =>
|
||||
preserveEffect<Endpoint21_1Output>()(
|
||||
const Endpoint22_1 = (raw: RawClient["server.shell"]) => (input: Endpoint22_1Input) =>
|
||||
preserveEffect<Endpoint22_1Output>()(
|
||||
raw["shell.create"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint21_2 = (raw: RawClient["server.shell"]) => (input: Endpoint21_2Input) =>
|
||||
preserveEffect<Endpoint21_2Output>()(
|
||||
const Endpoint22_2 = (raw: RawClient["server.shell"]) => (input: Endpoint22_2Input) =>
|
||||
preserveEffect<Endpoint22_2Output>()(
|
||||
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint21_3 = (raw: RawClient["server.shell"]) => (input: Endpoint21_3Input) =>
|
||||
preserveEffect<Endpoint21_3Output>()(
|
||||
const Endpoint22_3 = (raw: RawClient["server.shell"]) => (input: Endpoint22_3Input) =>
|
||||
preserveEffect<Endpoint22_3Output>()(
|
||||
raw["shell.timeout"]({
|
||||
params: { id: input["id"] },
|
||||
query: { location: input["location"] },
|
||||
@@ -1126,134 +1282,134 @@ const Endpoint21_3 = (raw: RawClient["server.shell"]) => (input: Endpoint21_3Inp
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) =>
|
||||
preserveEffect<Endpoint21_4Output>()(
|
||||
const Endpoint22_4 = (raw: RawClient["server.shell"]) => (input: Endpoint22_4Input) =>
|
||||
preserveEffect<Endpoint22_4Output>()(
|
||||
raw["shell.output"]({
|
||||
params: { id: input["id"] },
|
||||
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint21_5 = (raw: RawClient["server.shell"]) => (input: Endpoint21_5Input) =>
|
||||
preserveEffect<Endpoint21_5Output>()(
|
||||
const Endpoint22_5 = (raw: RawClient["server.shell"]) => (input: Endpoint22_5Input) =>
|
||||
preserveEffect<Endpoint22_5Output>()(
|
||||
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
|
||||
list: Endpoint21_0(raw),
|
||||
create: Endpoint21_1(raw),
|
||||
get: Endpoint21_2(raw),
|
||||
timeout: Endpoint21_3(raw),
|
||||
output: Endpoint21_4(raw),
|
||||
remove: Endpoint21_5(raw),
|
||||
const adaptGroup22 = (raw: RawClient["server.shell"]) => ({
|
||||
list: Endpoint22_0(raw),
|
||||
create: Endpoint22_1(raw),
|
||||
get: Endpoint22_2(raw),
|
||||
timeout: Endpoint22_3(raw),
|
||||
output: Endpoint22_4(raw),
|
||||
remove: Endpoint22_5(raw),
|
||||
})
|
||||
|
||||
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
|
||||
preserveEffect<Endpoint22_0Output>()(
|
||||
const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
|
||||
preserveEffect<Endpoint23_0Output>()(
|
||||
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
|
||||
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
|
||||
|
||||
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
|
||||
preserveEffect<Endpoint23_0Output>()(
|
||||
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
|
||||
preserveEffect<Endpoint23_1Output>()(
|
||||
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["worktree.create"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
|
||||
preserveEffect<Endpoint23_2Output>()(
|
||||
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["worktree.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
|
||||
preserveEffect<Endpoint23_3Output>()(
|
||||
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
|
||||
preserveEffect<Endpoint24_3Output>()(
|
||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint23_0(raw),
|
||||
create: Endpoint23_1(raw),
|
||||
remove: Endpoint23_2(raw),
|
||||
refresh: Endpoint23_3(raw),
|
||||
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint24_0(raw),
|
||||
create: Endpoint24_1(raw),
|
||||
remove: Endpoint24_2(raw),
|
||||
refresh: Endpoint24_3(raw),
|
||||
})
|
||||
|
||||
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||
preserveEffect<Endpoint25_0Output>()(
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
|
||||
preserveEffect<Endpoint25_2Output>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint24_0(raw),
|
||||
status: Endpoint24_1(raw),
|
||||
diff: Endpoint24_2(raw),
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint25_0(raw),
|
||||
status: Endpoint25_1(raw),
|
||||
diff: Endpoint25_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
|
||||
preserveEffect<Endpoint26_1Output>()(
|
||||
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
|
||||
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
})
|
||||
|
||||
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
|
||||
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
|
||||
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
preserveEffect<Endpoint27_0Output>()(
|
||||
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
preserveEffect<Endpoint27_1Output>()(
|
||||
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
|
||||
preserveEffect<Endpoint28_1Output>()(
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint27_0(raw),
|
||||
query: Endpoint27_1(raw),
|
||||
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint28_0(raw),
|
||||
query: Endpoint28_1(raw),
|
||||
})
|
||||
|
||||
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
||||
preserveEffect<Endpoint29_0Output>()(
|
||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
|
||||
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
@@ -1277,14 +1433,15 @@ const adaptClient = (raw: RawClient) => ({
|
||||
skill: adaptGroup18(raw["server.skill"]),
|
||||
event: adaptGroup19(raw["server.event"]),
|
||||
pty: adaptGroup20(raw["server.pty"]),
|
||||
shell: adaptGroup21(raw["server.shell"]),
|
||||
reference: adaptGroup22(raw["server.reference"]),
|
||||
worktree: adaptGroup23(raw["server.worktree"]),
|
||||
vcs: adaptGroup24(raw["server.vcs"]),
|
||||
debug: adaptGroup25(raw["server.debug"]),
|
||||
migration: adaptGroup26(raw["server.migration"]),
|
||||
websearch: adaptGroup27(raw["server.websearch"]),
|
||||
config: adaptGroup28(raw["server.config"]),
|
||||
"server.persistentPty": adaptGroup21(raw["server.persistentPty"]),
|
||||
shell: adaptGroup22(raw["server.shell"]),
|
||||
reference: adaptGroup23(raw["server.reference"]),
|
||||
worktree: adaptGroup24(raw["server.worktree"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
websearch: adaptGroup28(raw["server.websearch"]),
|
||||
config: adaptGroup29(raw["server.config"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -182,6 +182,32 @@ import type {
|
||||
PtyUpdateOutput,
|
||||
PtyRemoveInput,
|
||||
PtyRemoveOutput,
|
||||
ServerPersistentPtyGroupListOutput,
|
||||
ServerPersistentPtyGroupCreateInput,
|
||||
ServerPersistentPtyGroupCreateOutput,
|
||||
ServerPersistentPtyGroupGetInput,
|
||||
ServerPersistentPtyGroupGetOutput,
|
||||
ServerPersistentPtyGroupSetInput,
|
||||
ServerPersistentPtyGroupSetOutput,
|
||||
ServerPersistentPtyGroupRemoveInput,
|
||||
ServerPersistentPtyGroupRemoveOutput,
|
||||
ServerPersistentPtyListInput,
|
||||
ServerPersistentPtyListOutput,
|
||||
ServerPersistentPtyCreateInput,
|
||||
ServerPersistentPtyCreateOutput,
|
||||
ServerPersistentPtyShutdownOutput,
|
||||
ServerPersistentPtyGetInput,
|
||||
ServerPersistentPtyGetOutput,
|
||||
ServerPersistentPtyUpdateInput,
|
||||
ServerPersistentPtyUpdateOutput,
|
||||
ServerPersistentPtySnapshotInput,
|
||||
ServerPersistentPtySnapshotOutput,
|
||||
ServerPersistentPtyRemoveInput,
|
||||
ServerPersistentPtyRemoveOutput,
|
||||
ServerPersistentPtyConnectTokenInput,
|
||||
ServerPersistentPtyConnectTokenOutput,
|
||||
ServerPersistentPtyConnectInput,
|
||||
ServerPersistentPtyConnectOutput,
|
||||
ShellListInput,
|
||||
ShellListOutput,
|
||||
ShellCreateInput,
|
||||
@@ -1571,6 +1597,175 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
"server.persistentPty": {
|
||||
group: {
|
||||
list: (requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGroupListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/pty-group`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
create: (input?: ServerPersistentPtyGroupCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGroupCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/pty-group`,
|
||||
body: { items: input?.["items"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
get: (input: ServerPersistentPtyGroupGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGroupGetOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
set: (input: ServerPersistentPtyGroupSetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGroupSetOutput }>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
|
||||
body: { items: input["items"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
remove: (input: ServerPersistentPtyGroupRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerPersistentPtyGroupRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
list: (input: ServerPersistentPtyListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
create: (input: ServerPersistentPtyCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
|
||||
body: {
|
||||
command: input["command"],
|
||||
args: input["args"],
|
||||
cwd: input["cwd"],
|
||||
title: input["title"],
|
||||
env: input["env"],
|
||||
size: input["size"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
shutdown: (requestOptions?: RequestOptions) =>
|
||||
request<ServerPersistentPtyShutdownOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/persistent-pty/shutdown`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: ServerPersistentPtyGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGetOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
update: (input: ServerPersistentPtyUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyUpdateOutput }>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
|
||||
body: { attachmentID: input["attachmentID"], size: input["size"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
snapshot: (input: ServerPersistentPtySnapshotInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtySnapshotOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/snapshot`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
remove: (input: ServerPersistentPtyRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerPersistentPtyRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
connectToken: (input: ServerPersistentPtyConnectTokenInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyConnectTokenOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect-token`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [403, 404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
connect: (input: ServerPersistentPtyConnectInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerPersistentPtyConnectOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [403, 404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
shell: {
|
||||
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
|
||||
request<ShellListOutput>(
|
||||
@@ -1671,7 +1866,7 @@ export function make(options: ClientOptions) {
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
@@ -1682,7 +1877,7 @@ export function make(options: ClientOptions) {
|
||||
request<WorktreeCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
body: {
|
||||
strategy: input["strategy"],
|
||||
from: input["from"],
|
||||
@@ -1699,7 +1894,7 @@ export function make(options: ClientOptions) {
|
||||
request<WorktreeRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -1711,7 +1906,7 @@ export function make(options: ClientOptions) {
|
||||
request<WorktreeRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -328,6 +328,8 @@ export type FormMetadata1 = { [x: string]: any }
|
||||
|
||||
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
|
||||
|
||||
export type GroupItem = { type: "session"; id: string } | { type: "terminal"; id: string }
|
||||
|
||||
export type SessionStatus =
|
||||
| { type: "idle" }
|
||||
| {
|
||||
@@ -339,6 +341,22 @@ export type SessionStatus =
|
||||
}
|
||||
| { type: "busy" }
|
||||
|
||||
export type PersistentPtyInfo = {
|
||||
id: string
|
||||
title: string
|
||||
command: string
|
||||
args: Array<string>
|
||||
cwd: string
|
||||
status: "running" | "exited"
|
||||
pid: number
|
||||
exitCode?: number
|
||||
groupID: string
|
||||
size: { cols: number; rows: number }
|
||||
output: { head: number; tail: number }
|
||||
}
|
||||
|
||||
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
|
||||
|
||||
export type ShellInfo1 = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
@@ -1475,6 +1493,26 @@ export type FormMultiselectField1 = {
|
||||
default?: Array<string>
|
||||
}
|
||||
|
||||
export type GroupItemAdded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "group.item.added"
|
||||
location?: LocationRef
|
||||
data: { groupID: string; item: GroupItem }
|
||||
}
|
||||
|
||||
export type GroupItemRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "group.item.removed"
|
||||
location?: LocationRef
|
||||
data: { groupID: string; item: GroupItem }
|
||||
}
|
||||
|
||||
export type GroupInfo = { id: string; items: Array<GroupItem> }
|
||||
|
||||
export type SessionStatus2 = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1484,6 +1522,13 @@ export type SessionStatus2 = {
|
||||
data: { sessionID: string; status: SessionStatus }
|
||||
}
|
||||
|
||||
export type PersistentPtySnapshot = {
|
||||
info: PersistentPtyInfo
|
||||
text: string
|
||||
checkpoint: string
|
||||
cursor: { x: number; y: number }
|
||||
}
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type WorktreeList = Array<WorktreeDirectory>
|
||||
@@ -1787,7 +1832,7 @@ export type ConfigEntry =
|
||||
| { repository: string; branch?: string; description?: string; hidden?: boolean }
|
||||
| { path: string; description?: string; hidden?: boolean }
|
||||
}
|
||||
websearch?: false | { provider: "random" | (string & {}) }
|
||||
websearch?: { provider: string }
|
||||
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
@@ -1842,6 +1887,7 @@ export type ConfigEntry =
|
||||
}
|
||||
}
|
||||
| { type: "directory"; path: string }
|
||||
| { type: "file"; path: string }
|
||||
| { type: "agents"; path: string }
|
||||
| { type: "claude"; path: string }
|
||||
|
||||
@@ -2074,6 +2120,8 @@ export type V2Event =
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
| GroupItemAdded
|
||||
| GroupItemRemoved
|
||||
| WebsearchUpdated
|
||||
| SessionStatus2
|
||||
| SessionIdle
|
||||
@@ -2246,6 +2294,10 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
|
||||
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
|
||||
|
||||
export type ForbiddenError = { readonly _tag: "ForbiddenError"; readonly message: string }
|
||||
export const isForbiddenError = (value: unknown): value is ForbiddenError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ForbiddenError"
|
||||
|
||||
export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string }
|
||||
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
|
||||
@@ -5434,6 +5486,133 @@ export type PtyRemoveInput = {
|
||||
|
||||
export type PtyRemoveOutput = void
|
||||
|
||||
export type ServerPersistentPtyGroupListOutput = { data: Array<GroupInfo> }["data"]
|
||||
|
||||
export type ServerPersistentPtyGroupCreateInput = {
|
||||
readonly items?: {
|
||||
readonly items?:
|
||||
| ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
|
||||
>
|
||||
| undefined
|
||||
}["items"]
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyGroupCreateOutput = { data: GroupInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyGroupGetInput = { readonly groupID: { readonly groupID: string }["groupID"] }
|
||||
|
||||
export type ServerPersistentPtyGroupGetOutput = { data: GroupInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyGroupSetInput = {
|
||||
readonly groupID: { readonly groupID: string }["groupID"]
|
||||
readonly items: {
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
|
||||
>
|
||||
}["items"]
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyGroupSetOutput = { data: GroupInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyGroupRemoveInput = { readonly groupID: { readonly groupID: string }["groupID"] }
|
||||
|
||||
export type ServerPersistentPtyGroupRemoveOutput = void
|
||||
|
||||
export type ServerPersistentPtyListInput = { readonly groupID: { readonly groupID: string }["groupID"] }
|
||||
|
||||
export type ServerPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
|
||||
|
||||
export type ServerPersistentPtyCreateInput = {
|
||||
readonly groupID: { readonly groupID: string }["groupID"]
|
||||
readonly command: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["command"]
|
||||
readonly args: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["args"]
|
||||
readonly cwd: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["cwd"]
|
||||
readonly title: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["title"]
|
||||
readonly env: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["env"]
|
||||
readonly size?: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["size"]
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyCreateOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyShutdownOutput = void
|
||||
|
||||
export type ServerPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyUpdateInput = {
|
||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||
readonly attachmentID?: {
|
||||
readonly attachmentID?: string
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
}["attachmentID"]
|
||||
readonly size: {
|
||||
readonly attachmentID?: string
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
}["size"]
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyUpdateOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtySnapshotInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtySnapshotOutput = { data: PersistentPtySnapshot }["data"]
|
||||
|
||||
export type ServerPersistentPtyRemoveInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtyRemoveOutput = void
|
||||
|
||||
export type ServerPersistentPtyConnectTokenInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtyConnectTokenOutput = { data: PtyTicketConnectToken }["data"]
|
||||
|
||||
export type ServerPersistentPtyConnectInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtyConnectOutput = boolean
|
||||
|
||||
export type ShellListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -66,6 +66,7 @@ test("config.get returns ordered config entries for a location", async () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "file" as const, path: "/tmp/project/opencode.json" },
|
||||
]
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
@@ -282,10 +283,10 @@ test("worktree methods use the global project contract", async () => {
|
||||
await client.worktree.refresh({ projectID: "proj_test" })
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/worktree/proj_test"],
|
||||
["POST", "http://localhost:3000/api/worktree/proj_test"],
|
||||
["DELETE", "http://localhost:3000/api/worktree/proj_test"],
|
||||
["POST", "http://localhost:3000/api/worktree/proj_test/refresh"],
|
||||
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({
|
||||
strategy: "git",
|
||||
|
||||
+17
-102
@@ -4,14 +4,13 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory,
|
||||
Document,
|
||||
File,
|
||||
Info,
|
||||
type Entry,
|
||||
Event,
|
||||
@@ -37,8 +36,6 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
||||
export interface Interface {
|
||||
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
||||
readonly entries: () => Effect.Effect<Entry[]>
|
||||
/** Updates the first file-backed configuration document. */
|
||||
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, UpdateError>
|
||||
/**
|
||||
* Streams raw filesystem updates under config roots. Config owns root
|
||||
* topology and watch reconciliation; domain owners filter this feed for the
|
||||
@@ -47,11 +44,6 @@ export interface Interface {
|
||||
readonly changes: () => Stream.Stream<Watcher.Update>
|
||||
}
|
||||
|
||||
export class UpdateError extends Schema.TaggedErrorClass<UpdateError>()("Config.UpdateError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
project: Schema.optional(Schema.Boolean),
|
||||
file: Schema.optional(Schema.String),
|
||||
@@ -78,19 +70,6 @@ export const testLayer = (initial: Entry[] = []) =>
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const service = Test.of({
|
||||
entries: () => Ref.get(entries),
|
||||
update: (update) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.get(entries)
|
||||
const index = current.findIndex((entry) => entry.type === "document" && entry.path !== undefined)
|
||||
if (index === -1)
|
||||
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
||||
const entry = current[index]
|
||||
if (!entry || entry.type !== "document")
|
||||
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
||||
const info = produce(entry.info, update)
|
||||
yield* Ref.set(entries, current.with(index, new Document({ type: "document", path: entry.path, info })))
|
||||
return info
|
||||
}),
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
setEntries: (next) => Ref.set(entries, next),
|
||||
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
|
||||
@@ -112,7 +91,6 @@ export const layer = (options?: Options) =>
|
||||
const wellknown = yield* WellKnown.Service
|
||||
const names = ["opencode.json", "opencode.jsonc"]
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const fileTargets = new Set<AbsolutePath>()
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
@@ -153,7 +131,7 @@ export const layer = (options?: Options) =>
|
||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||
const info = yield* parseInfo(substituted, filepath)
|
||||
if (!info) return
|
||||
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
|
||||
return new Document({ type: "document", path: filepath, info })
|
||||
})
|
||||
|
||||
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
|
||||
@@ -246,18 +224,25 @@ export const layer = (options?: Options) =>
|
||||
const directPaths = discovered
|
||||
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
||||
.toReversed()
|
||||
fileTargets.clear()
|
||||
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
|
||||
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
|
||||
const direct = yield* Effect.forEach(directPaths, (filepath) =>
|
||||
loadFile(filepath).pipe(
|
||||
Effect.map((config) => [
|
||||
...(config ? [config] : []),
|
||||
new File({ type: "file", path: AbsolutePath.make(filepath) }),
|
||||
]),
|
||||
),
|
||||
).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
|
||||
Effect.map((entries) => entries.flat()),
|
||||
)
|
||||
|
||||
const file = options?.file
|
||||
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
|
||||
const explicit = file
|
||||
? yield* loadFile(path.resolve(file)).pipe(
|
||||
Effect.map((config) => (config ? [config] : [])),
|
||||
Effect.map((config) => [
|
||||
...(config ? [config] : []),
|
||||
new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }),
|
||||
]),
|
||||
Effect.orDie,
|
||||
)
|
||||
: []
|
||||
@@ -300,10 +285,7 @@ export const layer = (options?: Options) =>
|
||||
const watched = new Set<string>()
|
||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const files = [
|
||||
...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
|
||||
...fileTargets,
|
||||
]
|
||||
const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
|
||||
const targets = [
|
||||
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
|
||||
...files
|
||||
@@ -326,9 +308,9 @@ export const layer = (options?: Options) =>
|
||||
reloadLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const next = yield* discover()
|
||||
yield* reconcile(next)
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* reconcile(next)
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
@@ -382,54 +364,10 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
yield* reconcile(initial)
|
||||
|
||||
const update = Effect.fn("Config.update")((mutate: (draft: Draft<Info>) => void) =>
|
||||
reloadLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// TODO: Replace entry-order selection with an explicit config scope/target model.
|
||||
const document = configs.find((entry) => entry.type === "document" && entry.path !== undefined)
|
||||
if (!document || document.type !== "document" || !document.path)
|
||||
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
|
||||
const next = yield* Effect.try({
|
||||
try: () => produce(document.info, mutate),
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
const edits = changes(document.info, next)
|
||||
if (!edits.length) return document.info
|
||||
const text = yield* fs
|
||||
.readFileString(document.path)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new UpdateError({ message: `Failed to read config: ${document.path}`, cause }),
|
||||
),
|
||||
)
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
),
|
||||
text,
|
||||
)
|
||||
const info = yield* parseInfo(updated, document.path)
|
||||
if (!info)
|
||||
return yield* Effect.fail(new UpdateError({ message: `Invalid config update: ${document.path}` }))
|
||||
const temporary = document.path + ".tmp"
|
||||
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
|
||||
Effect.andThen(fs.rename(temporary, document.path)),
|
||||
Effect.mapError(
|
||||
(cause) => new UpdateError({ message: `Failed to write config: ${document.path}`, cause }),
|
||||
),
|
||||
)
|
||||
return info
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
entries: Effect.fn("Config.entries")(function* () {
|
||||
return configs
|
||||
}),
|
||||
update,
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
})
|
||||
}),
|
||||
@@ -444,26 +382,3 @@ export function configured(options?: Options) {
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
|
||||
|
||||
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
|
||||
if (Object.is(before, after)) return []
|
||||
if (
|
||||
before !== null &&
|
||||
after !== null &&
|
||||
typeof before === "object" &&
|
||||
typeof after === "object" &&
|
||||
!Array.isArray(before) &&
|
||||
!Array.isArray(after)
|
||||
) {
|
||||
const previous = before as Record<string, unknown>
|
||||
const next = after as Record<string, unknown>
|
||||
return [...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => {
|
||||
if (!(key in next)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!(key in previous)) return [{ path: [...path, key], value: next[key] }]
|
||||
return changes(previous[key], next[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Permission } from "../../permission.js"
|
||||
import type { LocationMutation } from "../../location-mutation.js"
|
||||
import type { ReadTool } from "../../tool/plugin/read.js"
|
||||
import type { EditTool } from "../../tool/plugin/edit.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
|
||||
const legacySources = [
|
||||
{ pattern: "{agent,agents}/**/*.md", primary: false },
|
||||
@@ -211,5 +210,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
}),
|
||||
)
|
||||
if (!info) return
|
||||
return new Document({ type: "document", path: AbsolutePath.make(file.filepath), info })
|
||||
return new Document({ type: "document", path: file.filepath, info })
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.websearch.transform((websearch) => {
|
||||
const selection = Config.latest(loaded.entries, "websearch")
|
||||
if (selection === false) websearch.default.set(false)
|
||||
if (selection) websearch.default.set(selection.provider)
|
||||
const providerID = Config.latest(loaded.entries, "websearch")?.provider
|
||||
if (providerID) websearch.default.set(providerID)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
|
||||
@@ -23,32 +23,6 @@ export type Options = typeof Options.Type
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
|
||||
|
||||
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
|
||||
type Prepared = ReturnType<typeof fuzzysort.prepare>
|
||||
|
||||
function emptyIndex() {
|
||||
return { files: new Map<string, Prepared>(), directories: new Map<string, Prepared>() }
|
||||
}
|
||||
|
||||
function search(index: ReturnType<typeof emptyIndex>, input: FileSystem.FindInput) {
|
||||
const items =
|
||||
input.type === "file"
|
||||
? Array.from(index.files.values())
|
||||
: input.type === "directory"
|
||||
? Array.from(index.directories.values())
|
||||
: [...index.files.values(), ...index.directories.values()]
|
||||
const result = fuzzysort.go(input.query, items, { limit: input.limit ?? 50 })
|
||||
// Targets are owned by the current location index. The only global fuzzysort
|
||||
// state left is its query cache, which must not retain every query forever.
|
||||
fuzzysort.cleanup()
|
||||
return result.map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
return FileSystem.Entry.make({
|
||||
path: RelativePath.make(relative),
|
||||
type,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const ripgrepLayer = Layer.effect(
|
||||
Service,
|
||||
@@ -58,13 +32,12 @@ export const ripgrepLayer = Layer.effect(
|
||||
const scope = yield* Scope.Scope
|
||||
const clock = yield* Clock.Clock
|
||||
const home = Protected.isHome(location.directory)
|
||||
let index = emptyIndex()
|
||||
let index = { files: [] as string[], directories: new Set<string>() }
|
||||
let initialized = false
|
||||
let settledAt = Number.NEGATIVE_INFINITY
|
||||
let refreshing = false
|
||||
const scan = Effect.gen(function* () {
|
||||
const next = emptyIndex()
|
||||
const previous = index
|
||||
const next = { files: [] as string[], directories: new Set<string>() }
|
||||
if (!initialized) index = next
|
||||
yield* ripgrep.find({
|
||||
cwd: location.directory,
|
||||
@@ -73,13 +46,11 @@ export const ripgrepLayer = Layer.effect(
|
||||
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||
onEntry: (entry) =>
|
||||
Effect.sync(() => {
|
||||
next.files.set(entry.path, previous.files.get(entry.path) ?? fuzzysort.prepare(entry.path))
|
||||
next.files.push(entry.path)
|
||||
const parts = entry.path.split("/")
|
||||
parts.slice(0, -1).forEach((_, offset) => {
|
||||
const directory = parts.slice(0, offset + 1).join("/") + path.sep
|
||||
if (!next.directories.has(directory))
|
||||
next.directories.set(directory, previous.directories.get(directory) ?? fuzzysort.prepare(directory))
|
||||
})
|
||||
parts
|
||||
.slice(0, -1)
|
||||
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
|
||||
}),
|
||||
})
|
||||
index = next
|
||||
@@ -103,7 +74,20 @@ export const ripgrepLayer = Layer.effect(
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* refresh
|
||||
return search(index, input)
|
||||
const items =
|
||||
input.type === "file"
|
||||
? index.files
|
||||
: input.type === "directory"
|
||||
? Array.from(index.directories)
|
||||
: [...index.files, ...index.directories]
|
||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
return FileSystem.Entry.make({
|
||||
path: RelativePath.make(relative),
|
||||
type,
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -76,11 +76,9 @@ const layer = Layer.effect(
|
||||
const type =
|
||||
input.kind === "directory"
|
||||
? "Directory"
|
||||
: input.kind === "file"
|
||||
? "File"
|
||||
: (yield* fs
|
||||
.stat(absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
|
||||
: (yield* fs
|
||||
.stat(absolute)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
|
||||
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
|
||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
@@ -50,7 +49,6 @@ import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { ToolOutput } from "./tool-output.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
@@ -112,13 +110,11 @@ export type LocationError = LayerNode.Error<typeof locationServices>
|
||||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
// Structural Equal distinguishes optional-key shape and Windows separator style.
|
||||
// The RcMap caches the raw key before the build callback, so normalize both here.
|
||||
const canonical = (ref: Location.Ref) =>
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
|
||||
workspaceID: ref.workspaceID,
|
||||
})
|
||||
// Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded
|
||||
// payloads omit optional keys) and `{ directory, workspaceID: undefined }` are
|
||||
// different RcMap keys. The RcMap caches by the raw key before the build
|
||||
// callback runs, so canonicalize at the map boundary to the key-present shape.
|
||||
const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID })
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.map(
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PersistentPty } from "./persistent-pty/index.js"
|
||||
export { Group } from "./persistent-pty/group.js"
|
||||
@@ -0,0 +1,103 @@
|
||||
export * as Group from "./group.js"
|
||||
|
||||
import { Group } from "@opencode-ai/schema/group"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { KV } from "../kv.js"
|
||||
|
||||
export const ID = Group.ID
|
||||
export type ID = Group.ID
|
||||
export const Item = Group.Item
|
||||
export type Item = Group.Item
|
||||
export const Info = Group.Info
|
||||
export type Info = Group.Info
|
||||
export const Event = Group.Event
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly create: (items?: ReadonlyArray<Item>) => Effect.Effect<Info>
|
||||
readonly set: (group: Info) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Group") {}
|
||||
|
||||
const key = "group:v1"
|
||||
const Document = Schema.Array(Info)
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const bus = yield* Bus.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const list = Effect.fn("Group.list")(function* () {
|
||||
const value = yield* kv.get(key)
|
||||
return Schema.is(Document)(value) ? value : []
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list,
|
||||
get: Effect.fn("Group.get")(function* (id) {
|
||||
return (yield* list()).find((group) => group.id === id)
|
||||
}),
|
||||
create: Effect.fn("Group.create")(function* (items = []) {
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const group = Info.make({ id: ID.create(), items: Array.from(items) })
|
||||
yield* kv.set(key, (yield* list()).concat(group))
|
||||
return group
|
||||
}),
|
||||
)
|
||||
}),
|
||||
set: Effect.fn("Group.set")(function* (group) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* list()
|
||||
const index = groups.findIndex((item) => item.id === group.id)
|
||||
yield* kv.set(
|
||||
key,
|
||||
index === -1 ? groups.concat(group) : groups.map((item) => (item.id === group.id ? group : item)),
|
||||
)
|
||||
const previous = groups[index]
|
||||
if (!previous) return
|
||||
yield* Effect.forEach(
|
||||
group.items.filter(
|
||||
(item) => !previous.items.some((current) => current.type === item.type && current.id === item.id),
|
||||
),
|
||||
(item) => bus.publish(Event.ItemAdded, { groupID: group.id, item }),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
previous.items.filter(
|
||||
(item) => !group.items.some((next) => next.type === item.type && next.id === item.id),
|
||||
),
|
||||
(item) => bus.publish(Event.ItemRemoved, { groupID: group.id, item }),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
remove: Effect.fn("Group.remove")(function* (id) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* list()
|
||||
const group = groups.find((group) => group.id === id)
|
||||
yield* kv.set(key, groups.filter((group) => group.id !== id))
|
||||
if (!group) return
|
||||
yield* Effect.forEach(
|
||||
group.items,
|
||||
(item) => bus.publish(Event.ItemRemoved, { groupID: id, item }),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node, Bus.node] })
|
||||
@@ -0,0 +1,736 @@
|
||||
export * as PersistentPty from "./index.js"
|
||||
|
||||
import { spawn } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import net from "node:net"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Group } from "./group.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
|
||||
const ProtocolVersion = 4
|
||||
const MaxFrameBytes = 8 * 1024 * 1024
|
||||
|
||||
const Lifecycle = Schema.Union([
|
||||
Schema.Struct({ status: Schema.Literal("running") }),
|
||||
Schema.Struct({ status: Schema.Literal("exited"), exit_code: Schema.NullOr(Schema.Number) }),
|
||||
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String }),
|
||||
])
|
||||
|
||||
const WireTerminal = Schema.Struct({
|
||||
id: Schema.Number,
|
||||
pid: Schema.NullOr(Schema.Number),
|
||||
title: Schema.String,
|
||||
group_id: Schema.String,
|
||||
command: Schema.Array(Schema.String),
|
||||
cwd: Schema.String,
|
||||
cols: Schema.Number,
|
||||
rows: Schema.Number,
|
||||
lifecycle: Lifecycle,
|
||||
output_head: Schema.Number,
|
||||
output_tail: Schema.Number,
|
||||
})
|
||||
|
||||
const Registration = Schema.Struct({
|
||||
instance_id: Schema.String,
|
||||
pid: Schema.Number,
|
||||
protocol: Schema.Number,
|
||||
socket: Schema.String,
|
||||
token: Schema.String,
|
||||
})
|
||||
|
||||
const Response = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("pong"),
|
||||
instance_id: Schema.String,
|
||||
pid: Schema.Number,
|
||||
protocol: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
|
||||
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
|
||||
Schema.Struct({ type: Schema.Literal("ok") }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("snapshot"),
|
||||
terminal: WireTerminal,
|
||||
text: Schema.String,
|
||||
checkpoint_base64: Schema.String,
|
||||
cursor_x: Schema.Number,
|
||||
cursor_y: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("attached"),
|
||||
terminal: WireTerminal,
|
||||
role: Schema.Literals(["controller", "observer"]),
|
||||
generation: Schema.Number,
|
||||
requested_offset: Schema.Number,
|
||||
available_offset: Schema.Number,
|
||||
end_offset: Schema.Number,
|
||||
truncated: Schema.Boolean,
|
||||
replay_base64: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("resized"),
|
||||
cols: Schema.Number,
|
||||
rows: Schema.Number,
|
||||
generation: Schema.Number,
|
||||
checkpoint_base64: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("exited"),
|
||||
exit_code: Schema.NullOr(Schema.Number),
|
||||
final_offset: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("controller_changed"),
|
||||
attachment_id: Schema.NullOr(Schema.String),
|
||||
generation: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }),
|
||||
])
|
||||
|
||||
type WireTerminal = typeof WireTerminal.Type
|
||||
type WireResponse = typeof Response.Type
|
||||
type Registration = typeof Registration.Type
|
||||
|
||||
export type Role = "controller" | "observer"
|
||||
|
||||
export type Info = Pty.Info & {
|
||||
readonly groupID: Group.ID
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
|
||||
export type Snapshot = {
|
||||
readonly info: Info
|
||||
readonly text: string
|
||||
readonly checkpoint: Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| { readonly type: "output"; readonly start: number; readonly end: number; readonly data: Uint8Array }
|
||||
| {
|
||||
readonly type: "resized"
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly generation: number
|
||||
readonly checkpoint: Uint8Array
|
||||
}
|
||||
| { readonly type: "exited"; readonly exitCode?: number; readonly finalOffset: number }
|
||||
| { readonly type: "controller_changed"; readonly attachmentID?: string; readonly generation: number }
|
||||
|
||||
export type Attachment = {
|
||||
readonly info: Info
|
||||
readonly role: Role
|
||||
readonly generation: number
|
||||
readonly replay: {
|
||||
readonly requestedOffset: number
|
||||
readonly availableOffset: number
|
||||
readonly endOffset: number
|
||||
readonly truncated: boolean
|
||||
readonly data: Uint8Array
|
||||
}
|
||||
readonly activate: () => void
|
||||
readonly detach: () => void
|
||||
}
|
||||
|
||||
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()("PersistentPty.UnavailableError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PersistentPty.NotFoundError", {
|
||||
ptyID: Pty.ID,
|
||||
}) {}
|
||||
|
||||
export class GroupNotFoundError extends Schema.TaggedErrorClass<GroupNotFoundError>()(
|
||||
"PersistentPty.GroupNotFoundError",
|
||||
{ groupID: Group.ID },
|
||||
) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (groupID?: Group.ID) => Effect.Effect<Info[], UnavailableError>
|
||||
readonly get: (id: Pty.ID) => Effect.Effect<Info, NotFoundError | UnavailableError>
|
||||
readonly create: (
|
||||
groupID: Group.ID,
|
||||
input: {
|
||||
readonly command: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
) => Effect.Effect<Info, GroupNotFoundError | UnavailableError>
|
||||
readonly write: (
|
||||
id: Pty.ID,
|
||||
data: string,
|
||||
attachmentID?: string,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly resize: (
|
||||
id: Pty.ID,
|
||||
cols: number,
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly control: (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly input: (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
|
||||
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly shutdown: () => Effect.Effect<void, UnavailableError>
|
||||
readonly attach: (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) => Effect.Effect<Attachment, NotFoundError | UnavailableError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
const database = yield* Database.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const client = new Client(runtimeDirectory(databasePath(database.db)))
|
||||
const removing = new Set<Pty.ID>()
|
||||
|
||||
const list = Effect.fn("PersistentPty.list")(function* (groupID?: Group.ID) {
|
||||
const response = yield* optionalRequest(client, { op: "list" })
|
||||
if (!response) return []
|
||||
if (response.type !== "terminals") return yield* unexpected(response)
|
||||
return response.terminals
|
||||
.map(toInfo)
|
||||
.filter((terminal) => groupID === undefined || terminal.groupID === groupID)
|
||||
})
|
||||
|
||||
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
|
||||
const found = (yield* list()).find((terminal) => terminal.id === id)
|
||||
if (!found) return yield* new NotFoundError({ ptyID: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
groupID: Group.ID,
|
||||
input: {
|
||||
readonly command: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
) {
|
||||
const group = yield* groups.get(groupID)
|
||||
if (!group) return yield* new GroupNotFoundError({ groupID })
|
||||
const response = yield* request(client, {
|
||||
op: "create",
|
||||
program: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
group_id: groupID,
|
||||
env: input.env,
|
||||
cols: input.cols ?? 80,
|
||||
rows: input.rows ?? 24,
|
||||
}, true)
|
||||
if (response.type !== "created") return yield* unexpected(response)
|
||||
const terminal = toInfo(response.terminal)
|
||||
yield* groups.set(
|
||||
Group.Info.make({
|
||||
id: group.id,
|
||||
items: group.items.concat({ type: "terminal", id: terminal.id }),
|
||||
}),
|
||||
)
|
||||
return terminal
|
||||
})
|
||||
|
||||
const write = Effect.fn("PersistentPty.write")(function* (
|
||||
id: Pty.ID,
|
||||
data: string,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, {
|
||||
op: "write",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const resize = Effect.fn("PersistentPty.resize")(function* (
|
||||
id: Pty.ID,
|
||||
cols: number,
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, {
|
||||
op: "resize",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const control = Effect.fn("PersistentPty.control")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, {
|
||||
op: "control",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const input = Effect.fn("PersistentPty.input")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, {
|
||||
op: "input",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, { op: "snapshot", id: fromID(id) })
|
||||
if (response.type !== "snapshot") return yield* unexpected(response)
|
||||
return {
|
||||
info: toInfo(response.terminal),
|
||||
text: response.text,
|
||||
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
|
||||
cursor: { x: response.cursor_x, y: response.cursor_y },
|
||||
}
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(client, { op: "terminate", id: fromID(id) })
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
const group = yield* groups.get(terminal.groupID)
|
||||
if (!group) return undefined
|
||||
yield* groups.set(
|
||||
Group.Info.make({
|
||||
id: group.id,
|
||||
items: group.items.filter((item) => item.type !== "terminal" || item.id !== id),
|
||||
}),
|
||||
)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
|
||||
const response = yield* Effect.tryPromise({ try: () => client.shutdown(), catch: unavailable })
|
||||
if (!response) return
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
})
|
||||
|
||||
const removeVisibleExit = (id: Pty.ID) => {
|
||||
if (removing.has(id)) return
|
||||
removing.add(id)
|
||||
runFork(
|
||||
remove(id).pipe(
|
||||
Effect.catchTags({
|
||||
"PersistentPty.NotFoundError": () => Effect.void,
|
||||
"PersistentPty.UnavailableError": (error) =>
|
||||
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => removing.delete(id))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) {
|
||||
yield* get(id)
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
client.subscribe(fromID(id), {
|
||||
...input,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "exited") removeVisibleExit(id)
|
||||
input.onEvent(event)
|
||||
},
|
||||
}),
|
||||
catch: (error) => unavailable(error),
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Group.node, Database.node] })
|
||||
|
||||
class Client {
|
||||
private registration?: Promise<Registration>
|
||||
|
||||
constructor(private readonly directory: string) {}
|
||||
|
||||
request(value: object, start = false): Promise<WireResponse> {
|
||||
return this.connect(start)
|
||||
.then((registration) => oneShot(registration, value))
|
||||
.catch((error) => {
|
||||
if (!(error instanceof ConnectError)) throw error
|
||||
this.registration = undefined
|
||||
if (!start) throw error
|
||||
return this.connect(true).then((registration) => oneShot(registration, value))
|
||||
})
|
||||
}
|
||||
|
||||
requestIfRunning(value: object) {
|
||||
return this.request(value).catch(() => undefined)
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
const response = await this.requestIfRunning({ op: "shutdown" })
|
||||
this.registration = undefined
|
||||
if (!response) return
|
||||
const deadline = Date.now() + 5_000
|
||||
while (Date.now() < deadline) {
|
||||
const running = await discover(this.directory).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
if (!running) return response
|
||||
await setTimeout(50)
|
||||
}
|
||||
throw new Error("opencode-pty did not stop")
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
id: number,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
): Promise<Attachment> {
|
||||
const registration = await this.connect(false)
|
||||
const socket = net.createConnection(registration.socket)
|
||||
const frames = decoder(socket)
|
||||
await connected(socket)
|
||||
socket.write(
|
||||
encode({
|
||||
token: registration.token,
|
||||
request: {
|
||||
op: "subscribe",
|
||||
id,
|
||||
offset: input.cursor,
|
||||
attachment_id: input.attachmentID,
|
||||
role: input.role,
|
||||
takeover: input.takeover ?? false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const initial = await frames.next()
|
||||
if (initial.done) throw new Error("opencode-pty closed before attachment")
|
||||
const response = decode(initial.value)
|
||||
if (response.type === "error") throw new Error(response.message)
|
||||
if (response.type !== "attached") throw new Error(`unexpected opencode-pty response: ${response.type}`)
|
||||
let detached = false
|
||||
const pump = async () => {
|
||||
try {
|
||||
for await (const frame of frames) {
|
||||
if (frame[0] === 0) {
|
||||
if (frame.length < 17) throw new Error("invalid opencode-pty output frame")
|
||||
input.onEvent({
|
||||
type: "output",
|
||||
start: Number(frame.readBigUInt64BE(1)),
|
||||
end: Number(frame.readBigUInt64BE(9)),
|
||||
data: frame.subarray(17),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const event = decode(frame)
|
||||
if (event.type === "resized")
|
||||
input.onEvent({
|
||||
type: "resized",
|
||||
cols: event.cols,
|
||||
rows: event.rows,
|
||||
generation: event.generation,
|
||||
checkpoint: Buffer.from(event.checkpoint_base64, "base64"),
|
||||
})
|
||||
if (event.type === "controller_changed")
|
||||
input.onEvent({
|
||||
type: "controller_changed",
|
||||
attachmentID: event.attachment_id ?? undefined,
|
||||
generation: event.generation,
|
||||
})
|
||||
if (event.type === "exited") {
|
||||
input.onEvent({
|
||||
type: "exited",
|
||||
exitCode: event.exit_code ?? undefined,
|
||||
finalOffset: event.final_offset,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!detached) input.onEnd()
|
||||
}
|
||||
}
|
||||
let activated = false
|
||||
return {
|
||||
info: toInfo(response.terminal),
|
||||
role: response.role,
|
||||
generation: response.generation,
|
||||
replay: {
|
||||
requestedOffset: response.requested_offset,
|
||||
availableOffset: response.available_offset,
|
||||
endOffset: response.end_offset,
|
||||
truncated: response.truncated,
|
||||
data: Buffer.from(response.replay_base64, "base64"),
|
||||
},
|
||||
activate() {
|
||||
if (activated || detached) return
|
||||
activated = true
|
||||
void pump().catch(() => {})
|
||||
},
|
||||
detach() {
|
||||
if (detached) return
|
||||
detached = true
|
||||
socket.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private connect(start: boolean) {
|
||||
this.registration ??= start ? ensure(this.directory) : discover(this.directory)
|
||||
return this.registration.catch((error) => {
|
||||
this.registration = undefined
|
||||
throw error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const request = (client: Client, value: object, start = false) =>
|
||||
Effect.tryPromise({ try: () => client.request(value, start), catch: (error) => unavailable(error) })
|
||||
|
||||
const optionalRequest = (client: Client, value: object) =>
|
||||
Effect.promise(() => client.requestIfRunning(value))
|
||||
|
||||
const unexpected = (response: WireResponse) =>
|
||||
Effect.fail(new UnavailableError({ message: `unexpected opencode-pty response: ${response.type}` }))
|
||||
|
||||
const unavailable = (error: unknown) =>
|
||||
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
|
||||
|
||||
function databasePath(db: Database.Interface["db"]) {
|
||||
const client: unknown = db.$client
|
||||
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
|
||||
return undefined
|
||||
const config = client.config
|
||||
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
|
||||
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
|
||||
return path.resolve(config.filename)
|
||||
}
|
||||
|
||||
const runtimeDirectory = (databasePath?: string) => {
|
||||
const root =
|
||||
process.env.OPENCODE_PTY_RUNTIME_DIR ??
|
||||
(process.env.XDG_RUNTIME_DIR
|
||||
? path.join(process.env.XDG_RUNTIME_DIR, "opencode-pty")
|
||||
: path.join(
|
||||
os.tmpdir(),
|
||||
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
|
||||
))
|
||||
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
|
||||
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
|
||||
}
|
||||
|
||||
const registrationPath = (directory: string) => path.join(directory, "service.json")
|
||||
|
||||
async function ensure(directory: string) {
|
||||
const found = await discover(directory).catch(() => undefined)
|
||||
if (found) return found
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(process.env.OPENCODE_PTY_BIN || "opencode-pty", ["daemon"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: directory },
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
child.once("error", reject)
|
||||
})
|
||||
const deadline = Date.now() + 5_000
|
||||
let last: unknown
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await discover(directory)
|
||||
} catch (error) {
|
||||
last = error
|
||||
await setTimeout(50)
|
||||
}
|
||||
}
|
||||
throw last instanceof Error ? last : new Error("opencode-pty did not become ready")
|
||||
}
|
||||
|
||||
async function discover(directory: string) {
|
||||
const registration = Schema.decodeUnknownSync(Registration)(
|
||||
JSON.parse(await readFile(registrationPath(directory), "utf8")),
|
||||
)
|
||||
if (registration.protocol !== ProtocolVersion) throw new Error("opencode-pty protocol mismatch")
|
||||
const response = await oneShot(registration, { op: "ping" })
|
||||
if (
|
||||
response.type !== "pong" ||
|
||||
response.instance_id !== registration.instance_id ||
|
||||
response.pid !== registration.pid ||
|
||||
response.protocol !== ProtocolVersion
|
||||
)
|
||||
throw new Error("opencode-pty registration mismatch")
|
||||
return registration
|
||||
}
|
||||
|
||||
async function oneShot(registration: Registration, request: object) {
|
||||
const socket = net.createConnection(registration.socket)
|
||||
const frames = decoder(socket)
|
||||
await connected(socket).catch((cause) => {
|
||||
socket.destroy()
|
||||
throw new ConnectError(cause)
|
||||
})
|
||||
socket.write(encode({ token: registration.token, request }))
|
||||
const first = await frames.next()
|
||||
socket.end()
|
||||
if (first.done) throw new Error("opencode-pty closed without response")
|
||||
const response = decode(first.value)
|
||||
if (response.type === "error") throw new Error(response.message)
|
||||
return response
|
||||
}
|
||||
|
||||
class ConnectError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
function connected(socket: net.Socket) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve)
|
||||
socket.once("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function encode(value: unknown) {
|
||||
const payload = Buffer.from(JSON.stringify(value))
|
||||
if (payload.length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
|
||||
const output = Buffer.allocUnsafe(payload.length + 4)
|
||||
output.writeUInt32BE(payload.length)
|
||||
payload.copy(output, 4)
|
||||
return output
|
||||
}
|
||||
|
||||
async function* decoder(socket: net.Socket) {
|
||||
let pending = Buffer.alloc(0)
|
||||
for await (const value of socket) {
|
||||
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
|
||||
pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk])
|
||||
while (pending.length >= 4) {
|
||||
const length = pending.readUInt32BE(0)
|
||||
if (length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
|
||||
if (pending.length < length + 4) break
|
||||
yield pending.subarray(4, length + 4)
|
||||
pending = pending.subarray(length + 4)
|
||||
}
|
||||
}
|
||||
if (pending.length !== 0) throw new Error("opencode-pty truncated frame")
|
||||
}
|
||||
|
||||
function decode(payload: Uint8Array) {
|
||||
return Schema.decodeUnknownSync(Response)(JSON.parse(Buffer.from(payload).toString("utf8")))
|
||||
}
|
||||
|
||||
function toInfo(value: WireTerminal): Info {
|
||||
const status = value.lifecycle.status
|
||||
return {
|
||||
...Pty.Info.make({
|
||||
id: toID(value.id),
|
||||
title: value.title,
|
||||
command: value.command[0] || "",
|
||||
args: value.command.slice(1),
|
||||
cwd: value.cwd,
|
||||
status: status === "running" ? "running" : "exited",
|
||||
pid: value.pid ?? 0,
|
||||
...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}),
|
||||
}),
|
||||
groupID: Group.ID.make(value.group_id),
|
||||
size: { cols: value.cols, rows: value.rows },
|
||||
output: { head: value.output_head, tail: value.output_tail },
|
||||
}
|
||||
}
|
||||
|
||||
function toID(value: number) {
|
||||
return Pty.ID.make(`pty_persistent_${value}`)
|
||||
}
|
||||
|
||||
function fromID(value: Pty.ID) {
|
||||
if (!value.startsWith("pty_persistent_")) throw new Error(`invalid persistent PTY ID: ${value}`)
|
||||
const parsed = Number(value.slice("pty_persistent_".length))
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`invalid persistent PTY ID: ${value}`)
|
||||
return parsed
|
||||
}
|
||||
@@ -332,10 +332,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
}),
|
||||
default: {
|
||||
get: draft.default.get,
|
||||
set: (selection) =>
|
||||
draft.default.set(
|
||||
selection === false || selection === "random" ? selection : WebSearch.ID.make(selection),
|
||||
),
|
||||
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,396 @@
|
||||
export * as PluginPromise from "./promise.js"
|
||||
|
||||
export { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { DateTime, Effect, Scope, Stream } from "effect"
|
||||
import { Tool } from "../tool.js"
|
||||
|
||||
type HostRegistration = { readonly dispose: Effect.Effect<void> }
|
||||
type Registration = { readonly dispose: () => Promise<void> }
|
||||
type PromiseEvent = ReturnType<Context["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
|
||||
type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
|
||||
|
||||
/**
|
||||
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
|
||||
* loader (`Plugin` / `PluginSupervisor`) can run it unchanged.
|
||||
*
|
||||
* Hook registrations created during the async `setup` attach to the plugin's
|
||||
* scope, so unloading the plugin disposes them. The captured fiber context
|
||||
* preserves boot-time batching, so Promise-plugin transforms still coalesce
|
||||
* into one reload per domain.
|
||||
*/
|
||||
export function fromPromise(plugin: Plugin) {
|
||||
return define({
|
||||
id: plugin.id,
|
||||
effect: (host) =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const context = yield* Effect.context<Scope.Scope>()
|
||||
|
||||
// Run a hook registration on the plugin scope and resolve once it is registered.
|
||||
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
|
||||
Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
|
||||
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
|
||||
}))
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect).then(wire)
|
||||
|
||||
const transform =
|
||||
<Draft>(domain: {
|
||||
transform: (callback: (draft: Draft) => void) => Effect.Effect<HostRegistration, never, Scope.Scope>
|
||||
}) =>
|
||||
(callback: (draft: Draft) => void) =>
|
||||
register(
|
||||
domain.transform((draft) => {
|
||||
callback(draft)
|
||||
}),
|
||||
)
|
||||
|
||||
const context2: Context = {
|
||||
app: host.app,
|
||||
options: host.options,
|
||||
agent: {
|
||||
get: (input) => run(host.agent.get({ ...input, agentID: Agent.ID.make(input.agentID) })),
|
||||
list: (input) => run(host.agent.list(input)),
|
||||
transform: transform(host.agent),
|
||||
reload: () => run(host.agent.reload()),
|
||||
},
|
||||
aisdk: {
|
||||
hook: (name, callback) =>
|
||||
register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
catalog: {
|
||||
provider: {
|
||||
list: (input) => run(host.catalog.provider.list(input)),
|
||||
get: (input) =>
|
||||
run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
|
||||
},
|
||||
model: {
|
||||
list: (input) => run(host.catalog.model.list(input)),
|
||||
default: (input) =>
|
||||
run(host.catalog.model.default(input)).then((result) => ({ ...result, data: result.data ?? null })),
|
||||
},
|
||||
transform: transform(host.catalog),
|
||||
reload: () => run(host.catalog.reload()),
|
||||
},
|
||||
command: {
|
||||
list: (input) => run(host.command.list(input)),
|
||||
transform: transform(host.command),
|
||||
reload: () => run(host.command.reload()),
|
||||
},
|
||||
event: {
|
||||
subscribe: () => Stream.toAsyncIterable(host.event.subscribe().pipe(Stream.map(wireEvent))),
|
||||
},
|
||||
integration: {
|
||||
list: (input) => run(host.integration.list(input)),
|
||||
get: (input) =>
|
||||
run(host.integration.get({ ...input, integrationID: Integration.ID.make(input.integrationID) })).then(
|
||||
(result) => ({ ...result, data: result.data ?? null }),
|
||||
),
|
||||
connect: {
|
||||
key: (input) =>
|
||||
run(
|
||||
host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) }),
|
||||
),
|
||||
},
|
||||
oauth: {
|
||||
connect: (input) =>
|
||||
run(
|
||||
host.integration.oauth.connect({
|
||||
...input,
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
methodID: Integration.MethodID.make(input.methodID),
|
||||
}),
|
||||
),
|
||||
status: (input) =>
|
||||
run(
|
||||
host.integration.oauth.status({
|
||||
...input,
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||
}),
|
||||
),
|
||||
complete: (input) =>
|
||||
run(
|
||||
host.integration.oauth.complete({
|
||||
...input,
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||
}),
|
||||
),
|
||||
cancel: (input) =>
|
||||
run(
|
||||
host.integration.oauth.cancel({
|
||||
...input,
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||
}),
|
||||
),
|
||||
},
|
||||
command: {
|
||||
connect: (input) =>
|
||||
run(
|
||||
host.integration.command.connect({
|
||||
...input,
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
methodID: Integration.MethodID.make(input.methodID),
|
||||
}),
|
||||
),
|
||||
status: (input) =>
|
||||
run(
|
||||
host.integration.command.status({
|
||||
...input,
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||
}),
|
||||
),
|
||||
cancel: (input) =>
|
||||
run(
|
||||
host.integration.command.cancel({
|
||||
...input,
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||
}),
|
||||
),
|
||||
},
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.integration.transform((draft) =>
|
||||
callback({
|
||||
list: draft.list,
|
||||
get: draft.get,
|
||||
update: draft.update,
|
||||
remove: draft.remove,
|
||||
method: {
|
||||
list: draft.method.list,
|
||||
update: (input) => {
|
||||
if (!("authorize" in input)) return draft.method.update(input)
|
||||
const refresh = input.refresh
|
||||
draft.method.update({
|
||||
...input,
|
||||
authorize: (answer) =>
|
||||
Effect.promise(() => input.authorize(answer)).pipe(
|
||||
Effect.map((authorization) =>
|
||||
authorization.mode === "auto"
|
||||
? {
|
||||
...authorization,
|
||||
callback: Effect.promise(() => authorization.callback),
|
||||
}
|
||||
: {
|
||||
...authorization,
|
||||
callback: (code) => Effect.promise(() => authorization.callback(code)),
|
||||
},
|
||||
),
|
||||
),
|
||||
refresh:
|
||||
refresh === undefined
|
||||
? undefined
|
||||
: (credential) => Effect.promise(() => refresh(credential)),
|
||||
})
|
||||
},
|
||||
remove: draft.method.remove,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
reload: () => run(host.integration.reload()),
|
||||
connection: {
|
||||
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
|
||||
resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
list: (input) => run(host.plugin.list(input)),
|
||||
},
|
||||
reference: {
|
||||
list: (input) => run(host.reference.list(input)),
|
||||
transform: transform(host.reference),
|
||||
reload: () => run(host.reference.reload()),
|
||||
},
|
||||
skill: {
|
||||
list: (input) => run(host.skill.list(input)),
|
||||
transform: transform(host.skill),
|
||||
reload: () => run(host.skill.reload()),
|
||||
},
|
||||
tool: {
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.tool.transform((draft) =>
|
||||
callback({
|
||||
add: (tool: Info) =>
|
||||
draft.add({
|
||||
...tool,
|
||||
execute: (input, context) => executePromiseTool(tool, input, context),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
hook: (name, callback) =>
|
||||
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
websearch: {
|
||||
providers: (input) => run(host.websearch.providers(input)),
|
||||
query: (input) =>
|
||||
run(
|
||||
host.websearch.query({
|
||||
...input,
|
||||
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
|
||||
}),
|
||||
),
|
||||
reload: () => run(host.websearch.reload()),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
execute: (input) => attempt((signal) => definition.execute(input, { signal })),
|
||||
}),
|
||||
default: draft.default,
|
||||
})
|
||||
}),
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
create: (input) =>
|
||||
run(
|
||||
host.session.create(
|
||||
input === undefined
|
||||
? undefined
|
||||
: {
|
||||
id: input.id == null ? undefined : Session.ID.make(input.id),
|
||||
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
|
||||
model: input.model == null ? undefined : model(input.model),
|
||||
location:
|
||||
input.location == null
|
||||
? undefined
|
||||
: Location.Ref.make({
|
||||
directory: AbsolutePath.make(input.location.directory),
|
||||
workspaceID:
|
||||
input.location.workspaceID === undefined
|
||||
? undefined
|
||||
: Workspace.ID.make(input.location.workspaceID),
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
|
||||
prompt: (input) =>
|
||||
run(
|
||||
host.session.prompt({
|
||||
...input,
|
||||
sessionID: Session.ID.make(input.sessionID),
|
||||
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
|
||||
skills: input.skills?.map((skill) => ({ ...skill, id: Skill.ID.make(skill.id) })),
|
||||
delivery: input.delivery ?? undefined,
|
||||
resume: input.resume ?? undefined,
|
||||
}),
|
||||
),
|
||||
generate: (input) =>
|
||||
run(host.session.generate({ sessionID: Session.ID.make(input.sessionID), prompt: input.prompt })),
|
||||
command: (input) =>
|
||||
run(
|
||||
host.session.command({
|
||||
...input,
|
||||
sessionID: Session.ID.make(input.sessionID),
|
||||
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
|
||||
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
|
||||
model: input.model == null ? undefined : model(input.model),
|
||||
skills: input.skills?.map((skill) => ({ ...skill, id: Skill.ID.make(skill.id) })),
|
||||
arguments: input.arguments ?? undefined,
|
||||
delivery: input.delivery ?? undefined,
|
||||
resume: input.resume ?? undefined,
|
||||
}),
|
||||
),
|
||||
synthetic: (input) =>
|
||||
run(
|
||||
host.session.synthetic({
|
||||
...input,
|
||||
sessionID: Session.ID.make(input.sessionID),
|
||||
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
|
||||
description: input.description ?? undefined,
|
||||
delivery: input.delivery ?? undefined,
|
||||
resume: input.resume ?? undefined,
|
||||
}),
|
||||
),
|
||||
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
|
||||
},
|
||||
shell: {
|
||||
hook: (name, callback) =>
|
||||
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
}
|
||||
|
||||
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
||||
if (!cleanup) return
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
|
||||
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
|
||||
}
|
||||
|
||||
function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) {
|
||||
return Model.Ref.make({
|
||||
id: Model.ID.make(input.id),
|
||||
providerID: Provider.ID.make(input.providerID),
|
||||
variant: input.variant === undefined ? undefined : Model.VariantID.make(input.variant),
|
||||
})
|
||||
}
|
||||
|
||||
type Wire<Value> = unknown extends Value
|
||||
? JsonValue
|
||||
: Value extends string | number | boolean | bigint | symbol | null | undefined
|
||||
? Value
|
||||
: Value extends DateTime.DateTime
|
||||
? number
|
||||
: Value extends readonly [infer Head, ...infer Tail]
|
||||
? [Wire<Head>, ...WireTuple<Tail>]
|
||||
: Value extends ReadonlyArray<infer Item>
|
||||
? Array<Wire<Item>>
|
||||
: Value extends object
|
||||
? { -readonly [Key in keyof Value]: Wire<Value[Key]> }
|
||||
: Value
|
||||
|
||||
type WireTuple<Value extends ReadonlyArray<unknown>> = {
|
||||
-readonly [Key in keyof Value]: Wire<Value[Key]>
|
||||
}
|
||||
|
||||
function wire<Value>(value: Value): Wire<Value>
|
||||
function wire(value: unknown): unknown {
|
||||
if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value)
|
||||
if (Array.isArray(value)) return value.map(wire)
|
||||
if (typeof value !== "object" || value === null) return value
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, wire(item)]))
|
||||
}
|
||||
|
||||
function wireEvent(value: unknown): PromiseEvent
|
||||
function wireEvent(value: unknown): unknown {
|
||||
return wire(value)
|
||||
}
|
||||
|
||||
const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
|
||||
Effect.promise(() =>
|
||||
tool.execute(input, {
|
||||
...context,
|
||||
progress: (update) => Effect.runPromise(context.progress(update)),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SessionHistory } from "./history.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
|
||||
export const layer = Layer.effect(
|
||||
@@ -39,12 +39,7 @@ export const layer = Layer.effect(
|
||||
sessionID: selection.session.id,
|
||||
agent: selection.agent.id,
|
||||
model: model.ref,
|
||||
system: [
|
||||
selection.agent.info.system
|
||||
? selection.agent.info.system
|
||||
: SessionSystemPrompt.make(toolDefinitions.map((tool) => tool.name)),
|
||||
history.initial,
|
||||
]
|
||||
system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
|
||||
@@ -19,7 +19,7 @@ import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
|
||||
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||
@@ -190,10 +190,7 @@ export const layer = Layer.effect(
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
const tools = input.context.tools
|
||||
const system = [
|
||||
agent.info.system ? agent.info.system : SessionSystemPrompt.make(tools.definitions.map((tool) => tool.name)),
|
||||
input.context.initial,
|
||||
]
|
||||
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make)
|
||||
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- /help: Get help with using opencode
|
||||
- To give feedback, users should report the issue at https://github.com/anomalyco/opencode/issues
|
||||
|
||||
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the webfetch tool to gather information to answer the question from opencode docs at https://opencode.ai/v2/docs/
|
||||
|
||||
# Tone and style
|
||||
You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
|
||||
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
||||
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
|
||||
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
|
||||
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
|
||||
<example>
|
||||
user: what is 2+2?
|
||||
assistant: 4
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: is 11 a prime number?
|
||||
assistant: Yes
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what command should I run to list files in the current directory?
|
||||
assistant: ls
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what command should I run to watch files in the current directory?
|
||||
assistant: [use the read tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
|
||||
npm run dev
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what files are in the directory src/?
|
||||
assistant: [uses read and sees foo.c, bar.c, baz.c]
|
||||
user: which file contains the implementation of foo?
|
||||
assistant: src/foo.c
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: write tests for new feature
|
||||
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
|
||||
</example>
|
||||
|
||||
# Proactiveness
|
||||
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
|
||||
1. Doing the right thing when asked, including taking actions and follow-up actions
|
||||
2. Not surprising the user with actions you take without asking
|
||||
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
|
||||
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
|
||||
|
||||
# Following conventions
|
||||
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
|
||||
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
|
||||
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
|
||||
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
|
||||
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
|
||||
|
||||
# Code style
|
||||
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
|
||||
- Implement the solution using all tools available to you
|
||||
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
|
||||
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with the shell tool if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
|
||||
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
|
||||
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple shell tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
|
||||
|
||||
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
|
||||
|
||||
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
|
||||
|
||||
# Code References
|
||||
|
||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
||||
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
@@ -1,14 +0,0 @@
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
|
||||
${OPENCODE_TOOL_GUIDANCE}
|
||||
|
||||
# Communication
|
||||
- Use clear file paths when referring to files.
|
||||
- Keep responses clear and concise, and avoid unnecessary technical jargon.
|
||||
|
||||
# Working in codebases
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
@@ -1,24 +0,0 @@
|
||||
export * as SessionSystemPrompt from "./system-prompt.js"
|
||||
|
||||
import PROMPT from "./runner/prompt/system.txt"
|
||||
|
||||
export function make(tools: string[]) {
|
||||
const instructions: string[] = []
|
||||
if (tools.includes("write")) {
|
||||
instructions.push(
|
||||
"- Use the write tool to create files or completely replace their content. Prefer using the edit tool for targeted changes.",
|
||||
)
|
||||
}
|
||||
if (tools.includes("edit")) {
|
||||
instructions.push(
|
||||
"- Use the edit tool for targeted changes to existing text files. It replaces the exact text in `oldString` with `newString`, and the values must differ. By default, `oldString` must occur exactly once. If it occurs multiple times, include more surrounding context to make it unique or set `replaceAll` to true to replace every occurrence.",
|
||||
)
|
||||
}
|
||||
// if (tools.includes("patch")) {
|
||||
// // instructions.push(...)
|
||||
// }
|
||||
if (tools.includes("read")) {
|
||||
instructions.push("- Prefer using the read tool rather than shell commands like `cat`.")
|
||||
}
|
||||
return PROMPT.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission.js"
|
||||
import DESCRIPTION from "../patch.txt"
|
||||
@@ -46,30 +46,38 @@ export const toModelOutput = (output: Output) =>
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: Target
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
readonly moveTarget?: LocationMutation.Target
|
||||
readonly moveTarget?: Target
|
||||
})
|
||||
|
||||
interface Target {
|
||||
readonly absolute: string
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: {
|
||||
readonly directory: string
|
||||
readonly resource: string
|
||||
}
|
||||
}
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -111,25 +119,26 @@ export const Plugin = {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
const updates = new Map<string, string>()
|
||||
const resolveTarget = Effect.fnUntraced(function* (value: string) {
|
||||
const target = yield* mutation.resolve({ path: value, kind: "file" })
|
||||
if (!target.externalDirectory) return target
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
metadata: {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
return target
|
||||
})
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
const target = yield* resolveTarget(hunk.path)
|
||||
const target = resolveTarget(location, hunk.path)
|
||||
targets.push(target)
|
||||
if (target.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [target.externalDirectory.resource],
|
||||
save: [target.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
if (hunk.type === "add") {
|
||||
const content =
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
@@ -173,7 +182,22 @@ export const Plugin = {
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
})
|
||||
const moveTarget = hunk.movePath ? yield* resolveTarget(hunk.movePath) : undefined
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
if (moveTarget?.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [moveTarget.externalDirectory.resource],
|
||||
save: [moveTarget.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: moveTarget.absolute,
|
||||
parentDir: moveTarget.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
@@ -193,10 +217,6 @@ export const Plugin = {
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map((change) => patchFile(change))
|
||||
const targets = prepared.flatMap((change) => [
|
||||
change.target,
|
||||
...(change.type === "update" && change.moveTarget ? [change.moveTarget] : []),
|
||||
])
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map((target) => target.resource))],
|
||||
@@ -293,7 +313,7 @@ export const Plugin = {
|
||||
})
|
||||
return { applied, files }
|
||||
}).pipe(
|
||||
fileMutation.withLock(lockTargets),
|
||||
mutation.withLock(lockTargets),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
@@ -374,3 +394,24 @@ function trimDiff(diff: string) {
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function resolveTarget(location: Location.Interface, value: string): Target {
|
||||
const absolute =
|
||||
process.platform === "win32"
|
||||
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
||||
: path.resolve(location.directory, value)
|
||||
const projectRoot = path.parse(location.project.directory).root
|
||||
const external =
|
||||
!FSUtil.contains(location.directory, absolute) &&
|
||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
|
||||
const directory = path.dirname(absolute)
|
||||
const resource =
|
||||
process.platform === "win32"
|
||||
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
||||
: path.join(directory, "*").replaceAll("\\", "/")
|
||||
return {
|
||||
absolute,
|
||||
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
|
||||
externalDirectory: external ? { directory, resource } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Form } from "../../form.js"
|
||||
import { KV } from "../../kv.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
|
||||
@@ -30,7 +30,7 @@ export const Plugin = {
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const config = yield* Config.Service
|
||||
const kv = yield* KV.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -65,7 +65,7 @@ export const Plugin = {
|
||||
return providerSelectionLock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (yield* websearch.default()) return
|
||||
if (yield* websearch.default()) return yield* Effect.void
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
@@ -83,7 +83,7 @@ export const Plugin = {
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
|
||||
label: `Allow web search via ${defaultProvider.name}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
@@ -97,9 +97,7 @@ export const Plugin = {
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* config.update((draft) => {
|
||||
draft.websearch = false
|
||||
})
|
||||
yield* kv.set("websearch:provider", false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
@@ -125,19 +123,13 @@ export const Plugin = {
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? "random"
|
||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||
if (
|
||||
typeof providerID !== "string" ||
|
||||
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
|
||||
!providers.some((provider) => provider.id === providerID)
|
||||
)
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* config.update((draft) => {
|
||||
draft.websearch = {
|
||||
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
|
||||
}
|
||||
})
|
||||
if (providerID !== "random") return WebSearch.ID.make(providerID)
|
||||
return providers[Math.floor(Math.random() * providers.length)]?.id
|
||||
return yield* kv.set("websearch:provider", providerID)
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
@@ -145,12 +137,7 @@ export const Plugin = {
|
||||
duration: "1 minute",
|
||||
orElse: () => Effect.fail(new Error("Web search cancelled")),
|
||||
}),
|
||||
Effect.flatMap((providerID) => {
|
||||
if (!providerID) return Effect.suspend(search)
|
||||
return context
|
||||
.progress({ provider: providerID })
|
||||
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID })))
|
||||
}),
|
||||
Effect.andThen(Effect.suspend(search)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -206,8 +193,7 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const disabled = Config.latest(yield* config.entries(), "websearch") === false
|
||||
if (disabled) delete event.tools[name]
|
||||
if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name]
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export const ID = WebSearch.ID
|
||||
@@ -59,14 +60,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
|
||||
|
||||
type Data = {
|
||||
readonly providers: Map<ID, ProviderImplementation>
|
||||
selection?: ID | "random" | false
|
||||
defaultProviderID?: ID
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (provider: ProviderImplementation) => void
|
||||
default: {
|
||||
get: () => ID | "random" | false | undefined
|
||||
set: (selection: ID | "random" | false) => void
|
||||
get: () => ID | undefined
|
||||
set: (providerID: ID) => void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,14 +75,15 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
draft: (draft) => ({
|
||||
add: (provider) => draft.providers.set(provider.id, provider),
|
||||
default: {
|
||||
get: () => draft.selection,
|
||||
set: (selection) => (draft.selection = selection),
|
||||
get: () => draft.defaultProviderID,
|
||||
set: (providerID) => (draft.defaultProviderID = providerID),
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
@@ -94,12 +96,12 @@ const layer = Layer.effect(
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||
const data = state.get()
|
||||
if (data.selection === false) return yield* new DisabledError()
|
||||
if (data.selection === "random") {
|
||||
const providers = Array.from(data.providers.values())
|
||||
return providers[Math.floor(Math.random() * providers.length)]
|
||||
}
|
||||
return data.selection ? data.providers.get(data.selection) : undefined
|
||||
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
|
||||
if (configured) return configured
|
||||
const stored = yield* kv.get("websearch:provider")
|
||||
if (stored === false) return yield* new DisabledError()
|
||||
if (typeof stored !== "string") return
|
||||
return data.providers.get(ID.make(stored))
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
||||
@@ -138,5 +140,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node],
|
||||
deps: [Bus.node, KV.node],
|
||||
})
|
||||
|
||||
@@ -72,53 +72,6 @@ const provider = {
|
||||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("updates the first file-backed document", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const globalFile = path.join(global, "opencode.jsonc")
|
||||
const projectFile = path.join(project, "opencode.json")
|
||||
return Effect.promise(async () => {
|
||||
await Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })])
|
||||
await Promise.all([
|
||||
fs.writeFile(globalFile, '{\n // Keep this comment.\n "shell": "global"\n}\n'),
|
||||
fs.writeFile(projectFile, JSON.stringify({ shell: "project" })),
|
||||
])
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const updated = yield* config.update((draft) => {
|
||||
draft.shell = "updated"
|
||||
})
|
||||
|
||||
expect(updated.shell).toBe("updated")
|
||||
expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain("// Keep this comment.")
|
||||
expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain('"shell": "updated"')
|
||||
expect(JSON.parse(yield* Effect.promise(() => fs.readFile(projectFile, "utf8")))).toEqual({
|
||||
shell: "project",
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(project, global))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("fails updates when no file-backed document exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const error = yield* config.update((draft) => void draft).pipe(Effect.flip)
|
||||
expect(error.message).toBe("No editable config document found")
|
||||
}).pipe(
|
||||
Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ shell: "virtual" }) })])),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads explicit file and content overrides in priority order", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -905,7 +858,7 @@ describe("Config", () => {
|
||||
expect(documents.map((document) => document.type)).toEqual(["document", "document"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
|
||||
expect(documents[0]).toBeInstanceOf(Document)
|
||||
expect(documents[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "opencode.json")))
|
||||
expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
|
||||
expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1452,14 +1405,9 @@ describe("Config", () => {
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
|
||||
expect(yield* watcher.subscriptions()).toContainEqual({
|
||||
path: path.join(tmp.path, "opencode.jsonc"),
|
||||
type: "file",
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
@@ -1543,9 +1491,13 @@ describe("Config", () => {
|
||||
"global",
|
||||
AbsolutePath.make(global),
|
||||
"outside",
|
||||
AbsolutePath.make(path.join(tmp.path, "opencode.json")),
|
||||
"root",
|
||||
AbsolutePath.make(path.join(root, "opencode.json")),
|
||||
"parent",
|
||||
AbsolutePath.make(path.join(parent, "opencode.jsonc")),
|
||||
"directory",
|
||||
AbsolutePath.make(path.join(directory, "opencode.json")),
|
||||
"root-dot",
|
||||
AbsolutePath.make(path.join(root, ".opencode")),
|
||||
"directory-dot",
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
@@ -80,7 +79,7 @@ describe("config plugin reloads", () => {
|
||||
function config(name: string) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
path: AbsolutePath.make(document),
|
||||
path: document,
|
||||
info: decode({
|
||||
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
|
||||
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
@@ -124,58 +123,4 @@ describe("FileSystemSearch", () => {
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
test("reuses location-owned fuzzy targets across index refreshes", async () => {
|
||||
let scans = 0
|
||||
const first = Effect.runSync(Deferred.make<void>())
|
||||
const second = Effect.runSync(Deferred.make<void>())
|
||||
const prepare = spyOn(fuzzysort, "prepare")
|
||||
const cleanup = spyOn(fuzzysort, "cleanup")
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Deferred.await(first)
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
yield* Deferred.await(second)
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(2)
|
||||
expect(cleanup).toHaveBeenCalledTimes(3)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
prepare.mockRestore()
|
||||
cleanup.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
@@ -27,7 +27,16 @@ function formatterLayer(directory: string, configured?: ConfigInput["formatter"]
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
[Config.node, Config.testLayer(entries)],
|
||||
[
|
||||
Config.node,
|
||||
Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed(entries),
|
||||
changes: () => Stream.empty,
|
||||
}),
|
||||
),
|
||||
],
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Group } from "@opencode-ai/core/persistent-pty"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([Group.node, KV.node, Bus.node])))
|
||||
|
||||
describe("Group", () => {
|
||||
it.effect("persists ordered groups in one versioned KV document", () =>
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
const kv = yield* KV.Service
|
||||
const created = yield* groups.create([
|
||||
{ type: "session", id: Session.ID.make("ses_one") },
|
||||
{ type: "terminal", id: Pty.ID.make("pty_one") },
|
||||
])
|
||||
|
||||
expect(yield* groups.get(created.id)).toEqual(created)
|
||||
expect(yield* groups.list()).toEqual([created])
|
||||
expect(yield* kv.get("group:v1")).toEqual([created])
|
||||
|
||||
const updated = Group.Info.make({
|
||||
id: created.id,
|
||||
items: [{ type: "terminal", id: Pty.ID.make("pty_two") }],
|
||||
})
|
||||
yield* groups.set(updated)
|
||||
expect(yield* groups.list()).toEqual([updated])
|
||||
|
||||
yield* groups.remove(created.id)
|
||||
expect(yield* groups.get(created.id)).toBeUndefined()
|
||||
expect(yield* kv.get("group:v1")).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serializes concurrent document mutations", () =>
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
yield* Effect.all(
|
||||
Array.from({ length: 20 }, (_, index) =>
|
||||
groups.create([{ type: "session", id: Session.ID.make(`ses_${index}`) }]),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(yield* groups.list()).toHaveLength(20)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes every removed group item", () =>
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
const bus = yield* Bus.Service
|
||||
const session = { type: "session" as const, id: Session.ID.make("ses_one") }
|
||||
const terminal = { type: "terminal" as const, id: Pty.ID.make("pty_one") }
|
||||
const group = yield* groups.create([session, terminal])
|
||||
const events = yield* bus
|
||||
.subscribe(Group.Event.ItemRemoved)
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* groups.set(Group.Info.make({ id: group.id, items: [session] }))
|
||||
yield* groups.remove(group.id)
|
||||
|
||||
expect(Array.from(yield* Fiber.join(events)).map((event) => event.data)).toEqual([
|
||||
{ groupID: group.id, item: terminal },
|
||||
{ groupID: group.id, item: session },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes every added group item", () =>
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
const bus = yield* Bus.Service
|
||||
const session = { type: "session" as const, id: Session.ID.make("ses_one") }
|
||||
const terminal = { type: "terminal" as const, id: Pty.ID.make("pty_one") }
|
||||
const group = yield* groups.create([session])
|
||||
const event = yield* bus.subscribe(Group.Event.ItemAdded).pipe(Stream.runHead, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* groups.set(Group.Info.make({ id: group.id, items: [session, terminal] }))
|
||||
|
||||
expect((yield* Fiber.join(event)).valueOrUndefined?.data).toEqual({ groupID: group.id, item: terminal })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -510,7 +510,7 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("normalizes equivalent refs to one cached location graph", () =>
|
||||
it.live("normalizes ref key shapes to one cached location graph", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -520,20 +520,16 @@ describe("LocationServiceMap", () => {
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const directory = AbsolutePath.make(dir.path)
|
||||
const alternate = AbsolutePath.make(directory.replaceAll("\\", "/"))
|
||||
const absent = Location.Ref.make({ directory: alternate })
|
||||
const absent = Location.Ref.make({ directory })
|
||||
const present = Location.Ref.make({ directory, workspaceID: undefined })
|
||||
// The two shapes are not structurally Equal: own-key sets differ.
|
||||
expect(Object.keys(absent)).toEqual(["directory"])
|
||||
expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
|
||||
expect(Equal.equals(absent, present)).toBe(false)
|
||||
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
|
||||
|
||||
const first = yield* locations.contextEffect(absent)
|
||||
expect(yield* locations.contextEffect(present)).toBe(first)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
|
||||
Location.Ref.make({ directory, workspaceID: undefined }),
|
||||
])
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
|
||||
|
||||
// Invalidating with the shape opposite to the one that booted must evict.
|
||||
yield* locations.invalidate(present)
|
||||
|
||||
@@ -160,20 +160,6 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses an explicit file kind without treating an existing directory as the target boundary", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: path.dirname(outside),
|
||||
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("authorizes prospective external descendants at their lexical parent", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
|
||||
@@ -185,11 +185,7 @@ function resourceMcpLayer(
|
||||
overrides?.entries
|
||||
? Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: overrides.entries,
|
||||
update: () => Effect.die("unused config update"),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
Config.Service.of({ entries: overrides.entries, changes: () => Stream.never }),
|
||||
)
|
||||
: Config.testLayer([
|
||||
new Document({
|
||||
|
||||
@@ -388,10 +388,7 @@ export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["w
|
||||
}),
|
||||
default: {
|
||||
get: draft.default.get,
|
||||
set: (selection) =>
|
||||
draft.default.set(
|
||||
selection === false || selection === "random" ? selection : WebSearch.ID.make(selection),
|
||||
),
|
||||
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DateTime, Effect, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -15,10 +14,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -27,53 +23,6 @@ import { host as testHost } from "./host"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it.effect("adapts session creation through the protocol schema", () =>
|
||||
Effect.gen(function* () {
|
||||
let seen: unknown
|
||||
const host = testHost({
|
||||
session: {
|
||||
create: (input) => {
|
||||
seen = input
|
||||
return Effect.succeed(
|
||||
Session.Info.make({
|
||||
id: Session.ID.make("ses_protocol_adapter"),
|
||||
projectID: Project.ID.make("project"),
|
||||
cost: Money.USD.make(0),
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
time: { created: DateTime.makeUnsafe(10), updated: DateTime.makeUnsafe(20) },
|
||||
title: input?.title,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-create",
|
||||
setup: async (ctx) => {
|
||||
await expect(Reflect.apply(ctx.session.create, undefined, [{ title: 42 }])).rejects.toBeDefined()
|
||||
const result = await ctx.session.create({
|
||||
id: null,
|
||||
title: "Promise title",
|
||||
agent: null,
|
||||
model: null,
|
||||
location: null,
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
id: "ses_protocol_adapter",
|
||||
title: "Promise title",
|
||||
time: { created: 10, updated: 20 },
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
|
||||
expect(seen).toEqual({ title: "Promise title" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards transient session generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const host = testHost({
|
||||
@@ -95,42 +44,6 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves no-content and rejected Promise behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: unknown[] = []
|
||||
const host = testHost({
|
||||
session: {
|
||||
interrupt: (input) => {
|
||||
if (input.sessionID === Session.ID.make("ses_failure")) {
|
||||
return Effect.fail(new Error("interrupt failed"))
|
||||
}
|
||||
expect(input.continue).toBe(true)
|
||||
return Effect.void
|
||||
},
|
||||
rename: (input) => Effect.sync(() => seen.push(input)),
|
||||
wait: (input) => Effect.sync(() => seen.push(input)),
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-interrupt",
|
||||
setup: async (ctx) => {
|
||||
expect(await ctx.session.interrupt({ sessionID: "ses_success", continue: true })).toBeUndefined()
|
||||
await expect(ctx.session.interrupt({ sessionID: "ses_failure" })).rejects.toThrow("interrupt failed")
|
||||
expect(await ctx.session.rename({ sessionID: "ses_success", title: "Renamed" })).toBeUndefined()
|
||||
expect(await ctx.session.wait({ sessionID: "ses_success" })).toBeUndefined()
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ sessionID: Session.ID.make("ses_success"), title: "Renamed" },
|
||||
{ sessionID: Session.ID.make("ses_success") },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards synthetic session input", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
@@ -201,7 +114,6 @@ describe("fromPromise", () => {
|
||||
ctx.skill.list(),
|
||||
])
|
||||
seen.push(...results.map((result) => result.location.directory))
|
||||
expect((await ctx.integration.get({ integrationID: "missing" })).data).toBeNull()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user