mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c325b8ffd8 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Apply shared Session model-request preparation to transient generation.
|
||||
@@ -175,14 +175,14 @@ const table = sqliteTable("session", {
|
||||
## 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 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. 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:",
|
||||
|
||||
@@ -27,9 +27,8 @@ 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 clustered or exactly-once
|
||||
execution recovery. Session execution separately provides bounded local recovery
|
||||
through durable write-ahead claims.
|
||||
protocol negotiation, idle background restart, or general execution-recovery
|
||||
framework.
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
@@ -177,9 +176,9 @@ This design gives each concept one authority.
|
||||
- Adding a permanent steward, proxy, or supervisor process.
|
||||
- Zero-downtime worker handoff or automatic rollback.
|
||||
- Application protocol negotiation or automatic TUI self-restart.
|
||||
- Exactly-once recovery for provider attempts, tools, shells, sub-agents,
|
||||
permissions, questions, or background jobs. Top-level Session continuation
|
||||
after process death is handled separately through durable execution claims.
|
||||
- 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.
|
||||
@@ -203,10 +202,9 @@ This design gives each concept one authority.
|
||||
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 exactly-once execution.** A successor invokes
|
||||
the Session execution-claim sweep, which resumes from durable history.
|
||||
Provider-attempt identity and tool-side-effect fencing belong to separate
|
||||
designs.
|
||||
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
|
||||
|
||||
@@ -487,20 +485,23 @@ The UI derives text from status:
|
||||
| `ready` | Normal TUI |
|
||||
|
||||
## Graceful Session Continuity
|
||||
|
||||
|
||||
Version-mismatch replacement uses the existing graceful Session suspension and
|
||||
its runner starts. Success, failure, and user interruption release the claim;
|
||||
shutdown interruption and process death leave it intact. The
|
||||
successor sweeps claimed top-level Sessions, durably counts a recovery attempt,
|
||||
appends a continuation instruction, and resumes from projected history. The same
|
||||
mechanism covers graceful replacement, crash, SIGKILL, and runtime eviction.
|
||||
|
||||
resumption hooks:
|
||||
|
||||
1. The old server snapshots active Session IDs during graceful teardown.
|
||||
does not prove whether an interrupted provider request or external operation
|
||||
already took effect. It does not replay the exact interrupted tool, preserve an
|
||||
in-memory form, recover process-local background work, or guarantee exactly-once
|
||||
provider or tool behavior.
|
||||
|
||||
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
|
||||
@@ -530,15 +531,13 @@ Automatic frozen-owner recovery is deferred.
|
||||
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.
|
||||
the execution claims already written by active Sessions.
|
||||
4. Open TUIs enter their indefinite status loops.
|
||||
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`.
|
||||
and reports `ready`.
|
||||
10. TUIs rebuild clients, reconcile state, and resume.
|
||||
10. TUIs rebuild clients, reconcile state, and resume.
|
||||
|
||||
### Server crashes while ready
|
||||
|
||||
@@ -547,9 +546,7 @@ Automatic frozen-owner recovery is deferred.
|
||||
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.
|
||||
them with bounded attempt accounting. External side effects remain
|
||||
potentially ambiguous.
|
||||
|
||||
|
||||
### Winner crashes during startup
|
||||
|
||||
1. Clients observed `starting` and remain alive.
|
||||
@@ -653,9 +650,8 @@ was the observed incident cost.
|
||||
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 startup execution-claim
|
||||
recovery.
|
||||
8. **Harden explicit recovery.** Verify exact process identity during explicit
|
||||
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.
|
||||
@@ -681,8 +677,7 @@ was the observed incident cost.
|
||||
- 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.
|
||||
idempotency or fencing, and clustered ownership.
|
||||
- Shell, sub-agent, permission, question, and background-job continuity.
|
||||
- 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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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> = []
|
||||
|
||||
@@ -1671,7 +1671,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 +1682,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 +1699,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 +1711,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,
|
||||
|
||||
@@ -1787,7 +1787,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 +1842,7 @@ export type ConfigEntry =
|
||||
}
|
||||
}
|
||||
| { type: "directory"; path: string }
|
||||
| { type: "file"; path: string }
|
||||
| { type: "agents"; path: string }
|
||||
| { type: "claude"; path: string }
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -69,7 +69,7 @@ function LimitsGraph(props: { href: string }) {
|
||||
{ id: "grok-4.5", name: "Grok 4.5", req: 120, d: "50ms" },
|
||||
{ id: "kimi-k3", name: "Kimi K3", req: 110, d: "75ms" },
|
||||
{ id: "qwen3.8-max", name: "Qwen3.8 Max", req: 160, d: "90ms" },
|
||||
{ id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" },
|
||||
{ id: "glm-5.3", name: "GLM-5.3", req: 880, d: "100ms" },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "210ms" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" },
|
||||
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", req: 4100, baseReq: 2050, d: "290ms" },
|
||||
|
||||
+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"),
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,54 +1,80 @@
|
||||
export * as SessionGenerateNode from "./generate-node.js"
|
||||
|
||||
import { LLMClient, Message } from "@opencode-ai/ai"
|
||||
import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app.js"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import { SessionGenerate } from "./generate.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
|
||||
export const layer = Layer.effect(
|
||||
SessionGenerate.Service,
|
||||
Effect.gen(function* () {
|
||||
const context = yield* SessionContext.Service
|
||||
const database = yield* Database.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const app = yield* App.Metadata
|
||||
|
||||
return SessionGenerate.Service.of({
|
||||
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
|
||||
const selection = yield* context.select(input.sessionID)
|
||||
const model = yield* models.resolve(selection.session)
|
||||
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: selection.agent.info,
|
||||
model,
|
||||
tools: selection.tools,
|
||||
initial: history.initial,
|
||||
messages: history.messages,
|
||||
const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
|
||||
const tools = selection.tools
|
||||
const toolDefinitions = tools.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
sessionID: selection.session.id,
|
||||
agent: selection.agent.id,
|
||||
model: model.ref,
|
||||
system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
...toLLMMessages(history.messages, model.ref, providerMetadataKey),
|
||||
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||
Message.user(input.prompt),
|
||||
],
|
||||
tools: Object.fromEntries(
|
||||
toolDefinitions.map((tool) => [
|
||||
tool.name,
|
||||
{ description: tool.description, input: { ...tool.inputSchema } },
|
||||
]),
|
||||
),
|
||||
})
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||
Message.user(input.prompt),
|
||||
],
|
||||
},
|
||||
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
|
||||
const registered = toolsByName.get(name)
|
||||
return registered
|
||||
? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
|
||||
: []
|
||||
})
|
||||
yield* Effect.logInfo("sending session generation request", {
|
||||
sessionID: selection.session.id,
|
||||
providerID: model.ref.providerID,
|
||||
modelID: model.ref.id,
|
||||
})
|
||||
const response = yield* llm.generate(prepared.request, prepared.options)
|
||||
const response = yield* llm.generate(
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||
promptCacheKey: SessionPromptCacheKey.make(selection.session.id),
|
||||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||
return response.text
|
||||
}),
|
||||
@@ -59,5 +85,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: SessionGenerate.Service,
|
||||
layer,
|
||||
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient],
|
||||
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
|
||||
})
|
||||
|
||||
@@ -12,16 +12,15 @@ import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { Agent } from "../agent.js"
|
||||
|
||||
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||
@@ -48,49 +47,20 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/** False when Session HTTP hooks require the request to remain on HTTP. */
|
||||
readonly webSocketEligible: boolean
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown and hook-removed calls
|
||||
* fail individually through the same seam.
|
||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
*/
|
||||
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
|
||||
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
|
||||
readonly stepLimitReached: boolean
|
||||
}
|
||||
|
||||
interface PrepareInput {
|
||||
readonly scope: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly tools: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
readonly system: Array<SystemPart>
|
||||
readonly messages: Array<Message>
|
||||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
|
||||
readonly webSocket?: "session"
|
||||
}
|
||||
|
||||
export const baseTranscript = (input: {
|
||||
readonly agent: Agent.Info
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly tools: Tool.Snapshot
|
||||
readonly initial: string
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
}) => {
|
||||
const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider
|
||||
return {
|
||||
providerMetadataKey,
|
||||
system: [
|
||||
input.agent.system
|
||||
? input.agent.system
|
||||
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)),
|
||||
input.initial,
|
||||
]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey),
|
||||
}
|
||||
readonly context: SessionContext.Loaded
|
||||
readonly step: number
|
||||
}
|
||||
|
||||
const mimeToModality = (mime: string) => {
|
||||
@@ -204,11 +174,27 @@ export const layer = Layer.effect(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const promptCacheSnapshots = diagnostics ? new Map<string, PromptCacheDiagnostics.Snapshot>() : undefined
|
||||
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
||||
const session = input.scope.session
|
||||
const resolved = input.scope.model
|
||||
const session = input.context.session
|
||||
const agent = input.context.agent
|
||||
const resolved = input.context.model
|
||||
const model = resolved.model
|
||||
const tools = input.scope.tools
|
||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
|
||||
// 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 : PROMPT_DEFAULT, input.context.initial]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make)
|
||||
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
|
||||
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
|
||||
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
|
||||
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
|
||||
// tool by moving its definition to a new key; recognizing the object recovers the tool.
|
||||
@@ -220,10 +206,10 @@ export const layer = Layer.effect(
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const context = yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
system,
|
||||
messages,
|
||||
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
|
||||
})
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
@@ -246,7 +232,7 @@ export const layer = Layer.effect(
|
||||
system: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: input.toolChoice,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const webSocketEligible =
|
||||
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
|
||||
@@ -254,20 +240,37 @@ export const layer = Layer.effect(
|
||||
? undefined
|
||||
: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
...(http ? { http } : {}),
|
||||
...(input.webSocket === "session" &&
|
||||
webSocket &&
|
||||
...(webSocket &&
|
||||
webSocketEligible &&
|
||||
resolved.ref.providerID === Provider.ID.openai &&
|
||||
model.route.id === "openai-responses"
|
||||
? { webSocket: transport.bind(session.id) }
|
||||
: {}),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
||||
promptCacheSnapshots.delete(session.id)
|
||||
promptCacheSnapshots.set(session.id, current)
|
||||
const oldest = promptCacheSnapshots.keys().next().value
|
||||
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
||||
yield* Effect.logInfo("prompt cache prefix").pipe(
|
||||
Effect.annotateLogs({
|
||||
sessionID: session.id,
|
||||
toolCount: current.tools.length,
|
||||
systemParts: current.system.length,
|
||||
messageCount: current.messages.length,
|
||||
...comparison,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const executeTool: Prepared["executeTool"] = (input) => {
|
||||
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
const tool = hooked.get(input.call.name)
|
||||
// A registered tool absent from the hooked set was removed or renamed by a hook.
|
||||
if (!tool && registry.has(input.call.name))
|
||||
@@ -279,7 +282,9 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
webSocketEligible,
|
||||
executeTool,
|
||||
stepLimitReached,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@ import {
|
||||
LLMClient,
|
||||
AIError,
|
||||
LLMEvent,
|
||||
Message,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
@@ -35,9 +34,6 @@ import { toSessionError } from "../to-session-error.js"
|
||||
import { SessionRunnerRetry } from "./retry.js"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { Tool } from "../../tool.js"
|
||||
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps.js"
|
||||
|
||||
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
@@ -120,32 +116,6 @@ const layer = Layer.effect(
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const promptCacheSnapshots = diagnostics ? new Map<string, PromptCacheDiagnostics.Snapshot>() : undefined
|
||||
const diagnosePromptCache = Effect.fn("SessionRunner.diagnosePromptCache")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
request: Parameters<typeof PromptCacheDiagnostics.snapshot>[0],
|
||||
) {
|
||||
if (!promptCacheSnapshots) return
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(sessionID), current)
|
||||
promptCacheSnapshots.delete(sessionID)
|
||||
promptCacheSnapshots.set(sessionID, current)
|
||||
const oldest = promptCacheSnapshots.keys().next().value
|
||||
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
||||
yield* Effect.logInfo("prompt cache prefix").pipe(
|
||||
Effect.annotateLogs({
|
||||
sessionID,
|
||||
toolCount: current.tools.length,
|
||||
systemParts: current.system.length,
|
||||
messageCount: current.messages.length,
|
||||
...comparison,
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
// The in-flight set coalesces overlapping prompts while title presence records success durably.
|
||||
const titlesRunning = new Set<SessionSchema.ID>()
|
||||
@@ -308,32 +278,10 @@ const layer = Layer.effect(
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
||||
return yield* new StepFailedError({ error: compacted.error })
|
||||
}
|
||||
const stepLimitReached = agent.info.steps !== undefined && currentStep >= agent.info.steps
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: agent.info,
|
||||
model: resolved,
|
||||
tools: loaded.tools,
|
||||
initial: loaded.initial,
|
||||
messages: loaded.messages,
|
||||
})
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
scope: { session, agentID: agent.id, model: resolved, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
},
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
context: loaded,
|
||||
step: currentStep,
|
||||
})
|
||||
yield* diagnosePromptCache(session.id, prepared.request)
|
||||
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
|
||||
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
return prepared.executeTool(input)
|
||||
}
|
||||
// Every local tool call forked here is owned until it reaches one durable settlement.
|
||||
const toolRuns: Array<{
|
||||
readonly call: ToolCall
|
||||
@@ -347,7 +295,7 @@ const layer = Layer.effect(
|
||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||
model: resolved.ref,
|
||||
providerMetadataKey: transcript.providerMetadataKey,
|
||||
providerMetadataKey: model.route.providerMetadataKey ?? model.provider,
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
@@ -408,7 +356,7 @@ const layer = Layer.effect(
|
||||
call: event,
|
||||
fiber: yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
executeTool({
|
||||
prepared.executeTool({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
messageID: assistantMessageID,
|
||||
@@ -556,7 +504,7 @@ const layer = Layer.effect(
|
||||
// A local call or malformed tool input requires another model step, unless
|
||||
// this step already exhausted the agent's allowance.
|
||||
needsContinuation:
|
||||
!stepLimitReached &&
|
||||
!prepared.stepLimitReached &&
|
||||
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
|
||||
step: currentStep,
|
||||
})
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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,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) }))),
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
@@ -15,9 +14,10 @@ import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import PROMPT_META from "../../src/plugin/system-prompt/meta.txt"
|
||||
import PROMPT_DEFAULT from "../../src/session/runner/prompt/base.txt"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const fallback = SessionSystemPrompt.make([])
|
||||
const fallback = PROMPT_DEFAULT
|
||||
const makeHost = Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -74,7 +74,7 @@ describe("SystemPromptPlugin", () => {
|
||||
["kimi-k2", "# Prompt and Tool Use"],
|
||||
["trinity", "what command should I run to list files"],
|
||||
["meta/muse-spark-1.1", "powered by Muse Spark"],
|
||||
["llama-3.3", fallback],
|
||||
["llama-3.3", "You are opencode, an interactive CLI tool"],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -52,17 +51,15 @@ import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const requests: LLMRequest[] = []
|
||||
const options: Array<StreamOptions | undefined> = []
|
||||
let instruction: string | Instructions.Unavailable = "Initial context"
|
||||
const sessionID = SessionSchema.ID.make("ses_generate_test")
|
||||
|
||||
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
stream: () => Stream.die(new Error("unused")),
|
||||
generate: (request, requestOptions) =>
|
||||
generate: (request) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(request)
|
||||
options.push(requestOptions)
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "generate" }),
|
||||
@@ -224,7 +221,6 @@ const setup = Effect.gen(function* () {
|
||||
it.effect("generates from fresh settled Session context without durable mutation", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
options.length = 0
|
||||
instruction = "Initial context"
|
||||
const { db, bus, instructions } = yield* setup
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
@@ -296,7 +292,6 @@ it.effect("generates from fresh settled Session context without durable mutation
|
||||
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "http.request", () => Effect.void)
|
||||
|
||||
const generate = yield* SessionGenerate.Service
|
||||
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
|
||||
@@ -326,8 +321,6 @@ it.effect("generates from fresh settled Session context without durable mutation
|
||||
).toEqual(["Settled partial answer"])
|
||||
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(options[0]?.http).toBeFunction()
|
||||
expect(options[0]?.webSocket).toBeUndefined()
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -71,7 +71,6 @@ import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
|
||||
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
|
||||
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
|
||||
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
|
||||
import { ID } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -82,6 +81,7 @@ import { asc, desc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
|
||||
let requests: LLMRequest[] = []
|
||||
@@ -147,7 +147,7 @@ const modelTransport = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
const model = LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
||||
const defaultSystem = SessionSystemPrompt.make([])
|
||||
const defaultSystem = PROMPT_DEFAULT
|
||||
const replacementModel = LanguageModel.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
|
||||
const compactModel = LanguageModel.make({
|
||||
id: "compact",
|
||||
@@ -1004,16 +1004,9 @@ describe("SessionRunnerLLM", () => {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
|
||||
const loaded = yield* context.load(selected)
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
scope: {
|
||||
session: loaded.session,
|
||||
agentID: loaded.agent.id,
|
||||
model: loaded.model,
|
||||
tools: loaded.tools,
|
||||
},
|
||||
transcript: { system: [], messages: [] },
|
||||
webSocket: "session",
|
||||
context: yield* context.load(selected),
|
||||
step: 1,
|
||||
})
|
||||
const http = prepared.options.http ?? (yield* Effect.die("Expected Session HTTP middleware"))
|
||||
|
||||
@@ -1022,7 +1015,7 @@ describe("SessionRunnerLLM", () => {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("network")))
|
||||
})
|
||||
|
||||
expect(prepared.options.webSocket).toBeUndefined()
|
||||
expect(prepared.webSocketEligible).toBe(false)
|
||||
expect(response.headers["x-response-hook"]).toBe("active")
|
||||
expect(requestTriggers).toBe(1)
|
||||
expect(responseTriggers).toBe(1)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
|
||||
|
||||
test("renders the default system prompt instructions", () => {
|
||||
const prompt = SessionSystemPrompt.make(["edit", "read", "shell"])
|
||||
expect(prompt).not.toContain("${OPENCODE_TOOL_GUIDANCE}")
|
||||
expect(prompt).toContain("Use the edit tool for targeted changes to existing text files")
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
|
||||
@@ -20,7 +20,13 @@ const withStore = <A, E, R>(
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const config = Config.testLayer([new Document({ type: "document", info })])
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed([new Document({ type: "document", info })]),
|
||||
changes: () => Stream.empty,
|
||||
}),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
[Config.node, config],
|
||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Stream } from "effect"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
@@ -19,7 +18,6 @@ import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { webSearchHost } from "./plugin/host"
|
||||
import { produce } from "immer"
|
||||
|
||||
const webSearchToolNode = makeLocationNode({
|
||||
name: "test/websearch-tool-plugin",
|
||||
@@ -29,14 +27,14 @@ const webSearchToolNode = makeLocationNode({
|
||||
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
|
||||
}),
|
||||
),
|
||||
deps: [Tool.node, Permission.node, WebSearch.node, Form.node, Config.node],
|
||||
deps: [Tool.node, Permission.node, WebSearch.node, Form.node, KV.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_websearch_test")
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const queries: WebSearch.Input[] = []
|
||||
const formRequests: Form.CreateInput[] = []
|
||||
let selection: WebSearch.ID | "random" | false | undefined
|
||||
const values = new Map<string, KV.Value>()
|
||||
const providers = [
|
||||
{ id: WebSearch.ID.make("exa"), name: "Exa" },
|
||||
{ id: WebSearch.ID.make("parallel"), name: "Parallel" },
|
||||
@@ -56,7 +54,7 @@ beforeEach(() => {
|
||||
assertions.length = 0
|
||||
queries.length = 0
|
||||
formRequests.length = 0
|
||||
selection = undefined
|
||||
values.clear()
|
||||
providerRequired = false
|
||||
formResponse = { status: "cancelled" }
|
||||
formResponses.length = 0
|
||||
@@ -75,39 +73,28 @@ const permission = permissionLayer({
|
||||
const websearch = Layer.succeed(
|
||||
WebSearch.Service,
|
||||
WebSearch.Service.of({
|
||||
transform: (transform) =>
|
||||
Effect.sync(() => {
|
||||
transform({
|
||||
add: () => undefined,
|
||||
default: {
|
||||
get: () => selection,
|
||||
set: (next) => (selection = next),
|
||||
},
|
||||
})
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
transform: () => Effect.die("unused"),
|
||||
reload: () => Effect.die("unused"),
|
||||
providers: () => Effect.succeed(providers),
|
||||
default: () =>
|
||||
Effect.gen(function* () {
|
||||
if (selection === false) return yield* new WebSearch.DisabledError()
|
||||
return selection ? providers.find((provider) => provider.id === selection) : undefined
|
||||
const stored = values.get("websearch:provider")
|
||||
if (stored === false) return yield* new WebSearch.DisabledError()
|
||||
return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined
|
||||
}),
|
||||
query: (input) =>
|
||||
Effect.gen(function* () {
|
||||
queries.push(input)
|
||||
const stored = values.get("websearch:provider")
|
||||
if (queryBarrier && synchronizedQueries < 5) {
|
||||
synchronizedQueries++
|
||||
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
|
||||
yield* Deferred.await(queryBarrier)
|
||||
}
|
||||
if (queryError) return yield* queryError
|
||||
if (providerRequired && !selection) return yield* new WebSearch.ProviderRequiredError()
|
||||
if (selection)
|
||||
return new WebSearch.Response({
|
||||
providerID: selection === "random" ? result.providerID : WebSearch.ID.make(selection),
|
||||
results: result.results,
|
||||
})
|
||||
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
||||
if (typeof stored === "string")
|
||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||
return result
|
||||
}),
|
||||
}),
|
||||
@@ -128,30 +115,12 @@ const form = Layer.succeed(
|
||||
cancel: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
update: (update) =>
|
||||
Effect.sync(() => {
|
||||
const info = produce(
|
||||
new Info({
|
||||
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
|
||||
}),
|
||||
update,
|
||||
)
|
||||
selection = info.websearch === false ? false : info.websearch?.provider
|
||||
return info
|
||||
}),
|
||||
changes: () => Stream.never,
|
||||
const kv = Layer.succeed(
|
||||
KV.Service,
|
||||
KV.Service.of({
|
||||
get: (key) => Effect.succeed(values.get(key)),
|
||||
set: (key, value) => Effect.sync(() => values.set(key, value)).pipe(Effect.asVoid),
|
||||
remove: (key) => Effect.sync(() => values.delete(key)).pipe(Effect.asVoid),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -159,7 +128,7 @@ const it = testEffect(
|
||||
[Permission.node, permission],
|
||||
[WebSearch.node, websearch],
|
||||
[Form.node, form],
|
||||
[Config.node, config],
|
||||
[KV.node, kv],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
)
|
||||
@@ -278,7 +247,7 @@ describe("WebSearchTool registration", () => {
|
||||
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
|
||||
expect(selection).toBe("random")
|
||||
expect(values.get("websearch:provider")).toBe("exa")
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(formRequests).toEqual([
|
||||
{
|
||||
@@ -295,7 +264,7 @@ describe("WebSearchTool registration", () => {
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: "Allow search via Exa, Parallel",
|
||||
label: "Allow web search via Exa",
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
@@ -336,7 +305,7 @@ describe("WebSearchTool registration", () => {
|
||||
call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
|
||||
expect(selection).toBe(WebSearch.ID.make("parallel"))
|
||||
expect(values.get("websearch:provider")).toBe("parallel")
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(formRequests[1]).toEqual({
|
||||
sessionID,
|
||||
@@ -384,7 +353,7 @@ describe("WebSearchTool registration", () => {
|
||||
|
||||
expect(results.every((item) => item.status === "completed")).toBe(true)
|
||||
expect(formRequests).toHaveLength(1)
|
||||
expect(selection).toBe("random")
|
||||
expect(values.get("websearch:provider")).toBe("exa")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -401,7 +370,7 @@ describe("WebSearchTool registration", () => {
|
||||
call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(selection).toBe(false)
|
||||
expect(values.get("websearch:provider")).toBe(false)
|
||||
expect(queries).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
@@ -410,7 +379,7 @@ describe("WebSearchTool registration", () => {
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const tools = yield* registry.snapshot()
|
||||
selection = WebSearch.ID.make("exa")
|
||||
values.set("websearch:provider", "exa")
|
||||
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Effect, Exit, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node])))
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
|
||||
|
||||
const register = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -80,14 +81,16 @@ describe("WebSearch", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("chooses a registered provider for random selection", () =>
|
||||
it.effect("uses the provider stored in KV", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
yield* register("parallel")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set("random"))
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set("websearch:provider", parallel.providerID)
|
||||
|
||||
expect(["exa", "parallel"]).toContain((yield* websearch.query({ query: "random" })).providerID)
|
||||
expect((yield* websearch.query({ query: "stored" })).providerID).toBe(parallel.providerID)
|
||||
yield* kv.remove("websearch:provider")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -95,9 +98,11 @@ describe("WebSearch", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(false))
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set("websearch:provider", false)
|
||||
|
||||
expect((yield* websearch.query({ query: "disabled" }).pipe(Effect.flip))._tag).toBe("WebSearch.Disabled")
|
||||
yield* kv.remove("websearch:provider")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,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:",
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface WebSearchDomain extends WebsearchApi<unknown> {
|
||||
export interface WebSearchDraft {
|
||||
add(definition: WebSearchDefinition): void
|
||||
readonly default: {
|
||||
get(): string | false | undefined
|
||||
set(selection: string | false): void
|
||||
get(): string | undefined
|
||||
set(providerID: string): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, SchemaAST, Scope, Stream } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { define } from "../effect/plugin.js"
|
||||
import type { Context, Plugin } from "./plugin.js"
|
||||
import type { Info } 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
|
||||
|
||||
interface CompiledEndpoint {
|
||||
readonly decode: ReadonlyArray<(input: unknown) => Effect.Effect<unknown, Schema.SchemaError>>
|
||||
readonly encode: (output: unknown) => Effect.Effect<unknown, Schema.SchemaError>
|
||||
readonly noContent: boolean
|
||||
}
|
||||
|
||||
const compiledEndpoints = new WeakMap<object, CompiledEndpoint>()
|
||||
|
||||
function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
|
||||
const cached = compiledEndpoints.get(endpoint)
|
||||
if (cached) return cached
|
||||
const payloadSchemas = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas)
|
||||
const successSchemas = Array.from(endpoint.success)
|
||||
if (payloadSchemas.length > 1 || successSchemas.length > 1) {
|
||||
throw new Error(`Unsupported API schema cardinality: ${endpoint.identifier}`)
|
||||
}
|
||||
const inputs = [
|
||||
endpoint.params,
|
||||
endpoint.query === undefined ? undefined : Schema.toType(endpoint.query),
|
||||
endpoint.headers,
|
||||
...payloadSchemas,
|
||||
].filter((schema): schema is Schema.Top => schema !== undefined) as Array<RuntimeSchema>
|
||||
const success = (successSchemas[0] ?? HttpApiSchema.NoContent) as RuntimeSchema
|
||||
const noContent = HttpApiSchema.isNoContent(success.ast)
|
||||
const type = Schema.toType(success).ast
|
||||
const data = SchemaAST.isObjects(success.ast)
|
||||
? success.ast.propertySignatures.find((property) => property.name === "data")
|
||||
: undefined
|
||||
const output =
|
||||
!noContent &&
|
||||
SchemaAST.isObjects(type) &&
|
||||
type.indexSignatures.length === 0 &&
|
||||
type.propertySignatures.length === 1 &&
|
||||
type.propertySignatures[0]?.name === "data" &&
|
||||
data !== undefined
|
||||
? (Schema.make<Schema.Top>(data.type) as RuntimeSchema)
|
||||
: success
|
||||
const compiled = {
|
||||
decode: inputs.map((schema) => Schema.decodeUnknownEffect(schema)),
|
||||
encode: Schema.encodeUnknownEffect(output),
|
||||
noContent,
|
||||
} satisfies CompiledEndpoint
|
||||
compiledEndpoints.set(endpoint, compiled)
|
||||
return compiled
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
|
||||
Promise.all([import("@opencode-ai/protocol/client"), import("@opencode-ai/protocol/groups/event")]),
|
||||
)
|
||||
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
|
||||
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
|
||||
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
|
||||
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
|
||||
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
|
||||
const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints
|
||||
const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints
|
||||
const SessionEndpoints = ClientApi.groups["server.session"].endpoints
|
||||
const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
|
||||
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
|
||||
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)
|
||||
|
||||
const adaptApiMethod = <PromiseMethod>(
|
||||
endpoint: HttpApiEndpoint.Top,
|
||||
method: (input: never) => Effect.Effect<unknown, unknown>,
|
||||
) => {
|
||||
const compiled = compileEndpoint(endpoint)
|
||||
return ((input?: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {}))
|
||||
const result = yield* method(Object.assign({}, ...decoded) as never)
|
||||
if (compiled.noContent) return undefined
|
||||
return yield* compiled.encode(result)
|
||||
}).pipe(Effect.runPromiseWith(context))) as PromiseMethod
|
||||
}
|
||||
|
||||
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: adaptApiMethod(AgentEndpoints["agent.get"], host.agent.get),
|
||||
list: adaptApiMethod(AgentEndpoints["agent.list"], host.agent.list),
|
||||
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: adaptApiMethod(ProviderEndpoints["provider.list"], host.catalog.provider.list),
|
||||
get: adaptApiMethod(ProviderEndpoints["provider.get"], host.catalog.provider.get),
|
||||
},
|
||||
model: {
|
||||
list: adaptApiMethod(ModelEndpoints["model.list"], host.catalog.model.list),
|
||||
default: adaptApiMethod(ModelEndpoints["model.default"], host.catalog.model.default),
|
||||
},
|
||||
transform: transform(host.catalog),
|
||||
reload: () => run(host.catalog.reload()),
|
||||
},
|
||||
command: {
|
||||
list: adaptApiMethod(CommandEndpoints["command.list"], host.command.list),
|
||||
transform: transform(host.command),
|
||||
reload: () => run(host.command.reload()),
|
||||
},
|
||||
event: {
|
||||
subscribe: () =>
|
||||
Stream.toAsyncIterable(
|
||||
host.event.subscribe().pipe(
|
||||
Stream.mapEffect((event) => Schema.encodeUnknownEffect(OpenCodeEvent)(event)),
|
||||
Stream.map((event) => event as unknown as PromiseEvent),
|
||||
),
|
||||
),
|
||||
},
|
||||
integration: {
|
||||
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
|
||||
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
|
||||
connect: {
|
||||
key: adaptApiMethod(IntegrationEndpoints["integration.connect.key"], host.integration.connect.key),
|
||||
},
|
||||
oauth: {
|
||||
connect: adaptApiMethod(
|
||||
IntegrationEndpoints["integration.oauth.connect"],
|
||||
host.integration.oauth.connect,
|
||||
),
|
||||
status: adaptApiMethod(IntegrationEndpoints["integration.oauth.status"], host.integration.oauth.status),
|
||||
complete: adaptApiMethod(
|
||||
IntegrationEndpoints["integration.oauth.complete"],
|
||||
host.integration.oauth.complete,
|
||||
),
|
||||
cancel: adaptApiMethod(IntegrationEndpoints["integration.oauth.cancel"], host.integration.oauth.cancel),
|
||||
},
|
||||
command: {
|
||||
connect: adaptApiMethod(
|
||||
IntegrationEndpoints["integration.command.connect"],
|
||||
host.integration.command.connect,
|
||||
),
|
||||
status: adaptApiMethod(
|
||||
IntegrationEndpoints["integration.command.status"],
|
||||
host.integration.command.status,
|
||||
),
|
||||
cancel: adaptApiMethod(
|
||||
IntegrationEndpoints["integration.command.cancel"],
|
||||
host.integration.command.cancel,
|
||||
),
|
||||
},
|
||||
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: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
|
||||
},
|
||||
reference: {
|
||||
list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
|
||||
transform: transform(host.reference),
|
||||
reload: () => run(host.reference.reload()),
|
||||
},
|
||||
skill: {
|
||||
list: adaptApiMethod(SkillEndpoints["skill.list"], host.skill.list),
|
||||
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: adaptApiMethod(WebSearchEndpoints["websearch.providers"], host.websearch.providers),
|
||||
query: adaptApiMethod(WebSearchEndpoints["websearch.query"], host.websearch.query),
|
||||
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: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
|
||||
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
|
||||
prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt),
|
||||
generate: adaptApiMethod(SessionEndpoints["session.generate"], host.session.generate),
|
||||
command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command),
|
||||
synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
|
||||
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
|
||||
rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
|
||||
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
|
||||
},
|
||||
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 })
|
||||
}
|
||||
|
||||
type RuntimeSchema = Schema.Codec<unknown, unknown>
|
||||
|
||||
const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
|
||||
Effect.promise(() =>
|
||||
tool.execute(input, {
|
||||
...context,
|
||||
progress: (update) => Effect.runPromise(context.progress(update)),
|
||||
}),
|
||||
)
|
||||
@@ -38,7 +38,7 @@ export interface SessionHooks {
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
SessionApi,
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
|
||||
> & {
|
||||
readonly hook: Hooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface WebSearchDomain extends WebSearchApi {
|
||||
export interface WebSearchDraft {
|
||||
add(definition: WebSearchDefinition): void
|
||||
readonly default: {
|
||||
get(): string | false | undefined
|
||||
set(selection: string | false): void
|
||||
get(): string | undefined
|
||||
set(providerID: string): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
}
|
||||
export type SlotPath = keyof SlotMap
|
||||
|
||||
|
||||
@@ -10534,7 +10534,7 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/api/worktree/{projectID}": {
|
||||
"/api/experimental/project/{projectID}/worktree": {
|
||||
"get": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.list",
|
||||
@@ -10736,7 +10736,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/worktree/{projectID}/refresh": {
|
||||
"/api/experimental/project/{projectID}/worktree/refresh": {
|
||||
"post": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.refresh",
|
||||
@@ -23517,24 +23517,6 @@
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ConfigWebSearch.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["random"]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Plugin.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24053,15 +24035,14 @@
|
||||
}
|
||||
},
|
||||
"websearch": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ConfigWebSearch.Info"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
@@ -24164,6 +24145,20 @@
|
||||
"required": ["type", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.File": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["file"]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.AgentsDirectory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24200,6 +24195,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.Directory"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.File"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.AgentsDirectory"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
const root = "/api/worktree/:projectID"
|
||||
const root = "/api/experimental/project/:projectID/worktree"
|
||||
|
||||
export class WorktreeError extends Schema.ErrorClass<WorktreeError>("WorktreeError")(
|
||||
{
|
||||
|
||||
@@ -22,24 +22,13 @@ export namespace JsonRpc {
|
||||
data: Schema.optional(Schema.Json),
|
||||
})
|
||||
|
||||
export const Response = Schema.Union(
|
||||
[
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: JsonRpcID,
|
||||
result: Schema.Json,
|
||||
error: Schema.optionalKey(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: JsonRpcID,
|
||||
result: Schema.optionalKey(Schema.Never),
|
||||
error: ErrorObject,
|
||||
}),
|
||||
],
|
||||
{ mode: "oneOf" },
|
||||
)
|
||||
export type Response = Schema.Schema.Type<typeof Response>
|
||||
export const Response = Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: JsonRpcID,
|
||||
result: Schema.optional(Schema.Json),
|
||||
error: Schema.optional(ErrorObject),
|
||||
})
|
||||
export interface Response extends Schema.Schema.Type<typeof Response> {}
|
||||
|
||||
export const decodeRequest = Schema.decodeUnknownSync(Request)
|
||||
|
||||
@@ -60,28 +49,6 @@ export namespace JsonRpc {
|
||||
}
|
||||
}
|
||||
|
||||
export class SimulationRequestError extends Schema.TaggedErrorClass<SimulationRequestError>()(
|
||||
"SimulationRequestError",
|
||||
{
|
||||
method: Schema.String,
|
||||
code: Schema.Number,
|
||||
message: Schema.String,
|
||||
data: Schema.optionalKey(Schema.Json),
|
||||
},
|
||||
) {}
|
||||
|
||||
const request = <
|
||||
const Tag extends string,
|
||||
Payload extends Schema.Top | Schema.Struct.Fields = typeof Schema.Void,
|
||||
Success extends Schema.Top = typeof Schema.Void,
|
||||
>(
|
||||
tag: Tag,
|
||||
options?: {
|
||||
readonly payload?: Payload
|
||||
readonly success?: Success
|
||||
},
|
||||
) => Rpc.make(tag, { ...options, error: SimulationRequestError })
|
||||
|
||||
export namespace Handshake {
|
||||
export const ProtocolVersion = Schema.Literal(1)
|
||||
export type ProtocolVersion = Schema.Schema.Type<typeof ProtocolVersion>
|
||||
@@ -114,7 +81,7 @@ export namespace Handshake {
|
||||
protocolVersion: ProtocolVersion,
|
||||
role: EndpointRole,
|
||||
server: Identity,
|
||||
capabilities: Schema.Array(Capability).check(Schema.isUnique()),
|
||||
capabilities: Schema.Array(Capability),
|
||||
})
|
||||
export interface Response extends Schema.Schema.Type<typeof Response> {}
|
||||
|
||||
@@ -597,29 +564,30 @@ export namespace Backend {
|
||||
matched: Schema.Boolean,
|
||||
})
|
||||
export interface NetworkLogEntry extends Schema.Schema.Type<typeof NetworkLogEntry> {}
|
||||
|
||||
export const Notification = Schema.Union([
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
method: Schema.Literal("llm.request"),
|
||||
params: ProviderInvocation,
|
||||
}),
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
method: Schema.Literal("tool.invocation"),
|
||||
params: ToolInvocation,
|
||||
}),
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
method: Schema.Literal("tool.cancel"),
|
||||
params: ToolCancellation,
|
||||
}),
|
||||
])
|
||||
export type Notification = Schema.Schema.Type<typeof Notification>
|
||||
export const decodeNotification = Schema.decodeUnknownSync(Notification)
|
||||
export const decodeNotificationEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Notification))
|
||||
}
|
||||
|
||||
export class SimulationRequestError extends Schema.TaggedErrorClass<SimulationRequestError>()(
|
||||
"SimulationRequestError",
|
||||
{
|
||||
method: Schema.String,
|
||||
code: Schema.Number,
|
||||
message: Schema.String,
|
||||
data: Schema.optionalKey(Schema.Json),
|
||||
},
|
||||
) {}
|
||||
|
||||
const request = <
|
||||
const Tag extends string,
|
||||
Payload extends Schema.Top | Schema.Struct.Fields = typeof Schema.Void,
|
||||
Success extends Schema.Top = typeof Schema.Void,
|
||||
>(
|
||||
tag: Tag,
|
||||
options?: {
|
||||
readonly payload?: Payload
|
||||
readonly success?: Success
|
||||
},
|
||||
) => Rpc.make(tag, { ...options, error: SimulationRequestError })
|
||||
|
||||
export const UiRpcs = RpcGroup.make(
|
||||
request("simulation.handshake", { payload: Handshake.Params, success: Handshake.Response }),
|
||||
request("ui.state", { success: Frontend.State }),
|
||||
|
||||
@@ -94,7 +94,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
references: ConfigReference.Info.pipe(optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Selection.pipe(optional).annotate({
|
||||
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
|
||||
@@ -109,7 +109,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
|
||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||
type: Schema.Literal("document"),
|
||||
path: AbsolutePath.pipe(optional),
|
||||
path: Schema.String.pipe(optional),
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
@@ -118,6 +118,11 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class File extends Schema.Class<File>("Config.File")({
|
||||
type: Schema.Literal("file"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
|
||||
type: Schema.Literal("agents"),
|
||||
path: AbsolutePath,
|
||||
@@ -128,7 +133,7 @@ export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.Claud
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([Document, Directory, AgentsDirectory, ClaudeDirectory]).annotate({
|
||||
export const Entry = Schema.Union([Document, Directory, File, AgentsDirectory, ClaudeDirectory]).annotate({
|
||||
identifier: "Config.Entry",
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
|
||||
@@ -4,8 +4,5 @@ import { Schema } from "effect"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
|
||||
provider: Schema.Union([Schema.Literal("random"), WebSearch.ID]),
|
||||
provider: WebSearch.ID,
|
||||
}) {}
|
||||
|
||||
export const Selection = Schema.Union([Schema.Literal(false), Info])
|
||||
export type Selection = typeof Selection.Type
|
||||
|
||||
@@ -6,22 +6,13 @@ import { ConfigMCP } from "../src/config/mcp.js"
|
||||
import { ConfigProvider } from "../src/config/provider.js"
|
||||
import { Mcp } from "../src/mcp.js"
|
||||
import { AbsolutePath } from "../src/schema.js"
|
||||
import { WebSearch } from "../src/websearch.js"
|
||||
|
||||
describe("Config.Entry", () => {
|
||||
test("accepts disabled, fixed, and random web search selection", () => {
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
expect(decode({ websearch: false }).websearch).toBe(false)
|
||||
expect(decode({ websearch: { provider: "exa" } }).websearch).toEqual({ provider: WebSearch.ID.make("exa") })
|
||||
expect(decode({ websearch: { provider: "random" } }).websearch).toEqual({ provider: "random" })
|
||||
})
|
||||
|
||||
test("round-trips every configuration entry type", () => {
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: AbsolutePath.make("/project/opencode.json"),
|
||||
path: "/project/opencode.json",
|
||||
info: new Config.Info({
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
@@ -31,6 +22,7 @@ describe("Config.Entry", () => {
|
||||
}),
|
||||
new Config.Document({ type: "document", info: new Config.Info({ shell: "/bin/zsh" }) }),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/project/.opencode") }),
|
||||
new Config.File({ type: "file", path: AbsolutePath.make("/project/opencode.json") }),
|
||||
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/project/.agents") }),
|
||||
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/project/.claude") }),
|
||||
]
|
||||
@@ -41,7 +33,14 @@ describe("Config.Entry", () => {
|
||||
expect(decoded).toEqual(entries)
|
||||
expect(decoded[0]).toBeInstanceOf(Config.Document)
|
||||
expect(decoded[1]).not.toHaveProperty("path")
|
||||
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "agents", "claude"])
|
||||
expect(decoded.map((entry) => entry.type)).toEqual([
|
||||
"document",
|
||||
"document",
|
||||
"directory",
|
||||
"file",
|
||||
"agents",
|
||||
"claude",
|
||||
])
|
||||
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
|
||||
@@ -7,7 +7,6 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
|
||||
it.live("returns ordered config entries for the requested directory", () =>
|
||||
Effect.acquireUseRelease(
|
||||
@@ -58,7 +57,7 @@ it.live("returns ordered config entries for the requested directory", () =>
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
])
|
||||
expect(document?.path).toBe(AbsolutePath.make(config))
|
||||
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
|
||||
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
|
||||
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
|
||||
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
|
||||
|
||||
@@ -36,7 +36,7 @@ it.live("lists, creates, and removes worktrees by project ID", () =>
|
||||
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
|
||||
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
|
||||
throw new Error("Expected resolved project")
|
||||
const url = new URL(`/api/worktree/${resolved.project.id}`, base)
|
||||
const url = new URL(`/api/experimental/project/${resolved.project.id}/worktree`, base)
|
||||
|
||||
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
|
||||
expect(initial).toEqual([{ directory: project }])
|
||||
|
||||
@@ -1,64 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Backend, Frontend, Handshake, JsonRpc } from "../src/protocol"
|
||||
|
||||
const successResponse: Schema.Schema.Type<typeof JsonRpc.Response> = { jsonrpc: "2.0", id: 1, result: null }
|
||||
// @ts-expect-error responses require one outcome
|
||||
const missingResponse: Schema.Schema.Type<typeof JsonRpc.Response> = { jsonrpc: "2.0", id: 1 }
|
||||
// @ts-expect-error responses cannot contain both outcomes
|
||||
const invalidResponse: Schema.Schema.Type<typeof JsonRpc.Response> = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: null,
|
||||
error: { code: -32600, message: "Invalid request" },
|
||||
}
|
||||
void [successResponse, missingResponse, invalidResponse]
|
||||
|
||||
test("normalizes an omitted finish reason", () => {
|
||||
expect(Backend.decodeRequest({ jsonrpc: "2.0", id: 1, method: "llm.finish", params: { id: "inv_1" } })).toMatchObject(
|
||||
{ params: { id: "inv_1", reason: "stop" } },
|
||||
)
|
||||
})
|
||||
|
||||
test("decodes typed backend notifications", () => {
|
||||
expect(
|
||||
Backend.decodeNotification({
|
||||
jsonrpc: "2.0",
|
||||
method: "tool.cancel",
|
||||
params: { id: "tool_1", reason: "interrupted" },
|
||||
}),
|
||||
).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
method: "tool.cancel",
|
||||
params: { id: "tool_1", reason: "interrupted" },
|
||||
})
|
||||
expect(() =>
|
||||
Backend.decodeNotification({
|
||||
jsonrpc: "2.0",
|
||||
method: "tool.cancel",
|
||||
params: { id: "tool_1", reason: "unknown" },
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("requires exactly one JSON-RPC response outcome", () => {
|
||||
const decode = Schema.decodeUnknownSync(JsonRpc.Response)
|
||||
expect(decode({ jsonrpc: "2.0", id: 1, result: null })).toEqual({ jsonrpc: "2.0", id: 1, result: null })
|
||||
expect(decode({ jsonrpc: "2.0", id: 1, error: { code: -32600, message: "Invalid request" } })).toEqual({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
error: { code: -32600, message: "Invalid request" },
|
||||
})
|
||||
expect(() => decode({ jsonrpc: "2.0", id: 1 })).toThrow()
|
||||
expect(() =>
|
||||
decode({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: null,
|
||||
error: { code: -32600, message: "Invalid request" },
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
import { Backend, Frontend, Handshake } from "../src/protocol"
|
||||
|
||||
test("decodes ui.matches text params", () => {
|
||||
expect(
|
||||
|
||||
@@ -83,7 +83,7 @@ test("streams a Drive-controlled provider response and removes the finished invo
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "llm.finish",
|
||||
params: { id: params.id },
|
||||
params: { id: params.id, reason: "stop" },
|
||||
}),
|
||||
)
|
||||
expect(yield* Queue.take(messages)).toMatchObject({ id: 3, result: { ok: true } })
|
||||
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
||||
import { directoryRecentValue } from "../../prompt/directory-completion"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -1540,25 +1539,21 @@ export function Prompt(props: PromptProps) {
|
||||
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
|
||||
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
||||
})
|
||||
const footerLocation = createMemo(() => {
|
||||
const locationLabel = createMemo(() => {
|
||||
if (!props.sessionID) {
|
||||
// No session yet: show where the next session will be created.
|
||||
return currentLocation.ref ?? data.location.default()
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
}
|
||||
if (status() !== "idle") return
|
||||
return data.session.get(props.sessionID)?.location
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
const location = footerLocation()
|
||||
const location = data.session.get(props.sessionID)?.location
|
||||
if (!location) return
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
})
|
||||
const locationActions = useWorkingDirectoryActions({
|
||||
directory: () => footerLocation()?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
@@ -1879,17 +1874,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text
|
||||
id="prompt.footer.location"
|
||||
fg={locationActions.hovered() ? theme.text.default : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
onMouseOver={locationActions.onMouseOver}
|
||||
onMouseOut={locationActions.onMouseOut}
|
||||
onMouseUp={locationActions.onMouseUp}
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -345,7 +345,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
let rail: { screenX: number; screenY: number } | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
let didDrag = false
|
||||
let addPressed = false
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
|
||||
@@ -761,8 +760,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
addPressed = event.button !== RIGHT_MOUSE_BUTTON
|
||||
if (addPressed) return
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
if (!rail) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
event.preventDefault()
|
||||
@@ -771,11 +769,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (!addPressed) return
|
||||
addPressed = false
|
||||
if (!newTab()) tabs.add?.()
|
||||
}}
|
||||
onMouseDragEnd={() => (addPressed = false)}
|
||||
>
|
||||
<text
|
||||
width={2}
|
||||
@@ -842,7 +837,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
let strip: { screenX: number; screenY: number } | undefined
|
||||
let didDrag = false
|
||||
let addPressed = false
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
@@ -1007,6 +1001,24 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
}}
|
||||
onMouseDrag={drag}
|
||||
onMouseDragEnd={release}
|
||||
renderAfter={function (buffer) {
|
||||
const x = Math.max(0, this.screenX)
|
||||
const y = this.screenY + this.height
|
||||
const width = Math.min(this.width, buffer.width - x)
|
||||
if (y < 0 || y >= buffer.height || width <= 0) return
|
||||
buffer.fillRect(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
1,
|
||||
RGBA.fromValues(
|
||||
theme.background.default.r,
|
||||
theme.background.default.g,
|
||||
theme.background.default.b,
|
||||
mode() === "light" ? 0.14 : 0.28,
|
||||
),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Show when={layout().before > 0}>
|
||||
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.subdued} selectable={false}>
|
||||
@@ -1209,8 +1221,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseDown={(event) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
addPressed = event.button !== RIGHT_MOUSE_BUTTON
|
||||
if (addPressed) return
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -1218,11 +1229,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (!addPressed) return
|
||||
addPressed = false
|
||||
tabs.add?.()
|
||||
}}
|
||||
onMouseDragEnd={() => (addPressed = false)}
|
||||
>
|
||||
{" + "}
|
||||
</text>
|
||||
|
||||
@@ -20,7 +20,7 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
|
||||
return (
|
||||
<Show when={list().length}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("mcp.list")}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={props.context.theme.text.default}>
|
||||
<Switch>
|
||||
<Match when={failed()}>
|
||||
@@ -56,7 +56,7 @@ function Plugins(props: { context: Plugin.Context }) {
|
||||
|
||||
return (
|
||||
<Show when={failed()}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("plugins.list")}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={props.context.theme.text.default}>
|
||||
<span style={{ fg: props.context.theme.text.feedback.error.default }}>⊙ </span>
|
||||
{failed()} plugin{failed() === 1 ? "" : "s"} failed
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
import { usePromptMove } from "../../component/prompt/move"
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const move = usePromptMove({
|
||||
projectID: () => props.context.data.session.get(props.sessionID)?.projectID,
|
||||
sessionID: () => props.sessionID,
|
||||
})
|
||||
const actions = useWorkingDirectoryActions({
|
||||
directory: () => props.context.location?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const directory = createMemo(() => {
|
||||
if (!props.context.location) return undefined
|
||||
const value = props.context.ui.format.path(props.context.location.directory)
|
||||
@@ -21,20 +11,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
})
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => (
|
||||
<box
|
||||
id="sidebar.footer.location"
|
||||
onMouseOver={actions.onMouseOver}
|
||||
onMouseOut={actions.onMouseOut}
|
||||
onMouseUp={actions.onMouseUp}
|
||||
>
|
||||
<FilePath
|
||||
value={value()}
|
||||
maxWidth={38}
|
||||
fg={actions.hovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -44,9 +21,6 @@ export default Plugin.define({
|
||||
setup(context) {
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
context.ui.slot({
|
||||
append: "sidebar.footer",
|
||||
render: (props) => <View context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
context.ui.slot({ append: "sidebar.footer", render: () => <View context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<Slot path="sidebar.footer" input={{ sessionID: props.sessionID }} />
|
||||
<Slot path="sidebar.footer" />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import open from "open"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useDialog } from "./dialog"
|
||||
import { DialogSelect } from "./dialog-select"
|
||||
import { useToast } from "./toast"
|
||||
|
||||
export function useWorkingDirectoryActions(input: { directory: () => string | undefined; onMove?: () => void }) {
|
||||
const clipboard = useClipboard()
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
|
||||
function openMenu() {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
const directory = input.directory()
|
||||
if (!directory) return
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Working directory"
|
||||
renderFilter={false}
|
||||
options={[
|
||||
{
|
||||
title: "Copy path",
|
||||
value: "location.copy",
|
||||
description: directory,
|
||||
onSelect: (dialog) => {
|
||||
void clipboard.write(directory).then(() => {
|
||||
dialog.clear()
|
||||
toast.show({ message: "Path copied to clipboard", variant: "info" })
|
||||
}, toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Open folder",
|
||||
value: "location.open",
|
||||
description: "in system file manager",
|
||||
onSelect: (dialog) => {
|
||||
dialog.clear()
|
||||
void open(directory).catch(toast.error)
|
||||
},
|
||||
},
|
||||
...(input.onMove
|
||||
? [
|
||||
{
|
||||
title: "Move session",
|
||||
value: "session.move",
|
||||
description: "to another working directory",
|
||||
onSelect: () => void input.onMove?.(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return {
|
||||
hovered,
|
||||
onMouseOver: () => setHovered(true),
|
||||
onMouseOut: () => setHovered(false),
|
||||
onMouseUp: openMenu,
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
|
||||
import { ThemeProvider } from "../../src/context/theme"
|
||||
import { emptyThemeSource } from "../fixture/fixture"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
test("releasing a transcript selection over tab controls does not activate them", async () => {
|
||||
const [active, setActive] = createSignal("first")
|
||||
const [added, setAdded] = createSignal(0)
|
||||
const controller = {
|
||||
tabs: () => [
|
||||
{ sessionID: "first", title: "First" },
|
||||
{ sessionID: "second", title: "Second" },
|
||||
],
|
||||
current: active,
|
||||
select: setActive,
|
||||
close() {},
|
||||
move() {},
|
||||
add: () => setAdded((value) => value + 1),
|
||||
status: () => EMPTY_SESSION_TAB_STATUS,
|
||||
} satisfies SessionTabsController
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<box flexDirection="column">
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
<text>selectable transcript text</text>
|
||||
</box>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 60, height: 3 },
|
||||
)
|
||||
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Second"))
|
||||
await app.mockMouse.pressDown(5, 1)
|
||||
await app.mockMouse.release(40, 0)
|
||||
expect(active()).toBe("first")
|
||||
|
||||
await app.mockMouse.click(40, 0)
|
||||
expect(active()).toBe("second")
|
||||
|
||||
await app.mockMouse.pressDown(5, 1)
|
||||
await app.mockMouse.release(58, 0)
|
||||
expect(added()).toBe(0)
|
||||
|
||||
await app.mockMouse.click(58, 0)
|
||||
expect(added()).toBe(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -107,12 +107,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
})
|
||||
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
||||
if (url.pathname === "/api/project") return json([])
|
||||
if (url.pathname === "/api/worktree/proj_test") {
|
||||
if (url.pathname === "/api/experimental/project/proj_test/worktree") {
|
||||
if (request.method === "GET") return json([{ directory: worktree }])
|
||||
if (request.method === "POST") return json({ directory: `${worktree}/created` })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/worktree/proj_test/refresh") return new Response(null, { status: 204 })
|
||||
if (url.pathname === "/api/experimental/project/proj_test/worktree/refresh")
|
||||
return new Response(null, { status: 204 })
|
||||
if (url.pathname === "/api/shell")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
|
||||
@@ -119,18 +119,17 @@ not included in model context.
|
||||
## Compaction advances the instruction epoch
|
||||
|
||||
Conversation compaction and instruction synchronization are separate. Before
|
||||
each physical model attempt, V2 compares live instruction sources with the
|
||||
latest admitted values, before delivering pending input for that attempt.
|
||||
Ordinary changes become durable value deltas. Later changes freeze their
|
||||
model-facing text when admitted and project it as chronological System messages;
|
||||
request assembly renders only the epoch baseline from stored values.
|
||||
promoting pending input, V2 compares live instruction sources with the latest
|
||||
admitted values. Ordinary changes become durable value deltas; their
|
||||
model-facing System messages are derived during request assembly rather than
|
||||
persisted.
|
||||
|
||||
Completed compaction advances the instruction epoch at the exact ended-event
|
||||
sequence and makes the currently admitted values initial. It does not reread
|
||||
sources or publish an instruction event. Session movement retains instruction
|
||||
state so destination changes become chronological updates. Committed revert
|
||||
clears instruction state so the next model attempt requires one complete source
|
||||
read. See [Instructions](/instructions) for source ordering and update behavior.
|
||||
sources or publish an instruction event. Session movement and committed revert
|
||||
clear the instruction fold so the next safe boundary requires one complete
|
||||
source read. See [Instructions](/instructions) for source ordering and update
|
||||
behavior.
|
||||
|
||||
## Current limitations
|
||||
|
||||
|
||||
@@ -102,9 +102,8 @@ than part of the initial instructions.
|
||||
|
||||
## Changes
|
||||
|
||||
Before each physical model attempt, V2 compares live instruction sources with
|
||||
the latest admitted source values. This comparison happens before pending input
|
||||
is delivered for that attempt:
|
||||
Before promoting pending input, V2 compares live instruction sources with the
|
||||
latest admitted source values:
|
||||
|
||||
- A new or changed ambient `AGENTS.md` aggregate is announced as a system update
|
||||
that replaces the previous ambient aggregate.
|
||||
@@ -116,13 +115,10 @@ is delivered for that attempt:
|
||||
- Completed conversation compaction advances the instruction epoch, making the
|
||||
currently admitted values initial without rereading sources or authoring an
|
||||
instruction event.
|
||||
- Moving a session retains instruction state, so destination changes become
|
||||
chronological updates. Committing a revert clears instruction state; the next
|
||||
model attempt requires one complete source read before delivering input.
|
||||
- Moving a session or committing a revert clears the instruction fold. The next
|
||||
safe boundary requires one complete source read before promoting input.
|
||||
|
||||
The durable event stores changed source keys and value hashes. Initial baseline
|
||||
events contain no rendered prose. Later changes render once when admitted and
|
||||
freeze that optional text in the event, which projects it as a chronological
|
||||
System message. During request assembly, OpenCode renders the epoch's initial
|
||||
values and reuses projected update messages verbatim. Clients see changed keys
|
||||
but never the privileged value bodies.
|
||||
The durable event stores changed source keys and value hashes, not rendered
|
||||
prose. During request assembly, OpenCode renders the epoch's initial values and
|
||||
interleaves later changes as chronological System messages. Clients see changed
|
||||
keys but never the privileged value bodies.
|
||||
|
||||
@@ -15,29 +15,20 @@ description: "Get started with OpenCode."
|
||||
|
||||
## Install
|
||||
|
||||
<CodeGroup>
|
||||
### Install script
|
||||
|
||||
```bash npm
|
||||
npm install -g @opencode-ai/cli@next
|
||||
```
|
||||
|
||||
```bash bun
|
||||
bun install -g --trust @opencode-ai/cli@next
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn global add @opencode-ai/cli@next
|
||||
```
|
||||
|
||||
```bash curl
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
You can also install it with the following package managers.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="npm">```bash npm install -g @opencode-ai/cli@next ```</Tab>
|
||||
<Tab title="bun">```bash bun install -g --trust @opencode-ai/cli@next ```</Tab>
|
||||
<Tab title="pnpm">```bash pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next ```</Tab>
|
||||
<Tab title="Yarn">```bash yarn global add @opencode-ai/cli@next ```</Tab>
|
||||
</Tabs>
|
||||
|
||||
The package uses a trusted postinstall script to select the native `opencode2` binary for your platform. The Bun and pnpm
|
||||
commands above explicitly allow that script to run.
|
||||
|
||||
+26
-28
@@ -10534,7 +10534,7 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/api/worktree/{projectID}": {
|
||||
"/api/experimental/project/{projectID}/worktree": {
|
||||
"get": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.list",
|
||||
@@ -10736,7 +10736,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/worktree/{projectID}/refresh": {
|
||||
"/api/experimental/project/{projectID}/worktree/refresh": {
|
||||
"post": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.refresh",
|
||||
@@ -23517,24 +23517,6 @@
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ConfigWebSearch.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["random"]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Plugin.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24053,15 +24035,14 @@
|
||||
}
|
||||
},
|
||||
"websearch": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ConfigWebSearch.Info"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
@@ -24164,6 +24145,20 @@
|
||||
"required": ["type", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.File": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["file"]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.AgentsDirectory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24200,6 +24195,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.Directory"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.File"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.AgentsDirectory"
|
||||
},
|
||||
|
||||
@@ -10534,7 +10534,7 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/api/worktree/{projectID}": {
|
||||
"/api/experimental/project/{projectID}/worktree": {
|
||||
"get": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.list",
|
||||
@@ -10736,7 +10736,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/worktree/{projectID}/refresh": {
|
||||
"/api/experimental/project/{projectID}/worktree/refresh": {
|
||||
"post": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.refresh",
|
||||
@@ -23517,24 +23517,6 @@
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ConfigWebSearch.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["random"]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Plugin.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24053,15 +24035,14 @@
|
||||
}
|
||||
},
|
||||
"websearch": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ConfigWebSearch.Info"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
@@ -24164,6 +24145,20 @@
|
||||
"required": ["type", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.File": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["file"]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.AgentsDirectory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24200,6 +24195,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.Directory"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.File"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Config.AgentsDirectory"
|
||||
},
|
||||
|
||||
+6
-5
@@ -27,11 +27,12 @@ Generated clients follow the assembled public `HttpApi`. GitHub issues own activ
|
||||
|
||||
## Decisions And Proposals
|
||||
|
||||
| Document | Status | Job |
|
||||
| ----------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------- |
|
||||
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
|
||||
| [Managed restart continuation](./session-restart-continuation.md) | Superseded decision record | Preserve the graceful-only design replaced by write-ahead execution claims. |
|
||||
| [Provider policy](./provider-policy.md) | Proposed and unimplemented | Explore provider authorization independently from provider configuration. |
|
||||
| Document | Status | Job |
|
||||
| ----------------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
|
||||
| [Managed restart continuation](./session-restart-continuation.md) | Accepted and implemented | Record why graceful managed-service restart uses private Session suspension. |
|
||||
| [Instruction sync](./instruction-sync-proposal.md) | Accepted and implemented | Record why instruction state is value deltas plus derived rendering. |
|
||||
| [Provider policy](./provider-policy.md) | Proposed and unimplemented | Explore provider authorization independently from provider configuration. |
|
||||
|
||||
## Historical Context
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Instruction Sync: V2 Architecture
|
||||
|
||||
Status: implemented on `instruction-sync-v2` (2026-07-10).
|
||||
|
||||
## Principle
|
||||
|
||||
The model is a replica that OpenCode can write but cannot read or edit. The transcript is the one-way channel. Instruction sync keeps mutable privileged context (`AGENTS.md`, guidance, API entries, date, and environment) current over that channel without rewriting text that was already sent.
|
||||
|
||||
**The durable log stores only irreducible facts: which source values changed, and when. Everything else is a function of the log and current renderer code.**
|
||||
|
||||
## Durable Fact
|
||||
|
||||
```typescript
|
||||
"session.instructions.updated.2" {
|
||||
sessionID: Session.ID
|
||||
delta: Record<Instructions.Key, Instructions.Hash | "removed">
|
||||
}
|
||||
```
|
||||
|
||||
A hash overwrites one source value. The literal `"removed"` removes it (chosen over JSON `null` because record-value nullability does not survive every client generator; it cannot collide with a 64-hex hash). The event stores no rendered text, mode, baseline, or snapshot.
|
||||
|
||||
Each hash body is canonical JSON stored once in the machine-local `instruction_blob` table. Hashes are local pointers, not cross-machine promises.
|
||||
|
||||
## Epochs And Folds
|
||||
|
||||
An instruction epoch is the span between completed compactions. `epochStart` is the sequence of the last `session.compaction.ended`, or the initial complete v2 delta when no epoch exists.
|
||||
|
||||
Folding deltas in durable sequence order derives:
|
||||
|
||||
```text
|
||||
values through epochStart -> renderInitial -> initial instructions
|
||||
each delta after epochStart -> renderUpdate -> chronological System message
|
||||
final values -> next boundary comparison state
|
||||
```
|
||||
|
||||
Completed compaction moves the epoch by copying current hashes to initial hashes at the exact ended-event sequence. It does not read sources or publish an instruction event.
|
||||
|
||||
Session movement and committed revert clear the fold. The next boundary must establish one complete delta before input promotion.
|
||||
|
||||
## Projection Cache
|
||||
|
||||
```text
|
||||
instruction_state
|
||||
session_id
|
||||
epoch_start
|
||||
through_seq
|
||||
initial_values
|
||||
current_values
|
||||
```
|
||||
|
||||
This row is derived state. The boundary compares `through_seq` with the latest relevant durable sequence. A missing or stale row folds the log and rewrites the cache without publishing an event.
|
||||
|
||||
The relevant reducer inputs are:
|
||||
|
||||
- `session.instructions.updated.2`: apply the delta; the first one establishes an epoch, including an empty complete delta.
|
||||
- `session.compaction.ended.1`: make current values initial and move `epochStart`.
|
||||
- `session.moved.1`: clear values.
|
||||
- `session.revert.committed.1`: clear values.
|
||||
- `session.forked.2`: derive from parent ancestry through its frozen `parentSeq`.
|
||||
|
||||
## Sources
|
||||
|
||||
```typescript
|
||||
interface Source {
|
||||
readonly key: Key
|
||||
readonly read: Effect<Json | Unavailable | Removed>
|
||||
readonly initial: (value: Json) => string | undefined
|
||||
readonly changed: (previous: Json, current: Json) => string | undefined
|
||||
readonly removed: (previous: Json) => string | undefined
|
||||
}
|
||||
|
||||
namespace Source {
|
||||
interface Definition<A> {
|
||||
readonly key: Key
|
||||
readonly codec: Schema.Codec<A, Json>
|
||||
readonly read: Effect<A | Unavailable | Removed>
|
||||
readonly render: {
|
||||
readonly initial: (value: A) => string
|
||||
readonly changed: (previous: A, current: A) => string
|
||||
readonly removed?: (previous: A) => string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Instructions = ReadonlyArray<Source>
|
||||
|
||||
declare function make<A>(definition: Source.Definition<A>): Instructions
|
||||
```
|
||||
|
||||
Producers author a typed `Source.Definition<A>`. `make` captures its codec and renderers in one JSON-level `Source`, the representation used for heterogeneous composition, durable values, and historical rendering. `Instructions` is an ordered collection of those sources; combining collections preserves order and rejects duplicate keys.
|
||||
|
||||
`read` runs once per source at the safe boundary, never at layer construction or request assembly. Codecs must be canonical: object keys are canonicalized by the hash function, while source-owned collections must have deterministic order and values must not contain observation timestamps.
|
||||
|
||||
`Unavailable` means the read failed temporarily. The initial complete delta blocks while any source is unavailable; later boundaries retain its prior hash silently.
|
||||
|
||||
`Removed` is an observed absence. If the key currently has a value, the next delta stores `"removed"` and assembly calls the source's removal renderer. A source that disappears from a software upgrade does not imply removal; its retained value becomes invisible while its renderer is absent.
|
||||
|
||||
## Safe Boundary
|
||||
|
||||
Once per physical attempt, before input promotion:
|
||||
|
||||
1. Load the selected agent and compose built-ins, discovery, skill guidance, reference guidance, MCP guidance, and API entries in fixed order.
|
||||
2. Read every source concurrently exactly once.
|
||||
3. Encode and hash values; compare with `instruction_state.current_values`.
|
||||
4. At the initial v2 boundary, require a complete read and admit one complete delta, including `{}` for a truly empty set.
|
||||
5. For later boundaries, insert new blobs and admit one delta only when a hash or explicit removal changed.
|
||||
6. Promote pending input.
|
||||
7. Read projected messages, epoch values, blobs, and post-epoch deltas in one database transaction.
|
||||
8. Render initial instructions and interleave derived update messages by durable sequence.
|
||||
|
||||
`MoveSession` interrupts any active drain and awaits idle before publishing `session.moved`, matching the best-effort ordering used by Session removal.
|
||||
|
||||
The blob inserts, durable event, and fold-cache advance share the event transaction.
|
||||
|
||||
## Forks
|
||||
|
||||
`session.forked.2` carries `parentSeq`, the authoritative parent event cutoff. For a fork before message N, the cutoff is `message.seq - 1`, so instruction changes admitted immediately before that message are inherited while later parent state is not.
|
||||
|
||||
The child stores the cutoff as `session.fork_seq`. Its virtual instruction log is the parent's ancestry through that cutoff followed by child events. Child event sequence reservation begins after the cutoff, preserving chronological interleaving with copied message rows. Replay accepts the intentional fork gap because the fork projector reserves the inherited prefix before later child events replay.
|
||||
|
||||
## API Entries
|
||||
|
||||
Each visible entry is one `api/<key>` source. DELETE marks the row as a hidden tombstone rather than physically removing it, preserving the renderer needed to admit and narrate the removal; list responses hide tombstones. The nullable value column preserves JSON `null`, while the separate tombstone flag distinguishes removal. A later PUT revives the same source.
|
||||
|
||||
PUT measures encoded JSON in UTF-8 and rejects values larger than 8KB with `InstructionEntryValueTooLargeError` (HTTP 413). Values are never truncated.
|
||||
|
||||
## Content-Addressed Storage
|
||||
|
||||
The blob store grows by one row per distinct encoded value. No GC ships initially. This is an at-rest deduplication policy; deleting a Session does not remove values only that Session referenced, so clients must not put secrets in API entries.
|
||||
|
||||
If retention becomes necessary, add mark-and-sweep: walk live v2 deltas for referenced hashes and delete the rest. No schema change or eager reference counter is required.
|
||||
|
||||
The blob store is machine/tenant scoped and must never deduplicate across tenants.
|
||||
|
||||
**Storage format is not wire format.** Any future V2 sync, export, share, or workspace-transfer boundary must hydrate referenced values, verify each body against its hash on ingestion, and insert blobs before replaying the event. Current V2 has no cross-machine durable replay surface; hashes are sufficient for local event logs and key-only clients.
|
||||
|
||||
## Client Projection
|
||||
|
||||
Instruction deltas do not project `session_message` rows. The TUI derives a non-model-facing notice from event keys, for example `Instructions updated: core/date, api/plan`. Model-facing update prose exists only during runner assembly and is excluded from compaction summaries.
|
||||
|
||||
## Migration
|
||||
|
||||
Migration deletes pre-beta `session.instructions.updated.1` events and their event-derived System rows, then drops `instruction_checkpoint`. It leaves unrelated events and System messages intact. The next safe boundary establishes one complete v2 delta.
|
||||
|
||||
Existing `session.forked.1` rows migrate to v2 with the event prefix reserved by their original projection as `parentSeq`.
|
||||
|
||||
## Accepted Costs
|
||||
|
||||
- Renderer changes can change request bytes for identical stored values, causing one provider-cache miss. They do not create an instruction delta.
|
||||
- Rendered text is not retained verbatim.
|
||||
- Source additions or software removals are silent unless a source explicitly reads `Removed`.
|
||||
- Clients display changed keys, not privileged prose.
|
||||
- Blob GC is deferred.
|
||||
- Pre-beta instruction events are deleted during migration; logs with resulting sequence gaps are not guaranteed to replay into a blank database.
|
||||
@@ -2,25 +2,12 @@
|
||||
|
||||
| Field | Value |
|
||||
| -------------- | ------------------------------------------------------------ |
|
||||
| Status | Superseded by write-ahead execution claims |
|
||||
| Status | Accepted and implemented |
|
||||
| Author | Kit Langton |
|
||||
| Date | 2026-07-08 |
|
||||
| Superseded | 2026-08-14 |
|
||||
| Tracking issue | [#35646](https://github.com/anomalyco/opencode/issues/35646) |
|
||||
|
||||
## Current Decision
|
||||
|
||||
Session execution now writes a durable claim when a process-local busy period starts. Success, failure, and user interruption release the claim. Shutdown interruption and process death preserve it, so graceful restart, crash, SIGKILL, and runtime eviction have the same durable recovery signature.
|
||||
|
||||
On startup, managed Node and fetch runtimes sweep claimed top-level Sessions. Recovery increments a durable attempt counter, appends a continuation instruction, and resumes from projected history. The claim remains until a terminal event releases it, so another process death remains recoverable. After ten automatic recovery attempts by default, the next sweep records terminal failure instead of creating a restart loop.
|
||||
|
||||
The historical `time_suspended` column now stores this execution claim, and `resume_attempts` counts automatic recovery attempts against the runtime's configured budget. A claim is a recovery marker, not live status, a lock, clustered ownership, or an exactly-once guarantee. Recovery fails stale running tool projections before further model work, but it cannot prove whether an interrupted provider request or external side effect already took effect.
|
||||
|
||||
See the current [Session contract](./session.md) and the implementation in `packages/core/src/session/execution.ts` and `packages/core/src/session/execution/restart.ts`.
|
||||
|
||||
## Original Summary
|
||||
|
||||
The remainder of this document records the graceful-only suspension design that first implemented issue #35646. It is retained as design history and does not describe the current recovery mechanism.
|
||||
## Summary
|
||||
|
||||
When the managed OpenCode server shuts down gracefully, active Sessions continue automatically the next time the managed server starts.
|
||||
|
||||
|
||||
+18
-18
@@ -1,12 +1,12 @@
|
||||
# V2 Session Contract
|
||||
|
||||
Status: **Current semantic overview.** Protocol owns public operations, Schema owns public shapes and durable events, and Core owns execution and persistence behavior.
|
||||
Status: **Current semantic overview.** Protocol owns public operations, Schema owns public shapes and durable events, and Core owns execution and persistence behavior. [CONTEXT.md](../../CONTEXT.md) defines the canonical terms used here.
|
||||
|
||||
## Prompt Admission Precedes Execution
|
||||
|
||||
`Session.prompt(...)` publishes one durable `session.inbox.enqueued` fact whose projection inserts one `session_inbox` row before advisory execution begins. An inbox item remains outside model-visible Session History until delivery. The `session.inbox.delivered` projection consumes the row and inserts a visible user or synthetic message atomically; compaction and move control items are consumed without becoming transcript messages.
|
||||
`SessionV2.prompt(...)` records one durable `session.input.admitted` fact and one `session_pending` row before advisory execution begins. Pending input remains outside model-visible Session History until promotion. The promotion transaction publishes `session.input.promoted`, projects the visible message, and consumes the pending row atomically.
|
||||
|
||||
Reusing a Session ID adopts the existing Session. While a user or synthetic item remains pending, reusing its ID reconciles only when Session, item type, complete payload, metadata, and delivery match; conflicting reuse fails. After delivery, retry reconciliation for those message-producing items uses the projected message and does not require enqueue history or the original delivery mode. Compaction and move controls retain operation-specific conflict behavior.
|
||||
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. A retry of an already-promoted input reconciles against projected history and its durable admission event.
|
||||
|
||||
`resume` controls scheduling, not durability:
|
||||
|
||||
@@ -15,12 +15,12 @@ Reusing a Session ID adopts the existing Session. While a user or synthetic item
|
||||
|
||||
Delivery is explicit:
|
||||
|
||||
- `steer` is the default. Steers deliver in enqueue order at the next Safe Step Boundary. Delivery stops before a compaction or move control item.
|
||||
- `queue` remains pending while the Session can continue. At an idle boundary, steers still take priority; otherwise one queued item delivers, followed by any steers that arrived during delivery. The runner then reevaluates continuation before another queued item.
|
||||
- `steer` is the default. Steers promote together at the next Safe Step Boundary while the current Session Drain still requires continuation.
|
||||
- `queue` remains pending while the Session can continue. When the Session would otherwise become idle, one queued input promotes; the runner then reevaluates continuation before promoting another.
|
||||
|
||||
Promoting new user input resets the selected agent's step allowance. A batch of steers resets it once.
|
||||
|
||||
Manual compaction and Session movement use the same inbox as control items. Each request has its own inbox identity and delivery mode. A control item forms a delivery boundary so later steers do not cross it.
|
||||
Manual compaction uses the same pending store as one coalesced barrier. The barrier blocks later input promotion until compaction ends or fails, then is consumed.
|
||||
|
||||
## Execution Is Process-Local
|
||||
|
||||
@@ -35,37 +35,37 @@ Manual compaction and Session movement use the same inbox as control items. Each
|
||||
|
||||
The public interrupt operation verifies that the durable Session exists. An unknown Session fails with `SessionNotFoundError`; a known Session that is idle, settled, or not locally owned is a no-op.
|
||||
|
||||
`sessions.active()` snapshots busy periods currently owned by this process. Durable execution events and claims are historical and recovery records, not proof that this process is still live.
|
||||
`sessions.active()` snapshots foreground drains currently owned by this process. Durable execution events are historical observations, not liveness or ownership records.
|
||||
|
||||
Execution commits a write-ahead claim when a process-local busy period starts. Success, failure, and user interruption release the claim; shutdown interruption and unclean process death preserve it. On startup, managed Node and fetch runtimes resume claimed top-level Sessions, append a durable continuation instruction, and count recovery attempts. Recovery is bounded per claimed execution but does not guarantee exactly-once provider requests or tool effects. See [Session restart recovery](./session-restart-continuation.md).
|
||||
The managed server provides graceful restart continuity through private Session suspension. Shutdown marks active Sessions before interrupting them; the next managed server atomically consumes each suspension and schedules at most one resume. Hard-crash recovery and exactly-once provider or tool execution remain out of scope. See [Managed restart continuation](./session-restart-continuation.md).
|
||||
|
||||
## One Step May Have Several Physical Attempts
|
||||
## One Step Owns One Logical LLM Call
|
||||
|
||||
Before each Step, the runner reloads Session History, resolves the selected agent and model, prepares instructions, and materializes tools. Most Steps make one Physical Attempt. Generic retry, continuation-state rejection, incomplete-stream continuation, or overflow-triggered compaction may make another attempt without promoting input again.
|
||||
Before each Step, the runner reloads Session History, resolves the selected agent and model, prepares instructions, and materializes tools. Most Steps make one Physical Attempt; overflow-triggered compaction recovery may rebuild the same Step for one additional provider request.
|
||||
|
||||
Each complete local tool call is durable before side effects begin. Local calls start eagerly and may run concurrently, but terminal outcome publication remains serialized. Every local and hosted call reaches durable success or failure before the Step publishes its single terminal ended or failed event.
|
||||
|
||||
Tool calls belong to their assistant message. A tool-call `id` is unique only within that Step, so durable tool events also carry `assistantMessageID`.
|
||||
Tool calls belong to their assistant message. `callID` is unique only within that Step, so durable tool events also carry `assistantMessageID`.
|
||||
|
||||
At drain start, orphan reconciliation fails tool calls still projected as streaming or running from an earlier process before further model work. It preserves the original assistant attribution and never directly replays ambiguous side effects.
|
||||
Before `runStep` assembles its provider request, orphan reconciliation fails tool calls still projected as streaming or running from an earlier process. It preserves the original assistant attribution and never replays ambiguous side effects.
|
||||
|
||||
After a local outcome, continuation reloads projected history and begins a new Step. The runner never delegates orchestration to an in-memory tool loop.
|
||||
|
||||
## Retry Is Narrow And Observable
|
||||
|
||||
Generic scheduled retry covers rate-limit and provider-internal failures, transport failures that are unsent or have unknown delivery, and provider output classified as an incomplete stream. The initial request plus at most four retries use jittered exponential backoff, increased when the provider supplies a longer retry delay.
|
||||
Core retries typed rate-limit, provider-internal, and transport failures only before durable assistant content, tool-call, tool-output, or tool-execution evidence exists. The initial request plus at most four retries use exponential backoff, increased when the provider supplies a longer retry delay.
|
||||
|
||||
Before durable output, generic retries retain the logical step number and assistant message ID and do not consume another agent-step allowance. An incomplete stream after durable output instead preserves the failed partial assistant, adds a synthetic continuation instruction, and continues with a new assistant message ID under the same retry budget. Provider continuation rejection permits one immediate full-context rebuild without a scheduled-retry event. `session.retry.scheduled` records generic backoff; later activity or a terminal execution event clears projected retry state.
|
||||
Each retry attempt is a distinct Step, consumes the selected agent's allowance, and reuses the assistant message ID while no durable output exists. `session.retry.scheduled` records the next attempt and absolute retry time. A later Step start or terminal failure clears projected retry state. Surviving retry history never triggers post-crash recovery by itself.
|
||||
|
||||
A normalized content-filter finish fails the Step. Any partial streamed content remains visible.
|
||||
|
||||
## Instructions Are Value Deltas
|
||||
|
||||
Instruction sync persists content-addressed values and may freeze rendered chronological prose. `session.instructions.updated { delta, text? }` maps each changed source key to a SHA-256 content hash, with the literal `"removed"` for observed absence. Canonical JSON bodies live once in the machine-local `instruction_blob` store. The projected `instruction_state` row supplies current and epoch-initial values during normal boundary processing. The runner explicitly combines built-ins, ambient discovery, selected-agent skill guidance, references, MCP guidance, and API-managed instruction entries. There is no instruction registry.
|
||||
Instruction sync persists values, never rendered privileged prose. The only durable fact is `session.instructions.updated { delta }`, mapping each changed source key to a SHA-256 content hash, with the literal `"removed"` for observed absence. Canonical JSON bodies live once in the machine-local `instruction_blob` store; `instruction_state` is a rebuildable fold cache, never primary state. The runner explicitly combines built-ins, ambient discovery, selected-agent skill guidance, references, MCP guidance, and API-managed instruction entries. There is no instruction registry.
|
||||
|
||||
Before each Physical Attempt that reaches model execution, the runner reads every source concurrently exactly once, hashes encoded values, and admits one delta atomically with its new blobs before input delivery. The initial delta must be complete; it carries no update text. An unavailable source blocks only that initial delta and otherwise silently retains the stored value. Request assembly renders the epoch baseline from stored values. Later changes render once at admission, freeze optional `text` in the durable event, and project that text as a chronological System message; clients display changed keys rather than privileged prose.
|
||||
At each Safe Step Boundary the runner reads every source concurrently exactly once, hashes encoded values, and admits one delta atomically with its new blobs before input promotion. The initial delta must be complete; an unavailable source blocks only that initial delta and otherwise silently retains the stored value. Initial instructions and chronological update messages are rendered from stored values during request assembly and are never persisted; clients display changed keys.
|
||||
|
||||
An instruction epoch spans completed compactions. `session.compaction.ended` moves the epoch start to its exact sequence, making current values initial, without reading sources or authoring an instruction event. Session movement retains state so destination changes become chronological updates; committed revert clears state so the next boundary establishes a fresh baseline. A fork copies messages only through its selected boundary but adopts the parent's newest instruction values as its baseline. Model selection affects request assembly but is not itself an instruction source.
|
||||
An instruction epoch spans completed compactions. `session.compaction.ended` moves the epoch start to its exact sequence, making current values initial, without reading sources or authoring an instruction event. Session movement and committed revert clear the fold. Forks record an authoritative parent sequence and derive values from the parent's ancestry through that cutoff. Model selection affects request assembly but is not itself an instruction source. See the [instruction sync design](./instruction-sync-proposal.md).
|
||||
|
||||
## Compaction Rebuilds Active History
|
||||
|
||||
@@ -85,6 +85,6 @@ There is no separate finite Session-history endpoint. Request/response consumers
|
||||
|
||||
## Recovery Boundaries Stay Explicit
|
||||
|
||||
An advisory wake is not itself crash recovery. Crash recovery is driven by a write-ahead execution claim that survives without a releasing terminal. Startup recovery resumes claimed top-level Sessions from durable projected history with bounded attempt accounting. It fails stale running tool projections before continuing, but it cannot prove whether an interrupted external operation already took effect and does not guarantee exactly-once provider or tool behavior.
|
||||
An advisory wake does not infer that ambiguous provider work is safe to retry after input promotion. Explicit resume may continue from durable projected history, but automatic hard-crash continuation requires a separate design covering provider-dispatch ambiguity, tool idempotency, retry budgets, and future clustered ownership.
|
||||
|
||||
Event replay ownership is separate from Session execution ownership. Local execution remains process-owned until clustering introduces an explicit placement and fencing protocol.
|
||||
|
||||
Reference in New Issue
Block a user