mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53a93aa494 | |||
| 153d7a9719 | |||
| ab7a0bf65c | |||
| 552fd40ef8 | |||
| 8afcb3870e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Apply shared Session model-request preparation to transient generation.
|
||||
+63
-223
@@ -1,272 +1,112 @@
|
||||
# Contributing to OpenCode
|
||||
|
||||
We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged:
|
||||
The changes most likely to be accepted are:
|
||||
|
||||
- Bug fixes
|
||||
- Additional LSPs / Formatters
|
||||
- Improvements to LLM performance
|
||||
- Support for new providers
|
||||
- Fixes for environment-specific quirks
|
||||
- Additional LSPs and formatters
|
||||
- LLM performance improvements
|
||||
- Environment-specific fixes
|
||||
- Missing standard behavior
|
||||
- Documentation improvements
|
||||
|
||||
However, any UI or core product feature must go through a design review with the core team before implementation.
|
||||
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).
|
||||
|
||||
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)
|
||||
Want to take on an issue? Leave a comment and a maintainer may assign it unless it is already being worked on.
|
||||
|
||||
> [!NOTE]
|
||||
> PRs that ignore these guardrails will likely be closed.
|
||||
|
||||
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.
|
||||
## Adding Providers
|
||||
|
||||
## Adding New Providers
|
||||
New providers should rarely require OpenCode changes. Add the provider to [models.dev](https://github.com/anomalyco/models.dev) first.
|
||||
|
||||
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
|
||||
## Development
|
||||
|
||||
## 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:
|
||||
OpenCode requires Bun 1.3 or newer. From the repository root:
|
||||
|
||||
```bash
|
||||
bun dev <directory>
|
||||
bun install
|
||||
bun dev [directory]
|
||||
```
|
||||
|
||||
To run OpenCode in the root of the opencode repo itself:
|
||||
`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:
|
||||
|
||||
```bash
|
||||
bun dev .
|
||||
bun run dev:live [directory]
|
||||
```
|
||||
|
||||
### Building a "localcode"
|
||||
|
||||
To compile a standalone executable:
|
||||
For web development, run the backend and app in separate terminals. Other interfaces have root scripts:
|
||||
|
||||
```bash
|
||||
./packages/opencode/script/build.ts --single
|
||||
bun dev serve --port 4096
|
||||
bun run dev:web
|
||||
bun run dev:desktop
|
||||
bun run dev:www
|
||||
```
|
||||
|
||||
Then run it with:
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
./packages/opencode/dist/opencode-<platform>/bin/opencode
|
||||
cd packages/core
|
||||
bun run test
|
||||
bun typecheck
|
||||
```
|
||||
|
||||
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
|
||||
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.
|
||||
|
||||
- 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`
|
||||
Follow the repository [style guide](./AGENTS.md).
|
||||
|
||||
### Understanding bun dev vs opencode
|
||||
## Pull Requests
|
||||
|
||||
During development, `bun dev` is the local equivalent of the built `opencode` command. Both run the same CLI interface:
|
||||
### Link Issues When Required
|
||||
|
||||
```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
|
||||
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.
|
||||
|
||||
# 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
|
||||
```
|
||||
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.
|
||||
|
||||
### Running the API Server
|
||||
Base branches on `v2`, not `dev`, and complete the provided pull request template.
|
||||
|
||||
To start the OpenCode headless API server:
|
||||
### Keep It Focused
|
||||
|
||||
```bash
|
||||
bun dev serve
|
||||
```
|
||||
- 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.
|
||||
|
||||
This starts the headless server on port 4096 by default. You can specify a different port:
|
||||
### Keep It Brief
|
||||
|
||||
```bash
|
||||
bun dev serve --port 8080
|
||||
```
|
||||
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.
|
||||
|
||||
### Running the Web App
|
||||
### Use Conventional Titles
|
||||
|
||||
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
|
||||
Use `type(scope): summary`. Supported types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. The scope is optional.
|
||||
|
||||
Examples:
|
||||
|
||||
- `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`
|
||||
- `docs: update contributing guide`
|
||||
- `fix(tui): restore scroll position`
|
||||
- `feat(app): add workspace search`
|
||||
|
||||
### Style Preferences
|
||||
## Issues
|
||||
|
||||
These are not strictly enforced, they are just general guidelines:
|
||||
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).
|
||||
|
||||
- **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.
|
||||
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.
|
||||
|
||||
@@ -568,6 +568,7 @@
|
||||
"@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:",
|
||||
|
||||
@@ -1,396 +1,3 @@
|
||||
export * as PluginPromise from "./promise.js"
|
||||
|
||||
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)),
|
||||
}),
|
||||
)
|
||||
export { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
|
||||
@@ -789,10 +789,7 @@ const layer = Layer.effect(
|
||||
return false
|
||||
}),
|
||||
)
|
||||
if (recovered) {
|
||||
yield* execution.wakeActive(input.sessionID)
|
||||
return
|
||||
}
|
||||
if (recovered) return
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
@@ -878,7 +875,8 @@ const layer = Layer.effect(
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* execution.interrupt(sessionID, options)
|
||||
yield* execution.interrupt(sessionID)
|
||||
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as SessionExecution from "./execution.js"
|
||||
|
||||
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionEvent } from "./event.js"
|
||||
@@ -12,7 +11,6 @@ import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { UserInterruptedError } from "./error.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
|
||||
export interface Interface {
|
||||
/** Snapshots active execution owned by this process. */
|
||||
@@ -21,10 +19,8 @@ export interface Interface {
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Wakes only an active execution, preserving its current input eligibility. */
|
||||
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -49,7 +45,6 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||
effect.pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -76,13 +71,12 @@ export const layer = Layer.effect(
|
||||
sessionID: SessionSchema.ID,
|
||||
force: boolean,
|
||||
continuation?: SessionRunner.Continuation,
|
||||
promotable: SessionInbox.Promotable = "input",
|
||||
): Effect.Effect<void, SessionRunner.RunError> {
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
runner.drain({ sessionID, force, continuation }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -92,7 +86,7 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
if (result.type === "complete") return
|
||||
return yield* drain(sessionID, false, result.continuation, promotable)
|
||||
return yield* drain(sessionID, false, result.continuation)
|
||||
})
|
||||
}
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
@@ -101,7 +95,7 @@ export const layer = Layer.effect(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
),
|
||||
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
|
||||
drain: (sessionID, force) => drain(sessionID, force),
|
||||
// One terminal observation per busy period, covering every coalesced drain.
|
||||
settled: (sessionID, exit, reason) =>
|
||||
reportLifecycle(
|
||||
@@ -133,20 +127,16 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
})
|
||||
yield* bus.subscribe(SessionEvent.Moved).pipe(
|
||||
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
active: coordinator.active,
|
||||
interrupt: (sessionID, options) =>
|
||||
coordinator.interrupt(
|
||||
sessionID,
|
||||
"user",
|
||||
options?.continue
|
||||
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
|
||||
: undefined,
|
||||
),
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
@@ -155,7 +145,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
@@ -165,7 +155,6 @@ export const noopLayer = Layer.succeed(
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
|
||||
@@ -1,85 +1,54 @@
|
||||
export * as SessionGenerateNode from "./generate-node.js"
|
||||
|
||||
import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { LLMClient, Message } 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 { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
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 app = yield* App.Metadata
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
|
||||
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 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
|
||||
: SessionSystemPrompt.make(toolDefinitions.map((tool) => tool.name)),
|
||||
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 transcript = SessionModelRequest.baseTranscript({
|
||||
agent: selection.agent.info,
|
||||
model,
|
||||
tools: selection.tools,
|
||||
initial: history.initial,
|
||||
messages: history.messages,
|
||||
})
|
||||
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 })]
|
||||
: []
|
||||
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),
|
||||
],
|
||||
},
|
||||
})
|
||||
yield* Effect.logInfo("sending session generation request", {
|
||||
sessionID: selection.session.id,
|
||||
providerID: model.ref.providerID,
|
||||
modelID: model.ref.id,
|
||||
})
|
||||
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,
|
||||
}),
|
||||
)
|
||||
const response = yield* llm.generate(prepared.request, prepared.options)
|
||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||
return response.text
|
||||
}),
|
||||
@@ -90,5 +59,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: SessionGenerate.Service,
|
||||
layer,
|
||||
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
|
||||
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient],
|
||||
})
|
||||
|
||||
@@ -349,14 +349,6 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
|
||||
export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: Promotable,
|
||||
) {
|
||||
return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
|
||||
})
|
||||
|
||||
/**
|
||||
* Which pending rows count: "any" counts every row, while "input" means any
|
||||
* item in either delivery mode.
|
||||
|
||||
@@ -12,15 +12,16 @@ 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 { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
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
|
||||
@@ -47,20 +48,49 @@ 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, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
* One request-scoped execution operation. Unknown and hook-removed 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 context: SessionContext.Loaded
|
||||
readonly step: number
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
const mimeToModality = (mime: string) => {
|
||||
@@ -174,30 +204,11 @@ 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.context.session
|
||||
const agent = input.context.agent
|
||||
const resolved = input.context.model
|
||||
const session = input.scope.session
|
||||
const resolved = input.scope.model
|
||||
const model = resolved.model
|
||||
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 : SessionSystemPrompt.make(tools.definitions.map((tool) => tool.name)),
|
||||
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 tools = input.scope.tools
|
||||
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.
|
||||
@@ -209,10 +220,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: agent.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system,
|
||||
messages,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.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
|
||||
@@ -235,7 +246,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: stepLimitReached ? "none" : undefined,
|
||||
toolChoice: input.toolChoice,
|
||||
})
|
||||
const webSocketEligible =
|
||||
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
|
||||
@@ -243,37 +254,20 @@ export const layer = Layer.effect(
|
||||
? undefined
|
||||
: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
...(http ? { http } : {}),
|
||||
...(webSocket &&
|
||||
...(input.webSocket === "session" &&
|
||||
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))
|
||||
@@ -285,9 +279,7 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
webSocketEligible,
|
||||
executeTool,
|
||||
stepLimitReached,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SessionRunCoordinator from "./run-coordinator.js"
|
||||
|
||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
import type { Promotable } from "./inbox.js"
|
||||
|
||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||
export interface Coordinator<Key, E, Reason = never> {
|
||||
@@ -10,41 +9,26 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
|
||||
/** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
|
||||
readonly wakeActive: (key: Key) => Effect.Effect<void>
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||
readonly interrupt: (
|
||||
key: Key,
|
||||
reason?: Reason,
|
||||
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
|
||||
) => Effect.Effect<void>
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type Request = Promotable
|
||||
|
||||
/**
|
||||
* One execution is a busy period for one key: one fiber that drains from the first wake
|
||||
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
||||
* execution rings it with its eligibility request, and the execution loop drains again
|
||||
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
|
||||
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
|
||||
* with this execution's exit.
|
||||
* execution rings it, and the execution loop drains again instead of ending. The doorbell
|
||||
* closes the gap between a drain's last eligibility check and the idle transition, since
|
||||
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
||||
*/
|
||||
type Execution<E, Reason> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void>
|
||||
request: Request
|
||||
pendingWake?: Request
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
interruptionReason?: Reason
|
||||
continuation?: {
|
||||
readonly request: Request
|
||||
readonly when: Effect.Effect<boolean>
|
||||
signaled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +43,7 @@ type Execution<E, Reason> = {
|
||||
* ```
|
||||
*/
|
||||
export const make = <Key, E, Reason = never>(options: {
|
||||
readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||
readonly started?: (key: Key) => Effect.Effect<void>
|
||||
/**
|
||||
@@ -71,26 +55,23 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
Effect.gen(function* () {
|
||||
const executions = new Map<Key, Execution<E, Reason>>()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const merge = (left: Request, right: Request): Request =>
|
||||
left === "input" || right === "input" ? "input" : "steer"
|
||||
|
||||
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
|
||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.suspend(() => {
|
||||
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
|
||||
execution.request = execution.pendingWake
|
||||
execution.pendingWake = undefined
|
||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
||||
execution.pendingWake = false
|
||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const start = (key: Key, force: boolean, request: Request) => {
|
||||
const start = (key: Key, force: boolean) => {
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
request,
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
}
|
||||
executions.set(key, execution)
|
||||
@@ -106,7 +87,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
execution.owner = undefined
|
||||
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
|
||||
),
|
||||
Effect.onExit((exit) => finish(key, execution, exit)),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
@@ -116,22 +97,12 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, continuation: boolean) => {
|
||||
if (continuation && execution.continuation) start(key, false, execution.continuation.request)
|
||||
else if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
|
||||
const finish = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (!execution.continuation) return Effect.sync(() => settle(key, execution, exit, false))
|
||||
return execution.continuation.when.pipe(
|
||||
Effect.flatMap((ready) =>
|
||||
Effect.sync(() => settle(key, execution, exit, ready || execution.continuation?.signaled === true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
@@ -140,57 +111,26 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done)
|
||||
}
|
||||
return Deferred.await(start(key, true, "input").done)
|
||||
return Deferred.await(start(key, true).done)
|
||||
})
|
||||
|
||||
const wake = (key: Key, request: Request = "input") =>
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
if (execution.stopping) {
|
||||
if (execution.continuation) execution.continuation.signaled = true
|
||||
else execution.continuation = { request, when: Effect.succeed(true), signaled: true }
|
||||
return
|
||||
}
|
||||
execution.pendingWake = execution.pendingWake ? merge(execution.pendingWake, request) : request
|
||||
execution.pendingWake = true
|
||||
return
|
||||
}
|
||||
start(key, false, request)
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const wakeActive = (key: Key) =>
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
return execution ? wake(key, execution.request) : Effect.void
|
||||
})
|
||||
|
||||
const interrupt = (
|
||||
key: Key,
|
||||
reason?: Reason,
|
||||
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
|
||||
): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined) return Effect.void
|
||||
if (execution.stopping) {
|
||||
if (options?.continue)
|
||||
execution.continuation = {
|
||||
...options.continue,
|
||||
signaled: execution.continuation?.signaled ?? false,
|
||||
}
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
|
||||
}
|
||||
if (execution.owner === undefined) {
|
||||
if (!options?.continue) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = undefined
|
||||
execution.continuation = { ...options.continue, signaled: false }
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
|
||||
}
|
||||
if (execution?.owner === undefined || execution.stopping) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = undefined
|
||||
execution.pendingWake = false
|
||||
execution.interruptionReason = reason
|
||||
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
|
||||
@@ -203,5 +143,5 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as SessionRunner from "./index.js"
|
||||
import type { AIError } from "@opencode-ai/ai"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import type { Promotable } from "../inbox.js"
|
||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
|
||||
import { SessionRunnerModel } from "./model.js"
|
||||
import type { Instructions } from "../../instructions/index.js"
|
||||
@@ -30,8 +29,6 @@ export interface Interface {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
/** "steer" settles the active intent without promoting queued next-turn work. */
|
||||
readonly promotable?: Promotable
|
||||
}) => Effect.Effect<DrainResult, RunError>
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
LLMClient,
|
||||
AIError,
|
||||
LLMEvent,
|
||||
Message,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Cause, Config, 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"
|
||||
@@ -34,6 +35,9 @@ 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<{
|
||||
@@ -116,6 +120,32 @@ 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>()
|
||||
@@ -128,25 +158,22 @@ const layer = Layer.effect(
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
readonly promotable?: SessionInbox.Promotable
|
||||
}) {
|
||||
let force = input.force
|
||||
let continuation = input.continuation
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
|
||||
return { type: "complete" as const }
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(input.sessionID, promotable)) {
|
||||
if (yield* runPendingCompaction(input.sessionID)) {
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
|
||||
return { type: "complete" as const }
|
||||
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
||||
const result = yield* runSteps(input.sessionID, continuation)
|
||||
if (result.type === "moved") return result
|
||||
if (promotable === "steer") return { type: "complete" as const }
|
||||
force = false
|
||||
continuation = undefined
|
||||
}
|
||||
@@ -159,14 +186,13 @@ const layer = Layer.effect(
|
||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
continuation?: Continuation,
|
||||
initialPromotable: SessionInbox.Promotable = continuation ? "steer" : "input",
|
||||
) {
|
||||
// Fresh work may promote queued input; later steps absorb steers only.
|
||||
let promotable = initialPromotable
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
|
||||
let step = continuation?.step ?? 1
|
||||
let next = continuation
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(sessionID, "steer")) continue
|
||||
if (yield* runPendingCompaction(sessionID)) continue
|
||||
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
next = result.needsContinuation ? { step: result.step + 1 } : undefined
|
||||
@@ -282,10 +308,32 @@ const layer = Layer.effect(
|
||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
||||
return yield* new StepFailedError({ error: compacted.error })
|
||||
}
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
context: loaded,
|
||||
step: currentStep,
|
||||
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",
|
||||
})
|
||||
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
|
||||
@@ -299,7 +347,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: model.route.providerMetadataKey ?? model.provider,
|
||||
providerMetadataKey: transcript.providerMetadataKey,
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
@@ -360,7 +408,7 @@ const layer = Layer.effect(
|
||||
call: event,
|
||||
fiber: yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
prepared.executeTool({
|
||||
executeTool({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
messageID: assistantMessageID,
|
||||
@@ -508,7 +556,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:
|
||||
!prepared.stepLimitReached &&
|
||||
!stepLimitReached &&
|
||||
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
|
||||
step: currentStep,
|
||||
})
|
||||
@@ -519,14 +567,14 @@ const layer = Layer.effect(
|
||||
/** Executes a previously admitted manual compaction request, if one is pending. */
|
||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
const selected =
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
|
||||
if (selected?.type !== "compaction") return
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
||||
@@ -568,7 +616,9 @@ const layer = Layer.effect(
|
||||
return yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
const pending =
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ??
|
||||
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
|
||||
if (pending?.type !== "move") return false
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -14,7 +15,10 @@ 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"
|
||||
@@ -23,6 +27,53 @@ 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({
|
||||
@@ -44,6 +95,42 @@ 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 = {
|
||||
@@ -114,6 +201,7 @@ describe("fromPromise", () => {
|
||||
ctx.skill.list(),
|
||||
])
|
||||
seen.push(...results.map((result) => result.location.directory))
|
||||
expect((await ctx.integration.get({ integrationID: "missing" })).data).toBeNull()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -51,15 +52,17 @@ 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) =>
|
||||
generate: (request, requestOptions) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(request)
|
||||
options.push(requestOptions)
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "generate" }),
|
||||
@@ -221,6 +224,7 @@ 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)
|
||||
@@ -292,6 +296,7 @@ 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" })
|
||||
@@ -321,6 +326,8 @@ 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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -31,7 +31,6 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const executionCalls: Session.ID[] = []
|
||||
const interruptCalls: Session.ID[] = []
|
||||
const interruptContinuations: Array<boolean | undefined> = []
|
||||
const wakeCalls: Session.ID[] = []
|
||||
const activeSessions = new Set<Session.ID>()
|
||||
const execution = Layer.succeed(
|
||||
@@ -42,16 +41,14 @@ const execution = Layer.succeed(
|
||||
Effect.sync(() => {
|
||||
executionCalls.push(sessionID)
|
||||
}),
|
||||
interrupt: (sessionID, options) =>
|
||||
interrupt: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
interruptCalls.push(sessionID)
|
||||
interruptContinuations.push(options?.continue)
|
||||
}),
|
||||
wake: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
wakeCalls.push(sessionID)
|
||||
}),
|
||||
wakeActive: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
@@ -180,18 +177,31 @@ describe("Session.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards interrupt continuation policy", () =>
|
||||
it.effect("continues after interruption when pending work remains", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
yield* session.synthetic({ sessionID, text: "Continue after interrupt", resume: false })
|
||||
interruptCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.interrupt(sessionID, { continue: true })
|
||||
|
||||
expect(interruptCalls).toEqual([sessionID])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue after interruption without pending work", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
interruptCalls.length = 0
|
||||
interruptContinuations.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.interrupt(sessionID, { continue: true })
|
||||
|
||||
expect(interruptCalls).toEqual([sessionID])
|
||||
expect(interruptContinuations).toEqual([true])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -269,35 +269,6 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("replaces a settlement-window wake with a steer continuation", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const settling = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) => Effect.sync(() => requests.push(request)),
|
||||
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.await(settling)
|
||||
yield* coordinator.wake("session", "input")
|
||||
const interrupted = yield* coordinator
|
||||
.interrupt("session", undefined, {
|
||||
continue: { request: "steer", when: Effect.succeed(true) },
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(interrupted)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("interrupts active execution and clears its pending wake", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -371,193 +342,6 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("coalesces drain requests with input taking precedence", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["steer", "input"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not carry a completed input request into a steer drain", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("an active wake inherits scope without starting idle work", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wakeActive("session")
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wakeActive("session")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["steer", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("coalesces overlapping interrupt continuations into one steer successor", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const cleanupStarted = yield* Deferred.make<void>()
|
||||
const cleanupGate = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Effect.never.pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
const continuation = { continue: { request: "steer" as const, when: Effect.succeed(false) } }
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(firstStarted)
|
||||
const first = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(cleanupStarted)
|
||||
const second = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.succeed(cleanupGate, undefined)
|
||||
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("a continuing interrupt replaces a cleanup-era input wake", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const cleanupStarted = yield* Deferred.make<void>()
|
||||
const cleanupGate = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Effect.never.pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.await(firstStarted)
|
||||
const plain = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(cleanupStarted)
|
||||
yield* coordinator.wake("session", "input")
|
||||
const continuing = yield* coordinator
|
||||
.interrupt("session", undefined, {
|
||||
continue: { request: "steer", when: Effect.succeed(false) },
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Deferred.succeed(cleanupGate, undefined)
|
||||
yield* Effect.all([Fiber.join(plain), Fiber.join(continuing)])
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not start a conditional continuation without eligible work", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.sync(() => requests.push(request)).pipe(
|
||||
Effect.andThen(Deferred.succeed(started, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(started)
|
||||
yield* coordinator.interrupt("session", undefined, {
|
||||
continue: { request: "steer", when: Effect.succeed(false) },
|
||||
})
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("starts a resume registered during interruption cleanup", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -126,8 +126,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -413,8 +413,7 @@ const execution = Layer.effect(
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
@@ -1005,9 +1004,16 @@ 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({
|
||||
context: yield* context.load(selected),
|
||||
step: 1,
|
||||
scope: {
|
||||
session: loaded.session,
|
||||
agentID: loaded.agent.id,
|
||||
model: loaded.model,
|
||||
tools: loaded.tools,
|
||||
},
|
||||
transcript: { system: [], messages: [] },
|
||||
webSocket: "session",
|
||||
})
|
||||
const http = prepared.options.http ?? (yield* Effect.die("Expected Session HTTP middleware"))
|
||||
|
||||
@@ -1016,7 +1022,7 @@ describe("SessionRunnerLLM", () => {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("network")))
|
||||
})
|
||||
|
||||
expect(prepared.webSocketEligible).toBe(false)
|
||||
expect(prepared.options.webSocket).toBeUndefined()
|
||||
expect(response.headers["x-response-hook"]).toBe("active")
|
||||
expect(requestTriggers).toBe(1)
|
||||
expect(responseTriggers).toBe(1)
|
||||
@@ -3089,24 +3095,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops a steer-scoped drain before queued input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false })
|
||||
yield* session.prompt({ sessionID, text: "Steer now", resume: false })
|
||||
yield* TestLLM.push(TestLLM.stop())
|
||||
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* runner.drain({ sessionID, force: false, promotable: "steer" })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0])).toEqual(["Steer now"])
|
||||
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(false)
|
||||
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("promotes queued input after steering continuation ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -115,7 +115,6 @@ const executionNode = makeGlobalNode({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
|
||||
})
|
||||
|
||||
@@ -86,7 +86,6 @@ const executionNode = makeGlobalNode({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
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"
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
> & {
|
||||
readonly hook: Hooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -660,7 +660,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
identifier: "v2.session.interrupt",
|
||||
summary: "Interrupt session execution",
|
||||
description:
|
||||
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
|
||||
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -345,6 +345,7 @@ 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
|
||||
|
||||
@@ -760,7 +761,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
addPressed = event.button !== RIGHT_MOUSE_BUTTON
|
||||
if (addPressed) return
|
||||
if (!rail) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
event.preventDefault()
|
||||
@@ -769,8 +771,11 @@ 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}
|
||||
@@ -837,6 +842,7 @@ 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)
|
||||
@@ -1203,7 +1209,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseDown={(event) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
addPressed = event.button !== RIGHT_MOUSE_BUTTON
|
||||
if (addPressed) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -1211,8 +1218,11 @@ 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>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user