Compare commits

..

4 Commits

Author SHA1 Message Date
Aiden Cline
699563e31f Merge branch 'dev' into snapshot-node-shim-stuff 2026-04-12 19:58:14 -05:00
Aiden Cline
4721b31d35 Merge branch 'dev' into snapshot-node-shim-stuff 2026-04-11 17:14:41 -05:00
Aiden Cline
8bce02e567 Merge branch 'dev' into snapshot-node-shim-stuff 2026-04-10 19:55:48 -05:00
Aiden Cline
c3dc5a3466 wip: node shim signals 2026-04-10 16:32:16 -05:00
80 changed files with 2998 additions and 3800 deletions

View File

@@ -319,7 +319,6 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49",
@@ -708,8 +707,6 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -5006,8 +5003,6 @@
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
}
}

View File

@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
size="large"
onClick={(e: MouseEvent) => {
props.loadMore()

View File

@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
onClick={props.onResumeScroll}
>
<div
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]"
style={{
"box-shadow":
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
"0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)",
}}
>
<Icon name="arrow-down-to-line" size="small" />

View File

@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
y: state.y,
width: state.width,
height: state.height,
show: false,
show: true,
title: "OpenCode",
icon: iconPath(),
backgroundColor,
@@ -94,10 +94,6 @@ export function createMainWindow(globals: Globals) {
wireZoom(win)
injectGlobals(win, globals)
win.once("ready-to-show", () => {
win.show()
})
return win
}

View File

@@ -13,7 +13,7 @@
Use these rules when writing or migrating Effect code.
See `specs/effect/migration.md` for the compact pattern reference and examples.
See `specs/effect-migration.md` for the compact pattern reference and examples.
## Core
@@ -51,7 +51,7 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
## Effect.cached for deduplication
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
## Instance.bind — ALS for native callbacks

View File

@@ -6,20 +6,33 @@ const path = require("path")
const os = require("os")
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
const child = childProcess.spawn(target, process.argv.slice(2), {
stdio: "inherit",
})
if (result.error) {
console.error(result.error.message)
child.on("error", (err) => {
console.error(err.message)
process.exit(1)
})
const forward = (sig) => {
if (!child.killed) {
try { child.kill(sig) } catch {}
}
}
const code = typeof result.status === "number" ? result.status : 0
process.exit(code)
;["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => {
process.on(sig, () => forward(sig))
})
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(typeof code === "number" ? code : 1)
})
}
const envPath = process.env.OPENCODE_BIN_PATH
if (envPath) {
run(envPath)
return run(envPath)
}
const scriptPath = fs.realpathSync(__filename)
@@ -28,7 +41,7 @@ const scriptDir = path.dirname(scriptPath)
//
const cached = path.join(scriptDir, ".opencode")
if (fs.existsSync(cached)) {
run(cached)
return run(cached)
}
const platformMap = {

View File

@@ -76,7 +76,6 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49",

View File

@@ -1,5 +1,3 @@
import { AppRuntime } from "@/effect/app-runtime"
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
@@ -18,20 +16,14 @@ const seed = async () => {
const { Project } = await import("../src/project/project")
const { ModelID, ProviderID } = await import("../src/provider/schema")
const { ToolRegistry } = await import("../src/tool/registry")
const { Effect } = await import("effect")
try {
await Instance.provide({
directory: dir,
init: () => AppRuntime.runPromise(InstanceBootstrap),
init: InstanceBootstrap,
fn: async () => {
await Config.waitForDependencies()
await AppRuntime.runPromise(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.ids()
}),
)
await ToolRegistry.ids()
const session = await Session.create({ title })
const messageID = MessageID.ascending()
@@ -62,7 +54,6 @@ const seed = async () => {
})
} finally {
await Instance.disposeAll().catch(() => {})
await AppRuntime.dispose().catch(() => {})
}
}

View File

@@ -178,9 +178,7 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades).
Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Account``account/index.ts`
- [x] `Agent``agent/agent.ts`
@@ -223,16 +221,59 @@ This checklist is only about the service shape migration. Many of these services
- [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts`
Still open:
- [x] `SessionTodo``session/todo.ts`
- [ ] `SyncEvent``sync/index.ts`
- [ ] `Workspace``control-plane/workspace.ts`
Still open at the service-shape level:
## Tool interface → Effect
- [ ] `SyncEvent``sync/index.ts` (deferred pending sync with James)
- [ ] `Workspace``control-plane/workspace.ts` (deferred pending sync with James)
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
## Tool migration
1. Migrate each tool body to return Effects
2. Keep `Tool.define()` inputs Effect-native
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
Tool-specific migration guidance and checklist live in `tools.md`.
### Tool migration details
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.
Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
- [ ] `task.ts` — MEDIUM: task state management
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
- [ ] `glob.ts` — LOW: simple async generator
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
- [ ] `question.ts` — LOW: prompt wrapper
- [ ] `skill.ts` — LOW: skill tool adapter
- [ ] `todo.ts` — LOW: todo persistence wrapper
- [ ] `invalid.ts` — LOW: invalid-tool fallback
- [ ] `plan.ts` — LOW: plan file operations
## Effect service adoption in already-migrated code
@@ -240,19 +281,27 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i
### `Filesystem.*``AppFileSystem.Service` (yield in layer)
- [x] `config/config.ts``installDependencies()` now uses `AppFileSystem`
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
- [ ] `config/config.ts` 5 remaining `Filesystem.*` calls in `installDependencies()`
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
### `Process.spawn``ChildProcessSpawner` (yield in layer)
- [x] `format/formatter.ts`direct `Process.spawn()` checks removed (`air`, `uv`)
- [ ] `format/formatter.ts`2 remaining `Process.spawn()` checks (`air`, `uv`)
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
## Filesystem consolidation
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
Tool-specific filesystem cleanup notes live in `tools.md`.
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
Current raw fs users that will convert during tool migration:
- `tool/read.ts` — fs.createReadStream, readline
- `tool/apply_patch.ts` — fs/promises
- `file/ripgrep.ts` — fs/promises
- `patch/index.ts` — fs, fs/promises
## Primitives & utilities
@@ -263,9 +312,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
## Destroying the facades
This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process
@@ -294,14 +341,47 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
Route-handler migration guidance and checklist live in `routes.md`.
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before — one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After — one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
Route files to convert (each handler that calls facades should be wrapped):
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
- [ ] `server/routes/question.ts` — uses Question
- [ ] `server/routes/pty.ts` — uses Pty
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config

View File

@@ -1,137 +0,0 @@
# HttpApi migration
Practical notes for an eventual migration of `packages/opencode` server routes from the current Hono handlers to Effect `HttpApi`, either as a full replacement or as a parallel surface.
## Goal
Use Effect `HttpApi` where it gives us a better typed contract for:
- route definition
- request decoding and validation
- typed success and error responses
- OpenAPI generation
- handler composition inside Effect
This should be treated as a later-stage HTTP boundary migration, not a prerequisite for ongoing service, route-handler, or schema work.
## Core model
`HttpApi` is definition-first.
- `HttpApi` is the root API
- `HttpApiGroup` groups related endpoints
- `HttpApiEndpoint` defines a single route and its request / response schemas
- handlers are implemented separately from the contract
This is a better fit once route inputs and outputs are already moving toward Effect Schema-first models.
## Why it is relevant here
The current route-effectification work is already pushing handlers toward:
- one `AppRuntime.runPromise(Effect.gen(...))` body
- yielding services from context
- using typed Effect errors instead of Promise wrappers
That work is a good prerequisite for `HttpApi`. Once the handler body is already a composed Effect, the remaining migration is mostly about replacing the Hono route declaration and validator layer.
## What HttpApi gives us
### Contracts
Request params, query, payload, success payloads, and typed error payloads are declared in one place using Effect Schema.
### Validation and decoding
Incoming data is decoded through Effect Schema instead of hand-maintained Zod validators per route.
### OpenAPI
`HttpApi` can derive OpenAPI from the API definition, which overlaps with the current `describeRoute(...)` and `resolver(...)` pattern.
### Typed errors
`Schema.TaggedErrorClass` maps naturally to endpoint error contracts.
## Likely fit for opencode
Best fit first:
- JSON request / response endpoints
- route groups that already mostly delegate into services
- endpoints whose request and response models can be defined with Effect Schema
Harder / later fit:
- SSE endpoints
- websocket endpoints
- streaming handlers
- routes with heavy Hono-specific middleware assumptions
## Current blockers and gaps
### Schema split
Many route boundaries still use Zod-first validators. That does not block all experimentation, but full `HttpApi` adoption is easier after the domain and boundary types are more consistently Schema-first with `.zod` compatibility only where needed.
### Mixed handler styles
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
The server currently includes SSE, websocket, and streaming-style endpoints. Those should not be the first `HttpApi` targets.
### Existing Hono integration
The current server composition, middleware, and docs flow are Hono-centered today. That suggests a parallel or incremental adoption plan is safer than a flag day rewrite.
## Recommended strategy
### 1. Finish the prerequisites first
- continue route-handler effectification in `server/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
### 2. Start with one parallel group
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
- `server/instance/question.ts`
- `server/instance/provider.ts`
- `server/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
### 3. Reuse existing services
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
### 4. Run in parallel before replacing
Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare:
- handler ergonomics
- OpenAPI output
- auth and middleware integration
- test ergonomics
### 5. Migrate JSON route groups gradually
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
## Proposed first steps
- [ ] add one small spike that defines an `HttpApi` group for a simple JSON route set
- [ ] use Effect Schema request / response types for that slice
- [ ] keep the underlying service calls identical to the current handlers
- [ ] compare generated OpenAPI against the current Hono/OpenAPI setup
- [ ] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
## Rule of thumb
Do not start with the hardest route file.
If `HttpApi` is adopted here, it should arrive after the handler body is already Effect-native and after the relevant request / response models have moved to Effect Schema.

View File

@@ -1,66 +0,0 @@
# Route handler effectification
Practical reference for converting server route handlers in `packages/opencode` to a single `AppRuntime.runPromise(Effect.gen(...))` body.
## Goal
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one.
This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before - one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After - one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
## Rules
- Wrap the whole handler body in one `AppRuntime.runPromise(Effect.gen(...))` call when the handler is service-heavy.
- Yield services from context instead of calling async facades repeatedly.
- When independent service calls can run in parallel, use `Effect.all(..., { concurrency: "unbounded" })`.
- Prefer one composed Effect body over multiple separate `runPromise(...)` calls in the same handler.
## Current route files
Current instance route files live under `src/server/instance`, not `server/routes`.
The main migration targets are:
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
- [ ] `server/instance/pty.ts` — still calls Pty facades directly
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
Additional route files that still participate in the migration:
- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format
- [ ] `server/instance/file.ts` — Ripgrep, File, LSP
- [ ] `server/instance/mcp.ts` — MCP facade-heavy
- [ ] `server/instance/permission.ts` — Permission
- [ ] `server/instance/workspace.ts` — Workspace
- [ ] `server/instance/tui.ts` — Bus and Session
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
## Notes
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.

View File

@@ -1,99 +0,0 @@
# Schema migration
Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
## Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
## Preferred shapes
### Data objects
Use `Schema.Class` for structured data.
```ts
export class Info extends Schema.Class<Info>("Foo.Info")({
id: FooID,
name: Schema.String,
enabled: Schema.Boolean,
}) {
static readonly zod = zod(Info)
}
```
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
```ts
const _Info = Schema.Struct({
id: FooID,
name: Schema.String,
})
export const Info = Object.assign(_Info, {
zod: zod(_Info),
})
```
### Errors
Use `Schema.TaggedErrorClass` for domain errors.
```ts
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
id: FooID,
}) {}
```
### IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
## Compatibility rule
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- new domain models should not start Zod-first unless there is a concrete boundary-specific need
## When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
## Ordering
Migrate in this order:
1. Shared leaf models and `schema.ts` files
2. Exported `Info`, `Input`, `Output`, and DTO types
3. Tagged domain errors
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
This keeps shared types canonical first and makes boundary updates mostly mechanical.
## Checklist
- [ ] Shared `schema.ts` leaf models are Effect Schema-first
- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
- [ ] Domain errors use `Schema.TaggedErrorClass`
- [ ] Migrated types expose `.zod` for back compatibility
- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
- [ ] New domain models default to Effect Schema first
## Notes
- Use `@/util/effect-zod` for all Schema -> Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.

View File

@@ -1,96 +0,0 @@
# Tool migration
Practical reference for the current tool-migration state in `packages/opencode`.
## Status
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch, and the built-in tool surface is now largely on the target shape.
The current exported tools in `src/tool` all use `Tool.define(...)` with Effect-based initialization, and nearly all of them already build their tool body with `Effect.gen(...)` and `Effect.fn(...)`.
So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
1. remove Promise and raw platform bridges inside individual tool bodies
2. swap tool internals to Effect-native services like `AppFileSystem`, `HttpClient`, and `ChildProcessSpawner`
3. keep tests and callers aligned with `yield* info.init()` and real service graphs
## Current shape
`Tool.define(...)` is already the Effect-native helper here.
- `init` is an `Effect`
- `info.init()` returns an `Effect`
- `execute(...)` returns an `Effect`
That means a tool does not need a separate `Tool.defineEffect(...)` helper to count as migrated. A tool is effectively migrated when its init and execute path stay Effect-native, even if some internals still bridge to Promise-based or raw APIs.
## Tests
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps tool tests aligned with the production service graph and makes follow-up cleanup mostly mechanical.
## Exported tools
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
- [x] `apply_patch.ts`
- [x] `bash.ts`
- [x] `codesearch.ts`
- [x] `edit.ts`
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `ls.ts`
- [x] `lsp.ts`
- [x] `multiedit.ts`
- [x] `plan.ts`
- [x] `question.ts`
- [x] `read.ts`
- [x] `skill.ts`
- [x] `task.ts`
- [x] `todo.ts`
- [x] `webfetch.ts`
- [x] `websearch.ts`
- [x] `write.ts`
Notes:
- `batch.ts` is no longer a current tool file and should not be tracked here.
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
## Follow-up cleanup
Most exported tools are already on the intended Effect-native shape. The remaining cleanup is narrower than the old checklist implied.
Current spot cleanups worth tracking:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
- `apply_patch.ts`
- `grep.ts`
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `ls.ts`
- `multiedit.ts`
- `edit.ts`
## Filesystem notes
Current raw fs users that still appear relevant here:
- `tool/read.ts``fs.createReadStream`, `readline`
- `file/ripgrep.ts``fs/promises`
- `patch/index.ts``fs`, `fs/promises`

View File

@@ -1,5 +1,6 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "../filesystem"
@@ -88,4 +89,22 @@ export namespace Auth {
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))
}
export async function all(): Promise<Record<string, Info>> {
return runPromise((service) => service.all())
}
export async function set(key: string, info: Info) {
return runPromise((service) => service.set(key, info))
}
export async function remove(key: string) {
return runPromise((service) => service.remove(key))
}
}

View File

@@ -1,11 +1,10 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
init: InstanceBootstrap,
fn: async () => {
try {
const result = await cb()

View File

@@ -12,7 +12,6 @@ import { Permission } from "../../../permission"
import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { AppRuntime } from "@/effect/app-runtime"
export const AgentCommand = cmd({
command: "agent <name>",
@@ -72,17 +71,11 @@ export const AgentCommand = cmd({
})
async function getAvailableTools(agent: Agent.Info) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
const registry = yield* ToolRegistry.Service
const model = agent.model ?? (yield* provider.defaultModel())
return yield* registry.tools({
...model,
agent,
})
}),
)
const model = agent.model ?? (await Provider.defaultModel())
return ToolRegistry.tools({
...model,
agent,
})
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
@@ -125,14 +118,7 @@ function parseToolParams(input?: string) {
async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending()
const model =
agent.model ??
(await AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
}),
))
const model = agent.model ?? (await Provider.defaultModel())
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,

View File

@@ -1,6 +1,4 @@
import { LSP } from "../../../lsp"
import { AppRuntime } from "../../../effect/app-runtime"
import { Effect } from "effect"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
@@ -21,16 +19,9 @@ const DiagnosticsCommand = cmd({
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const out = await AppRuntime.runPromise(
LSP.Service.use((lsp) =>
Effect.gen(function* () {
yield* lsp.touchFile(args.file, true)
yield* Effect.sleep(1000)
return yield* lsp.diagnostics()
}),
),
)
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
await LSP.touchFile(args.file, true)
await sleep(1000)
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
})
},
})
@@ -42,7 +33,7 @@ export const SymbolsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("symbols")
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
const results = await LSP.workspaceSymbol(args.query)
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
})
},
@@ -55,7 +46,7 @@ export const DocumentSymbolsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("document-symbols")
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
const results = await LSP.documentSymbol(args.uri)
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
})
},

View File

@@ -1,5 +1,4 @@
import { EOL } from "os"
import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap"
@@ -77,18 +76,12 @@ const SearchCommand = cmd({
description: "Limit number of results",
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) =>
svc.search({
cwd: Instance.directory,
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
}),
),
)
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
const results = await Ripgrep.search({
cwd: process.cwd(),
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
})
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
},
})

View File

@@ -1,6 +1,4 @@
import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Skill } from "../../../skill"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -11,12 +9,7 @@ export const SkillCommand = cmd({
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
const skills = await Skill.all()
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
})
},

View File

@@ -6,8 +6,6 @@ import { ModelsDev } from "../../provider/models"
import { cmd } from "./cmd"
import { UI } from "../ui"
import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const ModelsCommand = cmd({
command: "models [provider]",
@@ -37,51 +35,43 @@ export const ModelsCommand = cmd({
await Instance.provide({
directory: process.cwd(),
async fn() {
await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
const providers = yield* svc.list()
const providers = await Provider.list()
const print = (providerID: ProviderID, verbose?: boolean) => {
const provider = providers[providerID]
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sorted) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)
if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL)
}
}
function printModels(providerID: ProviderID, verbose?: boolean) {
const provider = providers[providerID]
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sortedModels) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)
if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL)
}
}
}
if (args.provider) {
const providerID = ProviderID.make(args.provider)
const provider = providers[providerID]
if (!provider) {
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
return
}
if (args.provider) {
const provider = providers[ProviderID.make(args.provider)]
if (!provider) {
UI.error(`Provider not found: ${args.provider}`)
return
}
yield* Effect.sync(() => print(providerID, args.verbose))
return
}
printModels(ProviderID.make(args.provider), args.verbose)
return
}
const ids = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("opencode")
const bIsOpencode = b.startsWith("opencode")
if (aIsOpencode && !bIsOpencode) return -1
if (!aIsOpencode && bIsOpencode) return 1
return a.localeCompare(b)
})
const providerIDs = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("opencode")
const bIsOpencode = b.startsWith("opencode")
if (aIsOpencode && !bIsOpencode) return -1
if (!aIsOpencode && bIsOpencode) return 1
return a.localeCompare(b)
})
yield* Effect.sync(() => {
for (const providerID of ids) {
print(ProviderID.make(providerID), args.verbose)
}
})
}),
)
for (const providerID of providerIDs) {
printModels(ProviderID.make(providerID), args.verbose)
}
},
})
},

View File

@@ -1,5 +1,4 @@
import { Auth } from "../../auth"
import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
@@ -14,18 +13,9 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { Effect } from "effect"
type PluginAuth = NonNullable<Hooks["auth"]>
const put = (key: string, info: Auth.Info) =>
AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set(key, info)
}),
)
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0
if (methodName) {
@@ -103,7 +93,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await put(saveProvider, {
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
@@ -112,7 +102,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})
}
if ("key" in result) {
await put(saveProvider, {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
@@ -135,7 +125,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await put(saveProvider, {
await Auth.set(saveProvider, {
type: "oauth",
refresh,
access,
@@ -144,7 +134,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})
}
if ("key" in result) {
await put(saveProvider, {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
@@ -158,12 +148,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (method.type === "api") {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
if (method.authorize) {
const result = await method.authorize(inputs)
if (result.type === "failed") {
@@ -171,9 +155,9 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
await Auth.set(saveProvider, {
type: "api",
key: result.key ?? key,
key: result.key,
})
prompts.log.success("Login successful")
}
@@ -231,12 +215,7 @@ export const ProvidersListCommand = cmd({
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const results = Object.entries(await Auth.all())
const database = await ModelsDev.get()
for (const [providerID, result] of results) {
@@ -315,7 +294,7 @@ export const ProvidersLoginCommand = cmd({
prompts.outro("Done")
return
}
await put(url, {
await Auth.set(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
@@ -462,7 +441,7 @@ export const ProvidersLoginCommand = cmd({
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await put(provider, {
await Auth.set(provider, {
type: "api",
key,
})
@@ -478,33 +457,22 @@ export const ProvidersLogoutCommand = cmd({
describe: "log out from a configured provider",
async handler(_args) {
UI.empty()
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const credentials = await Auth.all().then((x) => Object.entries(x))
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await ModelsDev.get()
const selected = await prompts.select({
const providerID = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
if (prompts.isCancel(providerID)) throw new UI.CancelledError()
await Auth.remove(providerID)
prompts.outro("Logout successful")
},
})

View File

@@ -1,7 +1,6 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { Terminal } from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
@@ -61,6 +60,66 @@ import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (match) {
cleanup()
const color = match[1]
// Parse RGB values from color string
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
let r = 0,
g = 0,
b = 0
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
} else if (color.startsWith("#")) {
r = parseInt(color.substring(1, 3), 16)
g = parseInt(color.substring(3, 5), 16)
b = parseInt(color.substring(5, 7), 16)
} else if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
r = parseInt(parts[0])
g = parseInt(parts[1])
b = parseInt(parts[2])
}
// Calculate luminance using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Determine if dark or light based on luminance threshold
resolve(luminance > 0.5 ? "light" : "dark")
}
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
@@ -119,7 +178,7 @@ export function tui(input: {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const mode = await Terminal.getTerminalBackgroundColor()
const mode = await getTerminalBackgroundColor()
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.

View File

@@ -2,28 +2,6 @@ import { RGBA } from "@opentui/core"
export namespace Terminal {
export type Colors = Awaited<ReturnType<typeof colors>>
function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
}
if (color.startsWith("#")) {
return RGBA.fromHex(color)
}
if (color.startsWith("rgb(")) {
const parts = color.substring(4, color.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
function mode(bg: RGBA | null): "dark" | "light" {
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
}
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
@@ -53,26 +31,46 @@ export namespace Terminal {
clearTimeout(timeout)
}
const parseColor = (colorStr: string): RGBA | null => {
if (colorStr.startsWith("rgb:")) {
const parts = colorStr.substring(4).split("/")
return RGBA.fromInts(
parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit
parseInt(parts[1], 16) >> 8,
parseInt(parts[2], 16) >> 8,
255,
)
}
if (colorStr.startsWith("#")) {
return RGBA.fromHex(colorStr)
}
if (colorStr.startsWith("rgb(")) {
const parts = colorStr.substring(4, colorStr.length - 1).split(",")
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
}
return null
}
const handler = (data: Buffer) => {
const str = data.toString()
// Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) {
background = parse(bgMatch[1])
background = parseColor(bgMatch[1])
}
// Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) {
foreground = parse(fgMatch[1])
foreground = parseColor(fgMatch[1])
}
// Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) {
const index = parseInt(match[1])
const color = parse(match[2])
const color = parseColor(match[2])
if (color) paletteColors[index] = color
}
@@ -102,36 +100,15 @@ export namespace Terminal {
})
}
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
const result = await colors()
if (!result.background) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const { r, g, b } = result.background
// Calculate luminance using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
// Determine if dark or light based on luminance threshold
return luminance > 0.5 ? "light" : "dark"
}
}

View File

@@ -7,10 +7,10 @@ import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -74,7 +74,7 @@ export const rpc = {
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
directory: input.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
init: InstanceBootstrap,
fn: async () => {
await upgrade().catch(() => {})
},

View File

@@ -22,19 +22,21 @@ import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { existsSync } from "fs"
import { constants, existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Duration, Effect, Layer, Option, Context } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm"
@@ -138,11 +140,53 @@ export namespace Config {
}
export type InstallInput = {
signal?: AbortSignal
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
}
type Package = {
dependencies?: Record<string, string>
export async function installDependencies(dir: string, input?: InstallInput) {
if (!(await isWritable(dir))) return
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
signal: input?.signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
})
input?.signal?.throwIfAborted()
const pkg = path.join(dir, "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
json.dependencies = {
...json.dependencies,
"@opencode-ai/plugin": target,
}
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
const ignore = await Filesystem.exists(gitignore)
if (!ignore) {
await Filesystem.write(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
await Npm.install(dir)
}
async function isWritable(dir: string) {
try {
await fsNode.access(dir, constants.W_OK)
return true
} catch {
return false
}
}
function rel(item: string, patterns: string[]) {
@@ -1067,7 +1111,7 @@ export namespace Config {
type State = {
config: Info
directories: string[]
deps: Fiber.Fiber<void, never>[]
deps: Promise<void>[]
consoleState: ConsoleState
}
@@ -1075,7 +1119,6 @@ export namespace Config {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1277,74 +1320,6 @@ export namespace Config {
return yield* cachedGlobal
})
const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
const gitignore = path.join(dir, ".gitignore")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = yield* fs.readJson(pkg).pipe(
Effect.catch(() => Effect.succeed({} satisfies Package)),
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
)
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
const hasIgnore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...json.dependencies,
"@opencode-ai/plugin": target,
},
})
}
if (!hasIgnore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && hasIgnore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
})
const installDependencies = Effect.fn("Config.installDependencies")(function* (
dir: string,
input?: InstallInput,
) {
if (
!(yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
))
)
return
const key =
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
yield* Effect.acquireUseRelease(
Effect.promise((signal) =>
Flock.acquire(key, {
signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
}),
),
() => install(dir),
(lease) => Effect.promise(() => lease.release()),
)
})
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
@@ -1427,7 +1402,7 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
const deps: Promise<void>[] = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
@@ -1441,18 +1416,12 @@ export namespace Config {
}
}
const dep = yield* installDependencies(dir).pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkScoped,
)
const dep = iife(async () => {
await installDependencies(dir)
})
void dep.catch((err) => {
log.warn("background dependency install failed", { dir, error: err })
})
deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
@@ -1589,9 +1558,7 @@ export namespace Config {
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
})
const update = Effect.fn("Config.update")(function* (config: Info) {
@@ -1646,7 +1613,6 @@ export namespace Config {
get,
getGlobal,
getConsoleState,
installDependencies,
update,
updateGlobal,
invalidate,
@@ -1676,10 +1642,6 @@ export namespace Config {
return runPromise((svc) => svc.getConsoleState())
}
export async function installDependencies(dir: string, input?: InstallInput) {
return runPromise((svc) => svc.installDependencies(dir, input))
}
export async function update(config: Info) {
return runPromise((svc) => svc.update(config))
}

View File

@@ -49,7 +49,7 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll(
// Observability.layer,
Observability.layer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
@@ -95,6 +95,6 @@ export const AppLayer = Layer.mergeAll(
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
).pipe(Layer.provide(Observability.layer))
)
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })

View File

@@ -1,27 +1,10 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
import { File } from "@/file"
import { Vcs } from "@/project/vcs"
import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Observability } from "./oltp"
export const BootstrapLayer = Layer.mergeAll(
Plugin.defaultLayer,
ShareNext.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Vcs.defaultLayer,
Snapshot.defaultLayer,
Bus.defaultLayer,
).pipe(Layer.provide(Observability.layer))
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })

View File

@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
import { Effect, Layer, Context, Schema } from "effect"
import { Effect, Layer, Context } from "effect"
import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
@@ -94,43 +94,8 @@ export namespace Ripgrep {
const Result = z.union([Begin, Match, End, Summary])
const Hit = Schema.Struct({
type: Schema.Literal("match"),
data: Schema.Struct({
path: Schema.Struct({
text: Schema.String,
}),
lines: Schema.Struct({
text: Schema.String,
}),
line_number: Schema.Number,
absolute_offset: Schema.Number,
submatches: Schema.mutable(
Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: Schema.Number,
end: Schema.Number,
}),
),
),
}),
})
const Row = Schema.Union([
Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }),
Hit,
Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }),
Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }),
])
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row))
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Item = Match["data"]
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
@@ -324,13 +289,6 @@ export namespace Ripgrep {
follow?: boolean
maxDepth?: number
}) => Stream.Stream<string, PlatformError>
readonly search: (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
@@ -340,32 +298,6 @@ export namespace Ripgrep {
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service
const bin = Effect.fn("Ripgrep.path")(function* () {
return yield* Effect.promise(() => filepath())
})
const args = Effect.fn("Ripgrep.args")(function* (input: {
mode: "files" | "search"
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
limit?: number
pattern?: string
}) {
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
if (input.follow) out.push("--follow")
if (input.hidden !== false) out.push("--hidden")
if (input.maxDepth !== undefined) out.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
out.push(`--glob=${g}`)
}
}
if (input.limit) out.push(`--max-count=${input.limit}`)
if (input.mode === "search") out.push("--no-messages")
if (input.pattern) out.push("--", input.pattern)
return out
})
const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
@@ -374,7 +306,7 @@ export namespace Ripgrep {
follow?: boolean
maxDepth?: number
}) {
const rgPath = yield* bin()
const rgPath = yield* Effect.promise(() => filepath())
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
@@ -386,77 +318,23 @@ export namespace Ripgrep {
)
}
const cmd = yield* args({
mode: "files",
glob: input.glob,
hidden: input.hidden,
follow: input.follow,
maxDepth: input.maxDepth,
})
const args = [rgPath, "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
return spawner
.streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
})
const search = Effect.fn("Ripgrep.search")(function* (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) {
return yield* Effect.scoped(
Effect.gen(function* () {
const cmd = yield* args({
mode: "search",
glob: input.glob,
follow: input.follow,
limit: input.limit,
pattern: input.pattern,
})
const handle = yield* spawner.spawn(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: input.cwd,
stdin: "ignore",
}),
)
const [items, stderr, code] = yield* Effect.all(
[
Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect((line) =>
decode(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))),
),
Stream.filter((row): row is Schema.Schema.Type<typeof Hit> => row.type === "match"),
Stream.map((row): Item => row.data),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
if (code !== 0 && code !== 1 && code !== 2) {
return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`))
}
return {
items,
partial: code === 2,
}
}),
)
})
return Service.of({
files: (input) => Stream.unwrap(files(input)),
search,
})
}),
)
@@ -523,4 +401,46 @@ export namespace Ripgrep {
return lines.join("\n")
}
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push("--")
args.push(input.pattern)
const result = await Process.text(args, {
cwd: input.cwd,
nothrow: true,
})
if (result.code !== 0) {
return []
}
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
}

View File

@@ -1,3 +1,4 @@
import { text } from "node:stream/consumers"
import { Npm } from "@/npm"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
@@ -216,16 +217,26 @@ export const rlang: Info = {
name: "air",
extensions: [".R"],
async enabled() {
const air = which("air")
if (air == null) return false
const airPath = which("air")
if (airPath == null) return false
const output = await Process.text([air, "--help"], { nothrow: true })
try {
const proc = Process.spawn(["air", "--help"], {
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
if (!proc.stdout) return false
const output = await text(proc.stdout)
// Check for "Air: An R language server and formatter"
const firstLine = output.text.split("\n")[0]
const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter")
if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"]
// Check for "Air: An R language server and formatter"
const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter")
if (hasR && hasFormatter) return ["air", "format", "$FILE"]
} catch {
return false
}
return false
},
}
@@ -235,10 +246,11 @@ export const uvformat: Info = {
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
const uv = which("uv")
if (uv == null) return false
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
if (output.code === 0) return [uv, "format", "--", "$FILE"]
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
if (code === 0) return ["uv", "format", "--", "$FILE"]
}
return false
},
}

View File

@@ -13,6 +13,7 @@ import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -507,6 +508,37 @@ export namespace LSP {
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const init = async () => runPromise((svc) => svc.init())
export const status = async () => runPromise((svc) => svc.status())
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
runPromise((svc) => svc.touchFile(input, waitForDiagnostics))
export const diagnostics = async () => runPromise((svc) => svc.diagnostics())
export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input))
export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input))
export const references = async (input: LocInput) => runPromise((svc) => svc.references(input))
export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input))
export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri))
export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query))
export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input))
export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input))
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
export namespace Diagnostic {
const MAX_PER_FILE = 20

View File

@@ -9,26 +9,24 @@ import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
import { Log } from "@/util/log"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
import * as Effect from "effect/Effect"
export const InstanceBootstrap = Effect.gen(function* () {
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
yield* Plugin.Service.use((svc) => svc.init())
yield* ShareNext.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* Format.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* LSP.Service.use((svc) => svc.init())
yield* File.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* FileWatcher.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* Vcs.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
yield* Snapshot.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
await Plugin.init()
void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
await LSP.init()
File.init()
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
Vcs.init()
Snapshot.init()
yield* Bus.Service.use((svc) =>
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(Instance.project.id)
}
}),
)
}).pipe(Effect.withSpan("InstanceBootstrap"))
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(Instance.project.id)
}
})
}

View File

@@ -4,6 +4,7 @@ import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
@@ -230,4 +231,22 @@ export namespace Vcs {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((svc) => svc.init())
}
export async function branch() {
return runPromise((svc) => svc.branch())
}
export async function defaultBranch() {
return runPromise((svc) => svc.defaultBranch())
}
export async function diff(mode: Mode) {
return runPromise((svc) => svc.diff(mode))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -209,9 +209,6 @@ export namespace ProviderTransform {
copilot: {
copilot_cache_control: { type: "ephemeral" },
},
alibaba: {
cacheControl: { type: "ephemeral" },
},
}
for (const msg of unique([...system, ...final])) {
@@ -288,8 +285,7 @@ export namespace ProviderTransform {
model.api.id.includes("claude") ||
model.id.includes("anthropic") ||
model.id.includes("claude") ||
model.api.npm === "@ai-sdk/anthropic" ||
model.api.npm === "@ai-sdk/alibaba") &&
model.api.npm === "@ai-sdk/anthropic") &&
model.api.npm !== "@ai-sdk/gateway"
) {
msgs = applyCaching(msgs, model)

View File

@@ -1,6 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Instance } from "@/project/instance"
import type { Proc } from "#pty"
import z from "zod"
@@ -360,4 +361,34 @@ export namespace Pty {
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
export async function get(id: PtyID) {
return runPromise((svc) => svc.get(id))
}
export async function write(id: PtyID, data: string) {
return runPromise((svc) => svc.write(id, data))
}
export async function connect(id: PtyID, ws: Socket, cursor?: number) {
return runPromise((svc) => svc.connect(id, ws, cursor))
}
export async function create(input: CreateInput) {
return runPromise((svc) => svc.create(input))
}
export async function update(id: PtyID, input: UpdateInput) {
return runPromise((svc) => svc.update(id, input))
}
export async function remove(id: PtyID) {
return runPromise((svc) => svc.remove(id))
}
}

View File

@@ -1,7 +1,5 @@
import { Auth } from "@/auth"
import { AppRuntime } from "@/effect/app-runtime"
import { Log } from "@/util/log"
import { Effect } from "effect"
import { ProviderID } from "@/provider/schema"
import { Hono } from "hono"
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
@@ -41,12 +39,7 @@ export function ControlPlaneRoutes(): Hono {
async (c) => {
const providerID = c.req.valid("param").providerID
const info = c.req.valid("json")
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set(providerID, info)
}),
)
await Auth.set(providerID, info)
return c.json(true)
},
)
@@ -76,12 +69,7 @@ export function ControlPlaneRoutes(): Hono {
),
async (c) => {
const providerID = c.req.valid("param").providerID
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
await Auth.remove(providerID)
return c.json(true)
},
)

View File

@@ -7,8 +7,6 @@ import { mapValues } from "remeda"
import { errors } from "../error"
import { Log } from "../../util/log"
import { lazy } from "../../util/lazy"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
const log = Log.create({ service: "server" })
@@ -84,12 +82,7 @@ export const ConfigRoutes = lazy(() =>
}),
async (c) => {
using _ = log.time("providers")
const providers = await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
return mapValues(yield* svc.list(), (item) => item)
}),
)
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
return c.json({
providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),

View File

@@ -162,13 +162,7 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
async (c) => {
const ids = await AppRuntime.runPromise(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
return yield* registry.ids()
}),
)
return c.json(ids)
return c.json(await ToolRegistry.ids())
},
)
.get(
@@ -211,17 +205,11 @@ export const ExperimentalRoutes = lazy(() =>
),
async (c) => {
const { provider, model } = c.req.valid("query")
const tools = await AppRuntime.runPromise(
Effect.gen(function* () {
const agents = yield* Agent.Service
const registry = yield* ToolRegistry.Service
return yield* registry.tools({
providerID: ProviderID.make(provider),
modelID: ModelID.make(model),
agent: yield* agents.get(yield* agents.defaultAgent()),
})
}),
)
const tools = await ToolRegistry.tools({
providerID: ProviderID.make(provider),
modelID: ModelID.make(model),
agent: await Agent.get(await Agent.defaultAgent()),
})
return c.json(
tools.map((t) => ({
id: t.id,

View File

@@ -1,7 +1,6 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { AppRuntime } from "../../effect/app-runtime"
import { File } from "../../file"
import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp"
@@ -35,10 +34,12 @@ export const FileRoutes = lazy(() =>
),
async (c) => {
const pattern = c.req.valid("query").pattern
const result = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
)
return c.json(result.items)
const result = await Ripgrep.search({
cwd: Instance.directory,
pattern,
limit: 10,
})
return c.json(result)
},
)
.get(
@@ -105,6 +106,11 @@ export const FileRoutes = lazy(() =>
}),
),
async (c) => {
/*
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
*/
return c.json([])
},
)

View File

@@ -1,7 +1,6 @@
import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import z from "zod"
import { Format } from "../../format"
import { TuiRoutes } from "./tui"
@@ -120,17 +119,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
},
}),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
concurrency: 2,
})
return { branch, default_branch }
}),
),
)
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
return c.json({
branch,
default_branch,
})
},
)
.get(
@@ -157,14 +150,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}),
),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff(c.req.valid("query").mode)
}),
),
)
return c.json(await Vcs.diff(c.req.valid("query").mode))
},
)
.get(
@@ -229,12 +215,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
},
}),
async (c) => {
const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
const skills = await Skill.all()
return c.json(skills)
},
)
@@ -256,8 +237,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
},
}),
async (c) => {
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
return c.json(items)
return c.json(await LSP.status())
},
)
.get(

View File

@@ -10,7 +10,6 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { AppRuntime } from "@/effect/app-runtime"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -67,7 +66,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
if (!workspaceID) {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
init: InstanceBootstrap,
async fn() {
return next()
},
@@ -104,7 +103,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
fn: () =>
Instance.provide({
directory: target.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
init: InstanceBootstrap,
async fn() {
return next()
},

View File

@@ -8,7 +8,6 @@ import { ProjectID } from "../../project/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { InstanceBootstrap } from "../../project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
export const ProjectRoutes = lazy(() =>
new Hono()
@@ -84,7 +83,7 @@ export const ProjectRoutes = lazy(() =>
directory: dir,
worktree: dir,
project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap),
init: InstanceBootstrap,
})
return c.json(next)
},

View File

@@ -11,7 +11,6 @@ import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Log } from "../../util/log"
import { Effect } from "effect"
const log = Log.create({ service: "server" })
@@ -41,35 +40,27 @@ export const ProviderRoutes = lazy(() =>
},
}),
async (c) => {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
const config = yield* Effect.promise(() => Config.get())
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
const connected = yield* svc.list()
const providers = Object.assign(
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return {
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: Object.keys(connected),
}
}),
const config = await Config.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const allProviders = await ModelsDev.get()
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filteredProviders[key] = value
}
}
const connected = await Provider.list()
const providers = Object.assign(
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return c.json({
all: result.all,
default: result.default,
connected: result.connected,
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: Object.keys(connected),
})
},
)

View File

@@ -1,9 +1,7 @@
import { Hono, type MiddlewareHandler } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db"
@@ -29,14 +27,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
),
)
return c.json(await Pty.list())
},
)
.post(
@@ -59,12 +50,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("json", Pty.CreateInput),
async (c) => {
const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.create(c.req.valid("json"))
}),
)
const info = await Pty.create(c.req.valid("json"))
return c.json(info)
},
)
@@ -88,12 +74,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(c.req.valid("param").ptyID)
}),
)
const info = await Pty.get(c.req.valid("param").ptyID)
if (!info) {
throw new NotFoundError({ message: "Session not found" })
}
@@ -121,12 +102,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput),
async (c) => {
const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
}),
)
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
return c.json(info)
},
)
@@ -150,12 +126,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* pty.remove(c.req.valid("param").ptyID)
}),
)
await Pty.remove(c.req.valid("param").ptyID)
return c.json(true)
},
)
@@ -179,11 +150,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
upgradeWebSocket(async (c) => {
type Handler = {
onMessage: (message: string | ArrayBuffer) => void
onClose: () => void
}
const id = PtyID.zod.parse(c.req.param("ptyID"))
const cursor = (() => {
const value = c.req.query("cursor")
@@ -192,17 +158,8 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed
})()
let handler: Handler | undefined
if (
!(await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(id)
}),
))
) {
throw new Error("Session not found")
}
let handler: Awaited<ReturnType<typeof Pty.connect>>
if (!(await Pty.get(id))) throw new Error("Session not found")
type Socket = {
readyState: number
@@ -228,12 +185,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
ws.close()
return
}
handler = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, socket, cursor)
}),
)
handler = await Pty.connect(id, socket, cursor)
ready = true
for (const msg of pending) handler?.onMessage(msg)
pending.length = 0

View File

@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { type ProviderMetadata } from "ai"
import { Flag } from "../flag/flag"
import { Installation } from "../installation"
@@ -28,6 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { Effect, Layer, Option, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
@@ -239,7 +240,7 @@ export namespace Session {
export const getUsage = (input: {
model: Provider.Model
usage: LanguageModelUsage
usage: LanguageModelV2Usage
metadata?: ProviderMetadata
}) => {
const safe = (value: number) => {
@@ -248,14 +249,11 @@ export namespace Session {
}
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
const cacheWriteInputTokens = safe(
(input.usage.inputTokenDetails?.cacheWriteTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
@@ -276,7 +274,7 @@ export namespace Session {
const tokens = {
total,
input: adjustedInputTokens,
output: safe(outputTokens - reasoningTokens),
output: outputTokens - reasoningTokens,
reasoning: reasoningTokens,
cache: {
write: cacheWriteInputTokens,

View File

@@ -94,24 +94,14 @@ export namespace LLM {
modelID: input.model.id,
providerID: input.model.providerID,
})
const [language, cfg, provider, info] = await Effect.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
const cfg = yield* Config.Service
const provider = yield* Provider.Service
return yield* Effect.all(
[
provider.getLanguage(input.model),
cfg.get(),
provider.getProvider(input.model.providerID),
auth.get(input.model.providerID),
],
{ concurrency: "unbounded" },
)
}).pipe(Effect.provide(Layer.mergeAll(Auth.defaultLayer, Config.defaultLayer, Provider.defaultLayer))),
)
const [language, cfg, provider, auth] = await Promise.all([
Provider.getLanguage(input.model),
Config.get(),
Provider.getProvider(input.model.providerID),
Auth.get(input.model.providerID),
])
// TODO: move this to a proper hook
const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth"
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"
const system: string[] = []
system.push(
@@ -210,7 +200,7 @@ export namespace LLM {
},
)
const tools = resolveTools(input)
const tools = await resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.

View File

@@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission"
@@ -261,4 +262,22 @@ export namespace Skill {
.map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n")
}
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(name: string) {
return runPromise((skill) => skill.get(name))
}
export async function all() {
return runPromise((skill) => skill.all())
}
export async function dirs() {
return runPromise((skill) => skill.dirs())
}
export async function available(agent?: Agent.Info) {
return runPromise((skill) => skill.available(agent))
}
}

View File

@@ -1,8 +1,11 @@
import z from "zod"
import { Effect, Option } from "effect"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { AppFileSystem } from "../filesystem"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
@@ -14,8 +17,7 @@ const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const rg = yield* Ripgrep.Service
const spawner = yield* ChildProcessSpawner
return {
description: DESCRIPTION,
@@ -26,11 +28,6 @@ export const GrepTool = Tool.define(
}),
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
const empty = {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
if (!params.pattern) {
throw new Error("pattern is required")
}
@@ -46,58 +43,92 @@ export const GrepTool = Tool.define(
},
})
const searchPath = AppFileSystem.resolve(
path.isAbsolute(params.path ?? Instance.directory)
? (params.path ?? Instance.directory)
: path.join(Instance.directory, params.path ?? "."),
)
let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
const result = yield* rg.search({
cwd: searchPath,
pattern: params.pattern,
glob: params.include ? [params.include] : undefined,
})
const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
args.push(searchPath)
if (result.items.length === 0) return empty
const result = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(rgPath, args, {
stdin: "ignore",
}),
)
const rows = result.items.map((item) => ({
path: AppFileSystem.resolve(
path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text),
),
line: item.line_number,
text: item.lines.text,
}))
const times = new Map(
(yield* Effect.forEach(
[...new Set(rows.map((row) => row.path))],
Effect.fnUntraced(function* (file) {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info || info.type === "Directory") return undefined
return [
file,
info.mtime.pipe(
Option.map((time) => time.getTime()),
Option.getOrElse(() => 0),
) ?? 0,
] as const
}),
{ concurrency: 16 },
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
const [output, errorOutput] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const exitCode = yield* handle.exitCode
return { output, errorOutput, exitCode }
}),
)
const matches = rows.flatMap((row) => {
const mtime = times.get(row.path)
if (mtime === undefined) return []
return [{ ...row, mtime }]
})
matches.sort((a, b) => b.mtime - a.mtime)
const { output, errorOutput, exitCode } = result
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
for (const line of lines) {
if (!line) continue
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
})
}
matches.sort((a, b) => b.modTime - a.modTime)
const limit = 100
const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) return empty
if (finalMatches.length === 0) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
@@ -112,8 +143,10 @@ export const GrepTool = Tool.define(
outputLines.push(`${match.path}:`)
}
const truncatedLineText =
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
outputLines.push(` Line ${match.line}: ${truncatedLineText}`)
match.lineText.length > MAX_LINE_LENGTH
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
: match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
if (truncated) {
@@ -123,7 +156,7 @@ export const GrepTool = Tool.define(
)
}
if (result.partial) {
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}

View File

@@ -36,6 +36,7 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
import { Question } from "../question"
import { Todo } from "../session/todo"
@@ -343,4 +344,18 @@ export namespace ToolRegistry {
Layer.provide(Truncate.defaultLayer),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function ids() {
return runPromise((svc) => svc.ids())
}
export async function tools(input: {
providerID: ProviderID
modelID: ModelID
agent: Agent.Info
}): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(input))
}
}

View File

@@ -17,7 +17,6 @@ import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { makeRuntime } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
@@ -267,7 +266,7 @@ export namespace Worktree {
const booted = yield* Effect.promise(() =>
Instance.provide({
directory: info.directory,
init: () => BootstrapRuntime.runPromise(InstanceBootstrap),
init: InstanceBootstrap,
fn: () => undefined,
})
.then(() => true)

View File

@@ -1,86 +1,58 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { test, expect } from "bun:test"
import { Auth } from "../../src/auth"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node))
describe("Auth", () => {
it.live("set normalizes trailing slashes in keys", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
const data = yield* auth.all()
expect(data["https://example.com"]).toBeDefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
),
)
it.live("set cleans up pre-existing trailing-slash entry", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "old",
})
yield* auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "new",
})
const data = yield* auth.all()
const keys = Object.keys(data).filter((key) => key.includes("example.com"))
expect(keys).toEqual(["https://example.com"])
const entry = data["https://example.com"]!
expect(entry.type).toBe("wellknown")
if (entry.type === "wellknown") expect(entry.token).toBe("new")
}),
),
)
it.live("remove deletes both trailing-slash and normalized keys", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
yield* auth.remove("https://example.com/")
const data = yield* auth.all()
expect(data["https://example.com"]).toBeUndefined()
expect(data["https://example.com/"]).toBeUndefined()
}),
),
)
it.live("set and remove are no-ops on keys without trailing slashes", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("anthropic", {
type: "api",
key: "sk-test",
})
const data = yield* auth.all()
expect(data["anthropic"]).toBeDefined()
yield* auth.remove("anthropic")
const after = yield* auth.all()
expect(after["anthropic"]).toBeUndefined()
}),
),
)
test("set normalizes trailing slashes in keys", async () => {
await Auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
const data = await Auth.all()
expect(data["https://example.com"]).toBeDefined()
expect(data["https://example.com/"]).toBeUndefined()
})
test("set cleans up pre-existing trailing-slash entry", async () => {
// Simulate a pre-fix entry with trailing slash
await Auth.set("https://example.com/", {
type: "wellknown",
key: "TOKEN",
token: "old",
})
// Re-login with normalized key (as the CLI does post-fix)
await Auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "new",
})
const data = await Auth.all()
const keys = Object.keys(data).filter((k) => k.includes("example.com"))
expect(keys).toEqual(["https://example.com"])
const entry = data["https://example.com"]!
expect(entry.type).toBe("wellknown")
if (entry.type === "wellknown") expect(entry.token).toBe("new")
})
test("remove deletes both trailing-slash and normalized keys", async () => {
await Auth.set("https://example.com", {
type: "wellknown",
key: "TOKEN",
token: "abc",
})
await Auth.remove("https://example.com/")
const data = await Auth.all()
expect(data["https://example.com"]).toBeUndefined()
expect(data["https://example.com/"]).toBeUndefined()
})
test("set and remove are no-ops on keys without trailing slashes", async () => {
await Auth.set("anthropic", {
type: "api",
key: "sk-test",
})
const data = await Auth.all()
expect(data["anthropic"]).toBeDefined()
await Auth.remove("anthropic")
const after = await Auth.all()
expect(after["anthropic"]).toBeUndefined()
})

View File

@@ -1,10 +1,10 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Stream } from "effect"
import z from "zod"
import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -13,7 +13,9 @@ const TestEvent = {
Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })),
}
const node = CrossSpawnSpawner.defaultLayer
const node = NodeChildProcessSpawner.layer.pipe(
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
)
const live = Layer.mergeAll(Bus.layer, node)

View File

@@ -6,6 +6,7 @@ import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { Global } from "../../../src/global"
import { TuiConfig } from "../../../src/config/tui"
import { Config } from "../../../src/config/config"
import { Filesystem } from "../../../src/util/filesystem"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
@@ -324,6 +325,7 @@ export default {
})
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
@@ -405,6 +407,7 @@ export default {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
install.mockRestore()
if (backup === undefined) {
await fs.rm(globalConfigPath, { force: true })
} else {
@@ -698,6 +701,7 @@ test("updates installed theme when plugin metadata changes", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
const api = () =>
createTuiPluginApi({
@@ -742,6 +746,7 @@ test("updates installed theme when plugin metadata changes", async () => {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
install.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})

View File

@@ -1,5 +1,5 @@
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Option } from "effect"
import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
@@ -7,9 +7,8 @@ import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { AppFileSystem } from "../../src/filesystem"
import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
const infra = CrossSpawnSpawner.defaultLayer.pipe(
@@ -33,18 +32,6 @@ const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}),
})
const it = testEffect(
Config.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(emptyAuth),
Layer.provide(emptyAccount),
Layer.provideMerge(infra),
),
)
const installDeps = (dir: string, input?: Config.InstallInput) =>
Config.Service.use((svc) => svc.installDependencies(dir, input))
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -830,134 +817,128 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
}
})
it.live("dedupes concurrent config dependency installs for the same dir", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const dir = path.join(tmp, "a")
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
test("dedupes concurrent config dependency installs for the same dir", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "a")
await fs.mkdir(dir, { recursive: true })
let calls = 0
const online = spyOn(Network, "online").mockReturnValue(false)
const ready = Deferred.makeUnsafe<void>()
const blocked = Deferred.makeUnsafe<void>()
const hold = Deferred.makeUnsafe<void>()
const target = path.normalize(dir)
const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
if (path.normalize(d) !== target) return
const ticks: number[] = []
let calls = 0
let start = () => {}
let done = () => {}
let blocked = () => {}
const ready = new Promise<void>((resolve) => {
start = resolve
})
const gate = new Promise<void>((resolve) => {
done = resolve
})
const waiting = new Promise<void>((resolve) => {
blocked = resolve
})
const online = spyOn(Network, "online").mockReturnValue(false)
const targetDir = dir
const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
const hit = path.normalize(d) === path.normalize(targetDir)
if (hit) {
calls += 1
Deferred.doneUnsafe(ready, Effect.void)
await Effect.runPromise(Deferred.await(hold))
const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
online.mockRestore()
run.mockRestore()
}),
start()
await gate
}
const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
if (hit) {
start()
await gate
}
})
const first = yield* installDeps(dir).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
let done = false
const second = yield* installDeps(dir, {
waitTick: () => {
Deferred.doneUnsafe(blocked, Effect.void)
try {
const first = Config.installDependencies(dir)
await ready
const second = Config.installDependencies(dir, {
waitTick: (tick) => {
ticks.push(tick.attempt)
blocked()
blocked = () => {}
},
}).pipe(
Effect.tap(() =>
Effect.sync(() => {
done = true
}),
),
Effect.forkScoped,
)
yield* Deferred.await(blocked)
expect(done).toBe(false)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(calls).toBe(1)
expect(yield* Effect.promise(() => Filesystem.exists(path.join(dir, "package.json")))).toBe(true)
}),
)
it.live("serializes config dependency installs across dirs", () =>
Effect.gen(function* () {
if (process.platform !== "win32") return
const tmp = yield* tmpdirScoped()
const a = path.join(tmp, "a")
const b = path.join(tmp, "b")
yield* Effect.promise(() => fs.mkdir(a, { recursive: true }))
yield* Effect.promise(() => fs.mkdir(b, { recursive: true }))
let calls = 0
let open = 0
let peak = 0
const ready = Deferred.makeUnsafe<void>()
const blocked = Deferred.makeUnsafe<void>()
const hold = Deferred.makeUnsafe<void>()
const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
const cwd = path.normalize(dir)
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
Deferred.doneUnsafe(ready, Effect.void)
await Effect.runPromise(Deferred.await(hold))
}
}
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
if (hit) {
open -= 1
}
})
await waiting
done()
await Promise.all([first, second])
} finally {
online.mockRestore()
run.mockRestore()
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
online.mockRestore()
run.mockRestore()
}),
expect(calls).toBe(2)
expect(ticks.length).toBeGreaterThan(0)
expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
})
test("serializes config dependency installs across dirs", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
await fs.mkdir(a, { recursive: true })
await fs.mkdir(b, { recursive: true })
let calls = 0
let open = 0
let peak = 0
let start = () => {}
let done = () => {}
const ready = new Promise<void>((resolve) => {
start = resolve
})
const gate = new Promise<void>((resolve) => {
done = resolve
})
const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
const cwd = path.normalize(dir)
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
open += 1
peak = Math.max(peak, open)
if (calls === 1) {
start()
await gate
}
}
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
if (hit) {
open -= 1
}
})
const first = yield* installDeps(a).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
try {
const first = Config.installDependencies(a)
await ready
const second = Config.installDependencies(b)
done()
await Promise.all([first, second])
} finally {
online.mockRestore()
run.mockRestore()
}
const second = yield* installDeps(b, {
waitTick: () => {
Deferred.doneUnsafe(blocked, Effect.void)
},
}).pipe(Effect.forkScoped)
yield* Deferred.await(blocked)
expect(peak).toBe(1)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(calls).toBe(2)
expect(peak).toBe(1)
}),
)
expect(calls).toBe(2)
expect(peak).toBe(1)
})
test("resolves scoped npm plugins in config", async () => {
await using tmp = await tmpdir({

View File

@@ -38,9 +38,7 @@ describe("file.ripgrep", () => {
expect(hasVisible).toBe(true)
expect(hasHidden).toBe(false)
})
})
describe("Ripgrep.Service", () => {
test("search returns empty when nothing matches", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -48,34 +46,16 @@ describe("Ripgrep.Service", () => {
},
})
const result = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle" })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
})
test("search returns matched rows", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n")
},
const hits = await Ripgrep.search({
cwd: tmp.path,
pattern: "needle",
})
const result = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toContain("match.ts")
expect(result.items[0]?.lines.text).toContain("needle")
expect(hits).toEqual([])
})
})
describe("Ripgrep.Service", () => {
test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -1,55 +1,55 @@
import { describe, expect, spyOn } from "bun:test"
import { describe, expect, spyOn, test } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { LSP } from "../../src/lsp"
import * as Lsp from "../../src/lsp/index"
import { LSPServer } from "../../src/lsp/server"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
describe("lsp.spawn", () => {
it.live("does not spawn builtin LSP for files outside instance", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
test("does not spawn builtin LSP for files outside instance", async () => {
await using tmp = await tmpdir()
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
yield* lsp.hover({
file: path.join(dir, "..", "hover.ts"),
line: 0,
character: 0,
})
expect(spy).toHaveBeenCalledTimes(0)
} finally {
spy.mockRestore()
}
}),
),
),
)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts"))
await Lsp.LSP.hover({
file: path.join(tmp.path, "..", "hover.ts"),
line: 0,
character: 0,
})
},
})
it.live("would spawn builtin LSP for files inside instance", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
expect(spy).toHaveBeenCalledTimes(0)
} finally {
spy.mockRestore()
await Instance.disposeAll()
}
})
try {
yield* lsp.hover({
file: path.join(dir, "src", "inside.ts"),
line: 0,
character: 0,
})
expect(spy).toHaveBeenCalledTimes(1)
} finally {
spy.mockRestore()
}
}),
),
),
)
test("would spawn builtin LSP for files inside instance", async () => {
await using tmp = await tmpdir()
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Lsp.LSP.hover({
file: path.join(tmp.path, "src", "inside.ts"),
line: 0,
character: 0,
})
},
})
expect(spy).toHaveBeenCalledTimes(1)
} finally {
spy.mockRestore()
await Instance.disposeAll()
}
})
})

View File

@@ -1,13 +1,23 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { LSP } from "../../src/lsp"
import * as Lsp from "../../src/lsp/index"
import { LSPServer } from "../../src/lsp/server"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
function withInstance(fn: (dir: string) => Promise<void>) {
return async () => {
await using tmp = await tmpdir()
try {
await Instance.provide({
directory: tmp.path,
fn: () => fn(tmp.path),
})
} finally {
await Instance.disposeAll()
}
}
}
describe("LSP service lifecycle", () => {
let spawnSpy: ReturnType<typeof spyOn>
@@ -20,112 +30,97 @@ describe("LSP service lifecycle", () => {
spawnSpy.mockRestore()
})
it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init())))
it.live("status() returns empty array initially", () =>
provideTmpdirInstance(() =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.status()
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0)
}),
),
),
test(
"init() completes without error",
withInstance(async () => {
await Lsp.LSP.init()
}),
)
it.live("diagnostics() returns empty object initially", () =>
provideTmpdirInstance(() =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.diagnostics()
expect(typeof result).toBe("object")
expect(Object.keys(result).length).toBe(0)
}),
),
),
test(
"status() returns empty array initially",
withInstance(async () => {
const result = await Lsp.LSP.status()
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0)
}),
)
it.live("hasClients() returns true for .ts files in instance", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
expect(result).toBe(true)
}),
),
),
test(
"diagnostics() returns empty object initially",
withInstance(async () => {
const result = await Lsp.LSP.diagnostics()
expect(typeof result).toBe("object")
expect(Object.keys(result).length).toBe(0)
}),
)
it.live("hasClients() returns false for files outside instance", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts"))
expect(typeof result).toBe("boolean")
}),
),
),
test(
"hasClients() returns true for .ts files in instance",
withInstance(async (dir) => {
const result = await Lsp.LSP.hasClients(path.join(dir, "test.ts"))
expect(result).toBe(true)
}),
)
it.live("workspaceSymbol() returns empty array with no clients", () =>
provideTmpdirInstance(() =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.workspaceSymbol("test")
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0)
}),
),
),
test(
"hasClients() returns false for files outside instance",
withInstance(async (dir) => {
const result = await Lsp.LSP.hasClients(path.join(dir, "..", "outside.ts"))
// hasClients checks servers but doesn't check containsPath — getClients does
// So hasClients may return true even for outside files (it checks extension + root)
// The guard is in getClients, not hasClients
expect(typeof result).toBe("boolean")
}),
)
it.live("definition() returns empty array for unknown file", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.definition({
file: path.join(dir, "nonexistent.ts"),
line: 0,
character: 0,
})
expect(Array.isArray(result)).toBe(true)
}),
),
),
test(
"workspaceSymbol() returns empty array with no clients",
withInstance(async () => {
const result = await Lsp.LSP.workspaceSymbol("test")
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0)
}),
)
it.live("references() returns empty array for unknown file", () =>
provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const result = yield* lsp.references({
file: path.join(dir, "nonexistent.ts"),
line: 0,
character: 0,
})
expect(Array.isArray(result)).toBe(true)
}),
),
),
test(
"definition() returns empty array for unknown file",
withInstance(async (dir) => {
const result = await Lsp.LSP.definition({
file: path.join(dir, "nonexistent.ts"),
line: 0,
character: 0,
})
expect(Array.isArray(result)).toBe(true)
}),
)
it.live("multiple init() calls are idempotent", () =>
provideTmpdirInstance(() =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
yield* lsp.init()
yield* lsp.init()
yield* lsp.init()
}),
),
),
test(
"references() returns empty array for unknown file",
withInstance(async (dir) => {
const result = await Lsp.LSP.references({
file: path.join(dir, "nonexistent.ts"),
line: 0,
character: 0,
})
expect(Array.isArray(result)).toBe(true)
}),
)
test(
"multiple init() calls are idempotent",
withInstance(async () => {
await Lsp.LSP.init()
await Lsp.LSP.init()
await Lsp.LSP.init()
// Should not throw or create duplicate state
}),
)
})
describe("LSP.Diagnostic", () => {
test("pretty() formats error diagnostic", () => {
const result = LSP.Diagnostic.pretty({
const result = Lsp.LSP.Diagnostic.pretty({
range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } },
message: "Type 'string' is not assignable to type 'number'",
severity: 1,
@@ -134,7 +129,7 @@ describe("LSP.Diagnostic", () => {
})
test("pretty() formats warning diagnostic", () => {
const result = LSP.Diagnostic.pretty({
const result = Lsp.LSP.Diagnostic.pretty({
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
message: "Unused variable",
severity: 2,
@@ -143,7 +138,7 @@ describe("LSP.Diagnostic", () => {
})
test("pretty() defaults to ERROR when no severity", () => {
const result = LSP.Diagnostic.pretty({
const result = Lsp.LSP.Diagnostic.pretty({
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
message: "Something wrong",
} as any)

View File

@@ -1,6 +1,5 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
@@ -21,14 +20,8 @@ async function withVcs(directory: string, body: () => Promise<void>) {
return Instance.provide({
directory,
fn: async () => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const watcher = yield* FileWatcher.Service
const vcs = yield* Vcs.Service
yield* watcher.init()
yield* vcs.init()
}),
)
void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
Vcs.init()
await Bun.sleep(500)
await body()
},
@@ -39,12 +32,7 @@ function withVcsOnly(directory: string, body: () => Promise<void>) {
return Instance.provide({
directory,
fn: async () => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.init()
}),
)
Vcs.init()
await body()
},
})
@@ -92,12 +80,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir({ git: true })
await withVcs(tmp.path, async () => {
const branch = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.branch()
}),
)
const branch = await Vcs.branch()
expect(branch).toBeDefined()
expect(typeof branch).toBe("string")
})
@@ -107,12 +90,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir()
await withVcs(tmp.path, async () => {
const branch = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.branch()
}),
)
const branch = await Vcs.branch()
expect(branch).toBeUndefined()
})
})
@@ -145,12 +123,7 @@ describeVcs("Vcs", () => {
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
await pending
const current = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.branch()
}),
)
const current = await Vcs.branch()
expect(current).toBe(branch)
})
})
@@ -166,12 +139,7 @@ describe("Vcs diff", () => {
await $`git branch -M main`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async () => {
const branch = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.defaultBranch()
}),
)
const branch = await Vcs.defaultBranch()
expect(branch).toBe("main")
})
})
@@ -182,12 +150,7 @@ describe("Vcs diff", () => {
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async () => {
const branch = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.defaultBranch()
}),
)
const branch = await Vcs.defaultBranch()
expect(branch).toBe("trunk")
})
})
@@ -200,12 +163,7 @@ describe("Vcs diff", () => {
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
await withVcsOnly(dir, async () => {
const [branch, base] = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
}),
)
const [branch, base] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
expect(branch).toBe("feature/test")
expect(base).toBe("main")
})
@@ -219,12 +177,7 @@ describe("Vcs diff", () => {
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
await withVcsOnly(tmp.path, async () => {
const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
const diff = await Vcs.diff("git")
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -241,12 +194,7 @@ describe("Vcs diff", () => {
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
await withVcsOnly(tmp.path, async () => {
const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
const diff = await Vcs.diff("git")
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -267,12 +215,7 @@ describe("Vcs diff", () => {
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async () => {
const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("branch")
}),
)
const diff = await Vcs.diff("branch")
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({

View File

@@ -9,17 +9,6 @@ import { Provider } from "../../src/provider/provider"
import { Env } from "../../src/env"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"
import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
async function list() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.list()
}),
)
}
test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
await using tmp = await tmpdir({
@@ -46,7 +35,7 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
@@ -71,7 +60,7 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
@@ -127,7 +116,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
},
@@ -172,7 +161,7 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
Env.set("AWS_ACCESS_KEY_ID", "test-key-id")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
},
@@ -203,7 +192,7 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
@@ -239,7 +228,7 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
Env.set("AWS_ACCESS_KEY_ID", "")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
},
@@ -279,7 +268,7 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// The model should exist with the us. prefix
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
@@ -316,7 +305,7 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
@@ -352,7 +341,7 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
@@ -388,7 +377,7 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// Non-prefixed model should still be registered
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()

View File

@@ -30,7 +30,7 @@
// Env.set("GITLAB_TOKEN", "test-gitlab-token")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token")
// },
@@ -62,7 +62,7 @@
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com")
// },
@@ -100,7 +100,7 @@
// Env.set("GITLAB_TOKEN", "")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// },
// })
@@ -135,7 +135,7 @@
// Env.set("GITLAB_TOKEN", "")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token")
// },
@@ -167,7 +167,7 @@
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal")
// },
@@ -198,7 +198,7 @@
// Env.set("GITLAB_TOKEN", "env-token")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// },
// })
@@ -221,7 +221,7 @@
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain(
// "context-1m-2025-08-07",
@@ -257,7 +257,7 @@
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
@@ -282,7 +282,7 @@
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// const models = Object.keys(providers[ProviderID.gitlab].models)
// expect(models.length).toBeGreaterThan(0)
@@ -306,7 +306,7 @@
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// const gitlab = providers[ProviderID.gitlab]
// expect(gitlab).toBeDefined()
// gitlab.models["duo-workflow-sonnet-4-6"] = {
@@ -332,10 +332,10 @@
// release_date: "",
// variants: {},
// }
// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
// expect(model).toBeDefined()
// expect(model.options?.workflowRef).toBe("claude_sonnet_4_6")
// const language = await getLanguage(model)
// const language = await Provider.getLanguage(model)
// expect(language).toBeDefined()
// expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel)
// },
@@ -354,11 +354,11 @@
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// expect(providers[ProviderID.gitlab]).toBeDefined()
// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
// expect(model).toBeDefined()
// const language = await getLanguage(model)
// const language = await Provider.getLanguage(model)
// expect(language).toBeDefined()
// expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel)
// },
@@ -377,10 +377,10 @@
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// const gitlab = providers[ProviderID.gitlab]
// expect(gitlab.options?.featureFlags).toBeDefined()
// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
// expect(model).toBeDefined()
// expect(model.options).toBeDefined()
// },
@@ -401,7 +401,7 @@
// Env.set("GITLAB_TOKEN", "test-token")
// },
// fn: async () => {
// const providers = await list()
// const providers = await Provider.list()
// const models = Object.keys(providers[ProviderID.gitlab].models)
// expect(models).toContain("duo-chat-haiku-4-5")
// expect(models).toContain("duo-chat-sonnet-4-5")

View File

@@ -11,47 +11,8 @@ import { Provider } from "../../src/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem"
import { Env } from "../../src/env"
import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
async function run<A, E>(fn: (provider: Provider.Interface) => Effect.Effect<A, E, never>) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* fn(provider)
}),
)
}
async function list() {
return run((provider) => provider.list())
}
async function getProvider(providerID: ProviderID) {
return run((provider) => provider.getProvider(providerID))
}
async function getModel(providerID: ProviderID, modelID: ModelID) {
return run((provider) => provider.getModel(providerID, modelID))
}
async function getLanguage(model: Provider.Model) {
return run((provider) => provider.getLanguage(model))
}
async function closest(providerID: ProviderID, query: string[]) {
return run((provider) => provider.closest(providerID, query))
}
async function getSmallModel(providerID: ProviderID) {
return run((provider) => provider.getSmallModel(providerID))
}
async function defaultModel() {
return run((provider) => provider.defaultModel())
}
function paid(providers: Awaited<ReturnType<typeof list>>) {
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
const item = providers[ProviderID.make("opencode")]
expect(item).toBeDefined()
return Object.values(item.models).filter((model) => model.cost.input > 0).length
@@ -74,7 +35,7 @@ test("provider loaded from env variable", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
// Provider should retain its connection source even if custom loaders
// merge additional options.
@@ -105,7 +66,7 @@ test("provider loaded from config with apiKey option", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
},
})
@@ -129,7 +90,7 @@ test("disabled_providers excludes provider", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeUndefined()
},
})
@@ -154,7 +115,7 @@ test("enabled_providers restricts to only listed providers", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
},
@@ -183,7 +144,7 @@ test("model whitelist filters models for provider", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514")
@@ -214,7 +175,7 @@ test("model blacklist excludes specific models", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).not.toContain("claude-sonnet-4-20250514")
@@ -249,7 +210,7 @@ test("custom model alias via config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
@@ -292,7 +253,7 @@ test("custom provider with npm package", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
@@ -325,7 +286,7 @@ test("env variable takes precedence, config merges options", async () => {
Env.set("ANTHROPIC_API_KEY", "env-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
// Config options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
@@ -351,11 +312,11 @@ test("getModel returns model for valid provider/model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
const language = await getLanguage(model)
const language = await Provider.getLanguage(model)
expect(language).toBeDefined()
},
})
@@ -378,7 +339,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow()
expect(Provider.getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow()
},
})
})
@@ -397,7 +358,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow()
expect(Provider.getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow()
},
})
})
@@ -431,7 +392,7 @@ test("defaultModel returns first available model when no config set", async () =
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const model = await defaultModel()
const model = await Provider.defaultModel()
expect(model.providerID).toBeDefined()
expect(model.modelID).toBeDefined()
},
@@ -456,7 +417,7 @@ test("defaultModel respects config model setting", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const model = await defaultModel()
const model = await Provider.defaultModel()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
},
@@ -495,7 +456,7 @@ test("provider with baseURL from config", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("custom-openai")]).toBeDefined()
expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1")
},
@@ -533,7 +494,7 @@ test("model cost defaults to zero when not specified", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.cost.input).toBe(0)
expect(model.cost.output).toBe(0)
@@ -571,7 +532,7 @@ test("model options are merged from existing model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value")
},
@@ -600,7 +561,7 @@ test("provider removed when all models filtered out", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeUndefined()
},
})
@@ -623,7 +584,7 @@ test("closest finds model by partial match", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const result = await closest(ProviderID.anthropic, ["sonnet-4"])
const result = await Provider.closest(ProviderID.anthropic, ["sonnet-4"])
expect(result).toBeDefined()
expect(String(result?.providerID)).toBe("anthropic")
expect(String(result?.modelID)).toContain("sonnet-4")
@@ -645,7 +606,7 @@ test("closest returns undefined for nonexistent provider", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await closest(ProviderID.make("nonexistent"), ["model"])
const result = await Provider.closest(ProviderID.make("nonexistent"), ["model"])
expect(result).toBeUndefined()
},
})
@@ -678,10 +639,10 @@ test("getModel uses realIdByKey for aliased models", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet"))
const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("my-sonnet"))
expect(model).toBeDefined()
expect(String(model.id)).toBe("my-sonnet")
expect(model.name).toBe("My Sonnet Alias")
@@ -721,7 +682,7 @@ test("provider api field sets model api.url", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
// api field is stored on model.api.url, used by getSDK to set baseURL
expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1")
},
@@ -761,7 +722,7 @@ test("explicit baseURL overrides api field", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1")
},
})
@@ -793,7 +754,7 @@ test("model inherits properties from existing database model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.name).toBe("Custom Name for Sonnet")
expect(model.capabilities.toolcall).toBe(true)
@@ -821,7 +782,7 @@ test("disabled_providers prevents loading even with env var", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.openai]).toBeUndefined()
},
})
@@ -846,7 +807,7 @@ test("enabled_providers with empty array allows no providers", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(Object.keys(providers).length).toBe(0)
},
})
@@ -875,7 +836,7 @@ test("whitelist and blacklist can be combined", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514")
@@ -914,7 +875,7 @@ test("model modalities default correctly", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.output.text).toBe(true)
@@ -957,7 +918,7 @@ test("model with custom cost values", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.cost.input).toBe(5)
expect(model.cost.output).toBe(15)
@@ -984,7 +945,7 @@ test("getSmallModel returns appropriate small model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const model = await getSmallModel(ProviderID.anthropic)
const model = await Provider.getSmallModel(ProviderID.anthropic)
expect(model).toBeDefined()
expect(model?.id).toContain("haiku")
},
@@ -1009,7 +970,7 @@ test("getSmallModel respects config small_model override", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const model = await getSmallModel(ProviderID.anthropic)
const model = await Provider.getSmallModel(ProviderID.anthropic)
expect(model).toBeDefined()
expect(String(model?.providerID)).toBe("anthropic")
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
@@ -1058,7 +1019,7 @@ test("multiple providers can be configured simultaneously", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeDefined()
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
@@ -1099,7 +1060,7 @@ test("provider with custom npm package", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("local-llm")]).toBeDefined()
expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1")
@@ -1136,7 +1097,7 @@ test("model alias name defaults to alias key when id differs", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
},
})
@@ -1176,7 +1137,7 @@ test("provider with multiple env var options only includes apiKey when single en
Env.set("MULTI_ENV_KEY_1", "test-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
// When multiple env options exist, key should NOT be auto-set
expect(providers[ProviderID.make("multi-env")].key).toBeUndefined()
@@ -1218,7 +1179,7 @@ test("provider with single env var includes apiKey automatically", async () => {
Env.set("SINGLE_ENV_KEY", "my-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("single-env")]).toBeDefined()
// Single env option should auto-set key
expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key")
@@ -1255,7 +1216,7 @@ test("model cost overrides existing cost values", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.cost.input).toBe(999)
expect(model.cost.output).toBe(888)
@@ -1302,7 +1263,7 @@ test("completely new provider not in database can be configured", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined()
expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New")
const model = providers[ProviderID.make("brand-new-provider")].models["new-model"]
@@ -1336,7 +1297,7 @@ test("disabled_providers and enabled_providers interaction", async () => {
Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
// anthropic: in enabled, not in disabled = allowed
expect(providers[ProviderID.anthropic]).toBeDefined()
// openai: in enabled, but also in disabled = NOT allowed
@@ -1376,7 +1337,7 @@ test("model with tool_call false", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false)
},
})
@@ -1411,7 +1372,7 @@ test("model defaults tool_call to true when not specified", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true)
},
})
@@ -1450,7 +1411,7 @@ test("model headers are preserved", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.make("headers-provider")].models["model"]
expect(model.headers).toEqual({
"X-Custom-Header": "custom-value",
@@ -1493,7 +1454,7 @@ test("provider env fallback - second env var used if first missing", async () =>
Env.set("FALLBACK_KEY", "fallback-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
// Provider should load because fallback env var is set
expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
},
@@ -1517,8 +1478,8 @@ test("getModel returns consistent results", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model1 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model2 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
expect(model1.providerID).toEqual(model2.providerID)
expect(model1.id).toEqual(model2.id)
expect(model1).toEqual(model2)
@@ -1555,7 +1516,7 @@ test("provider name defaults to id when not in database", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id")
},
})
@@ -1579,7 +1540,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
},
fn: async () => {
try {
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
expect(true).toBe(false) // Should not reach here
} catch (e: any) {
expect(e.data.suggestions).toBeDefined()
@@ -1607,7 +1568,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
},
fn: async () => {
try {
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
await Provider.getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
expect(true).toBe(false) // Should not reach here
} catch (e: any) {
expect(e.data.suggestions).toBeDefined()
@@ -1631,7 +1592,7 @@ test("getProvider returns undefined for nonexistent provider", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const provider = await getProvider(ProviderID.make("nonexistent"))
const provider = await Provider.getProvider(ProviderID.make("nonexistent"))
expect(provider).toBeUndefined()
},
})
@@ -1654,7 +1615,7 @@ test("getProvider returns provider info", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const provider = await getProvider(ProviderID.anthropic)
const provider = await Provider.getProvider(ProviderID.anthropic)
expect(provider).toBeDefined()
expect(String(provider?.id)).toBe("anthropic")
},
@@ -1678,7 +1639,7 @@ test("closest returns undefined when no partial match found", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"])
const result = await Provider.closest(ProviderID.anthropic, ["nonexistent-xyz-model"])
expect(result).toBeUndefined()
},
})
@@ -1702,7 +1663,7 @@ test("closest checks multiple query terms in order", async () => {
},
fn: async () => {
// First term won't match, second will
const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"])
const result = await Provider.closest(ProviderID.anthropic, ["nonexistent", "haiku"])
expect(result).toBeDefined()
expect(result?.modelID).toContain("haiku")
},
@@ -1738,7 +1699,7 @@ test("model limit defaults to zero when not specified", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.make("no-limit")].models["model"]
expect(model.limit.context).toBe(0)
expect(model.limit.output).toBe(0)
@@ -1773,7 +1734,7 @@ test("provider options are deeply merged", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
// Custom options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value")
@@ -1811,7 +1772,7 @@ test("custom model inherits npm package from models.dev provider config", async
Env.set("OPENAI_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.openai].models["my-custom-model"]
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai")
@@ -1846,7 +1807,7 @@ test("custom model inherits api.url from models.dev provider", async () => {
Env.set("OPENROUTER_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.openrouter]).toBeDefined()
// New model not in database should inherit api.url from provider
@@ -1947,7 +1908,7 @@ test("model variants are generated for reasoning models", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
// Claude sonnet 4 has reasoning capability
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.capabilities.reasoning).toBe(true)
@@ -1985,7 +1946,7 @@ test("model variants can be disabled via config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
@@ -2028,7 +1989,7 @@ test("model variants can be customized via config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
@@ -2067,7 +2028,7 @@ test("disabled key is stripped from variant config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined()
@@ -2105,7 +2066,7 @@ test("all variants can be disabled via config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0)
@@ -2143,7 +2104,7 @@ test("variant config merges with generated variants", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option
@@ -2181,7 +2142,7 @@ test("variants filtered in second pass for database models", async () => {
Env.set("OPENAI_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.openai].models["gpt-5"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
@@ -2227,7 +2188,7 @@ test("custom model with variants enabled and disabled", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"]
expect(model.variants).toBeDefined()
// Enabled variants should exist
@@ -2285,7 +2246,7 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
},
@@ -2330,7 +2291,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
expect(model).toBeDefined()
@@ -2358,7 +2319,7 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
Env.set("CLOUDFLARE_API_TOKEN", "test-token")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
},
})
@@ -2390,7 +2351,7 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
Env.set("CLOUDFLARE_API_TOKEN", "test-token")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
invoked_by: "test",
@@ -2438,7 +2399,7 @@ test("plugin config providers persist after instance dispose", async () => {
directory: tmp.path,
fn: async () => {
await Plugin.init()
return list()
return Provider.list()
},
})
expect(first[ProviderID.make("demo")]).toBeDefined()
@@ -2448,7 +2409,7 @@ test("plugin config providers persist after instance dispose", async () => {
const second = await Instance.provide({
directory: tmp.path,
fn: async () => list(),
fn: async () => Provider.list(),
})
expect(second[ProviderID.make("demo")]).toBeDefined()
expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
@@ -2484,7 +2445,7 @@ test("plugin config enabled and disabled providers are honored", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key")
},
fn: async () => {
const providers = await list()
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
},
@@ -2505,7 +2466,7 @@ test("opencode loader keeps paid models when config apiKey is present", async ()
const none = await Instance.provide({
directory: base.path,
fn: async () => paid(await list()),
fn: async () => paid(await Provider.list()),
})
await using keyed = await tmpdir({
@@ -2528,7 +2489,7 @@ test("opencode loader keeps paid models when config apiKey is present", async ()
const keyedCount = await Instance.provide({
directory: keyed.path,
fn: async () => paid(await list()),
fn: async () => paid(await Provider.list()),
})
expect(none).toBe(0)
@@ -2549,7 +2510,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
const none = await Instance.provide({
directory: base.path,
fn: async () => paid(await list()),
fn: async () => paid(await Provider.list()),
})
await using keyed = await tmpdir({
@@ -2583,7 +2544,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
const keyedCount = await Instance.provide({
directory: keyed.path,
fn: async () => paid(await list()),
fn: async () => paid(await Provider.list()),
})
expect(none).toBe(0)

View File

@@ -1842,11 +1842,6 @@ describe("ProviderTransform.message - cache control on gateway", () => {
type: "ephemeral",
},
},
alibaba: {
cacheControl: {
type: "ephemeral",
},
},
})
})
@@ -1899,11 +1894,6 @@ describe("ProviderTransform.message - cache control on gateway", () => {
type: "ephemeral",
},
},
alibaba: {
cacheControl: {
type: "ephemeral",
},
},
})
})
})

View File

@@ -1,6 +1,4 @@
import { describe, expect, test } from "bun:test"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
@@ -12,48 +10,48 @@ describe("pty", () => {
await Instance.provide({
directory: dir.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* pty.create({ command: "cat", title: "a" })
const b = yield* pty.create({ command: "cat", title: "b" })
try {
const outA: string[] = []
const outB: string[] = []
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
const b = await Pty.create({ command: "cat", title: "b" })
try {
const outA: string[] = []
const outB: string[] = []
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
},
}
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
},
}
yield* pty.connect(a.id, ws as any)
// Connect "a" first with ws.
Pty.connect(a.id, ws as any)
ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
yield* pty.connect(b.id, ws as any)
// Now "reuse" the same ws object for another connection.
ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
Pty.connect(b.id, ws as any)
outA.length = 0
outB.length = 0
// Clear connect metadata writes.
outA.length = 0
outB.length = 0
yield* pty.write(a.id, "AAA\n")
yield* Effect.promise(() => sleep(100))
// Output from a must never show up in b.
Pty.write(a.id, "AAA\n")
await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
yield* pty.remove(a.id)
yield* pty.remove(b.id)
}
}),
),
expect(outB.join("")).not.toContain("AAA")
} finally {
await Pty.remove(a.id)
await Pty.remove(b.id)
}
},
})
})
@@ -62,43 +60,42 @@ describe("pty", () => {
await Instance.provide({
directory: dir.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* pty.create({ command: "cat", title: "a" })
try {
const outA: string[] = []
const outB: string[] = []
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
try {
const outA: string[] = []
const outB: string[] = []
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
},
}
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
},
}
yield* pty.connect(a.id, ws as any)
outA.length = 0
// Connect "a" first.
Pty.connect(a.id, ws as any)
outA.length = 0
ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
// Simulate Bun reusing the same websocket object for another
// connection before the next onOpen calls Pty.connect.
ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
yield* pty.write(a.id, "AAA\n")
yield* Effect.promise(() => sleep(100))
Pty.write(a.id, "AAA\n")
await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
yield* pty.remove(a.id)
}
}),
),
expect(outB.join("")).not.toContain("AAA")
} finally {
await Pty.remove(a.id)
}
},
})
})
@@ -107,40 +104,38 @@ describe("pty", () => {
await Instance.provide({
directory: dir.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* pty.create({ command: "cat", title: "a" })
try {
const out: string[] = []
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
try {
const out: string[] = []
const ctx = { connId: 1 }
const ws = {
readyState: 1,
data: ctx,
send: (data: unknown) => {
out.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op
},
}
const ctx = { connId: 1 }
const ws = {
readyState: 1,
data: ctx,
send: (data: unknown) => {
out.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op
},
}
yield* pty.connect(a.id, ws as any)
out.length = 0
Pty.connect(a.id, ws as any)
out.length = 0
ctx.connId = 2
// Mutating fields on ws.data should not look like a new
// connection lifecycle when the object identity stays stable.
ctx.connId = 2
yield* pty.write(a.id, "AAA\n")
yield* Effect.promise(() => sleep(100))
Pty.write(a.id, "AAA\n")
await sleep(100)
expect(out.join("")).toContain("AAA")
} finally {
yield* pty.remove(a.id)
}
}),
),
expect(out.join("")).toContain("AAA")
} finally {
await Pty.remove(a.id)
}
},
})
})
})

View File

@@ -1,7 +1,5 @@
import { describe, expect, test } from "bun:test"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Bus } from "../../src/bus"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import type { PtyID } from "../../src/pty/schema"
@@ -29,37 +27,33 @@ describe("pty", () => {
await Instance.provide({
directory: dir.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
fn: async () => {
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
let id: PtyID | undefined
try {
const info = yield* pty.create({
command: "/usr/bin/env",
args: ["sh", "-c", "sleep 0.1"],
title: "sleep",
})
id = info.id
let id: PtyID | undefined
try {
const info = await Pty.create({
command: "/usr/bin/env",
args: ["sh", "-c", "sleep 0.1"],
title: "sleep",
})
id = info.id
yield* Effect.promise(() => wait(() => pick(log, id!).includes("exited")))
await wait(() => pick(log, id!).includes("exited"))
yield* pty.remove(id)
yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3))
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) yield* pty.remove(id)
}
}),
),
await Pty.remove(id)
await wait(() => pick(log, id!).length >= 3)
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) await Pty.remove(id)
}
},
})
})
@@ -70,33 +64,29 @@ describe("pty", () => {
await Instance.provide({
directory: dir.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
fn: async () => {
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
let id: PtyID | undefined
try {
const info = yield* pty.create({ command: "/bin/sh", title: "sh" })
id = info.id
let id: PtyID | undefined
try {
const info = await Pty.create({ command: "/bin/sh", title: "sh" })
id = info.id
yield* Effect.promise(() => sleep(100))
await sleep(100)
yield* pty.remove(id)
yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3))
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) yield* pty.remove(id)
}
}),
),
await Pty.remove(id)
await wait(() => pick(log, id!).length >= 3)
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) await Pty.remove(id)
}
},
})
})
})

View File

@@ -1,6 +1,4 @@
import { describe, expect, test } from "bun:test"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { Shell } from "../../src/shell/shell"
@@ -19,18 +17,14 @@ describe("pty shell args", () => {
await using dir = await tmpdir()
await Instance.provide({
directory: dir.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* pty.create({ command: ps, title: "pwsh" })
try {
expect(info.args).toEqual([])
} finally {
yield* pty.remove(info.id)
}
}),
),
fn: async () => {
const info = await Pty.create({ command: ps, title: "pwsh" })
try {
expect(info.args).toEqual([])
} finally {
await Pty.remove(info.id)
}
},
})
},
{ timeout: 30000 },
@@ -49,18 +43,14 @@ describe("pty shell args", () => {
await using dir = await tmpdir()
await Instance.provide({
directory: dir.path,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* pty.create({ command: bash, title: "bash" })
try {
expect(info.args).toEqual(["-l"])
} finally {
yield* pty.remove(info.id)
}
}),
),
fn: async () => {
const info = await Pty.create({ command: bash, title: "bash" })
try {
expect(info.args).toEqual(["-l"])
} finally {
await Pty.remove(info.id)
}
},
})
},
{ timeout: 30000 },

View File

@@ -43,6 +43,7 @@ describe("project.initGit endpoint", () => {
worktree: tmp.path,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
expect(reloadSpy.mock.calls[0]?.[0]?.init).toBe(InstanceBootstrap)
expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe(
true,
)

View File

@@ -1005,15 +1005,6 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
})
@@ -1032,15 +1023,7 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
cachedInputTokens: 200,
},
})
@@ -1056,15 +1039,6 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
metadata: {
anthropic: {
@@ -1085,15 +1059,7 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
cachedInputTokens: 200,
},
metadata: {
anthropic: {},
@@ -1112,15 +1078,7 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 400,
reasoningTokens: 100,
},
reasoningTokens: 100,
},
})
@@ -1146,15 +1104,7 @@ describe("session.getUsage", () => {
inputTokens: 0,
outputTokens: 1_000_000,
totalTokens: 1_000_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 750_000,
reasoningTokens: 250_000,
},
reasoningTokens: 250_000,
},
})
@@ -1171,15 +1121,6 @@ describe("session.getUsage", () => {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
})
@@ -1207,15 +1148,6 @@ describe("session.getUsage", () => {
inputTokens: 1_000_000,
outputTokens: 100_000,
totalTokens: 1_100_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
})
@@ -1231,15 +1163,7 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
cachedInputTokens: 200,
}
if (npm === "@ai-sdk/amazon-bedrock") {
const result = Session.getUsage({
@@ -1290,15 +1214,7 @@ describe("session.getUsage", () => {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
cachedInputTokens: 200,
},
metadata: {
vertex: {

View File

@@ -219,59 +219,6 @@ describe("Instruction.resolve", () => {
test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
})
describe("Instruction.system", () => {
test("loads both project and global AGENTS.md when both exist", async () => {
const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
delete process.env["OPENCODE_CONFIG_DIR"]
await using globalTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
},
})
await using projectTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions")
},
})
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
try {
await Instance.provide({
directory: projectTmp.path,
fn: () =>
run(
Instruction.Service.use((svc) =>
Effect.gen(function* () {
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
const rules = yield* svc.system()
expect(rules).toHaveLength(2)
expect(rules).toContain(
`Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`,
)
expect(rules).toContain(
`Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`,
)
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
if (originalConfigDir === undefined) {
delete process.env["OPENCODE_CONFIG_DIR"]
} else {
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
}
}
})
})
describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
let originalConfigDir: string | undefined

View File

@@ -1,7 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { tool, type ModelMessage } from "ai"
import { Cause, Effect, Exit, Stream } from "effect"
import { Cause, Exit, Stream } from "effect"
import z from "zod"
import { makeRuntime } from "../../src/effect/run-service"
import { LLM } from "../../src/session/llm"
@@ -15,16 +15,6 @@ import { tmpdir } from "../fixture/fixture"
import type { Agent } from "../../src/agent/agent"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
async function getModel(providerID: ProviderID, modelID: ModelID) {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.getModel(providerID, modelID)
}),
)
}
describe("session.llm.hasToolCalls", () => {
test("returns false for empty messages array", () => {
@@ -335,7 +325,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-1")
const agent = {
name: "test",
@@ -426,7 +416,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-raw-abort")
const agent = {
name: "test",
@@ -500,7 +490,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-service-abort")
const agent = {
name: "test",
@@ -591,7 +581,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-tools")
const agent = {
name: "test",
@@ -709,7 +699,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
const sessionID = SessionID.make("session-test-2")
const agent = {
name: "test",
@@ -829,7 +819,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
const sessionID = SessionID.make("session-test-data-url")
const agent = {
name: "test",
@@ -952,7 +942,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-3")
const agent = {
name: "test",
@@ -1053,7 +1043,7 @@ describe("session.llm.stream", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-4")
const agent = {
name: "test",

View File

@@ -204,7 +204,7 @@ const it = testEffect(makeHttp())
const unix = process.platform !== "win32" ? it.live : it.live.skip
// Config that registers a custom "test" provider with a "test-model" model
// so provider model lookup succeeds inside the loop.
// so Provider.getModel("test", "test-model") succeeds inside the loop.
const cfg = {
provider: {
test: {

View File

@@ -1,15 +1,13 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { afterEach, test, expect } from "bun:test"
import { Skill } from "../../src/skill"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node))
afterEach(async () => {
await Instance.disposeAll()
})
async function createGlobalSkill(homeDir: string) {
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
@@ -28,29 +26,14 @@ This skill is loaded from the global home directory.
)
}
const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const prev = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = home
return prev
}),
() => self,
(prev) =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = prev
}),
)
describe("skill", () => {
it.live("discovers skills from .opencode/skill/ directory", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"),
`---
test("discovers skills from .opencode/skill/ directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "test-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: test-skill
description: A test skill for verification.
---
@@ -59,217 +42,230 @@ description: A test skill for verification.
Instructions here.
`,
),
)
)
},
})
const skill = yield* Skill.Service
const list = yield* skill.all()
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "test-skill")
expect(item).toBeDefined()
expect(item!.description).toBe("A test skill for verification.")
expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
}),
{ git: true },
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(1)
const testSkill = skills.find((s) => s.name === "test-skill")
expect(testSkill).toBeDefined()
expect(testSkill!.description).toBe("A test skill for verification.")
expect(testSkill!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
},
})
})
it.live("returns skill directories from Skill.dirs", () =>
provideTmpdirInstance(
(dir) =>
withHome(
dir,
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"),
`---
test("returns skill directories from Skill.dirs", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "dir-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: dir-skill
description: Skill for dirs test.
---
# Dir Skill
`,
),
)
)
},
})
const skill = yield* Skill.Service
const dirs = yield* skill.dirs()
expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill"))
expect(dirs.length).toBe(1)
}),
),
{ git: true },
),
)
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
it.live("discovers multiple skills from .opencode/skill/ directory", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
Bun.write(
path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"),
`---
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await Skill.dirs()
const skillDir = path.join(tmp.path, ".opencode", "skill", "dir-skill")
expect(dirs).toContain(skillDir)
expect(dirs.length).toBe(1)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
test("discovers multiple skills from .opencode/skill/ directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir1 = path.join(dir, ".opencode", "skill", "skill-one")
const skillDir2 = path.join(dir, ".opencode", "skill", "skill-two")
await Bun.write(
path.join(skillDir1, "SKILL.md"),
`---
name: skill-one
description: First test skill.
---
# Skill One
`,
),
Bun.write(
path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"),
`---
)
await Bun.write(
path.join(skillDir2, "SKILL.md"),
`---
name: skill-two
description: Second test skill.
---
# Skill Two
`,
),
]),
)
)
},
})
const skill = yield* Skill.Service
const list = yield* skill.all()
expect(list.length).toBe(2)
expect(list.find((x) => x.name === "skill-one")).toBeDefined()
expect(list.find((x) => x.name === "skill-two")).toBeDefined()
}),
{ git: true },
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(2)
expect(skills.find((s) => s.name === "skill-one")).toBeDefined()
expect(skills.find((s) => s.name === "skill-two")).toBeDefined()
},
})
})
it.live("skips skills with missing frontmatter", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"),
`# No Frontmatter
test("skips skills with missing frontmatter", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "no-frontmatter")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`# No Frontmatter
Just some content without YAML frontmatter.
`,
),
)
)
},
})
const skill = yield* Skill.Service
expect(yield* skill.all()).toEqual([])
}),
{ git: true },
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills).toEqual([])
},
})
})
it.live("discovers skills from .claude/skills/ directory", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`---
test("discovers skills from .claude/skills/ directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".claude", "skills", "claude-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
),
)
const skill = yield* Skill.Service
const list = yield* skill.all()
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "claude-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
}),
{ git: true },
),
)
it.live("discovers global skills from ~/.claude/skills/ directory", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
},
})
yield* withHome(
tmp.path,
Effect.gen(function* () {
yield* Effect.promise(() => createGlobalSkill(tmp.path))
yield* Effect.gen(function* () {
const skill = yield* Skill.Service
const list = yield* skill.all()
expect(list.length).toBe(1)
expect(list[0].name).toBe("global-test-skill")
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
}).pipe(provideInstance(tmp.path))
}),
)
}),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(1)
const claudeSkill = skills.find((s) => s.name === "claude-skill")
expect(claudeSkill).toBeDefined()
expect(claudeSkill!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
},
})
})
it.live("returns empty array when no skills exist", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const skill = yield* Skill.Service
expect(yield* skill.all()).toEqual([])
}),
{ git: true },
),
)
test("discovers global skills from ~/.claude/skills/ directory", async () => {
await using tmp = await tmpdir({ git: true })
it.live("discovers skills from .agents/skills/ directory", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`---
const originalHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await createGlobalSkill(tmp.path)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(1)
expect(skills[0].name).toBe("global-test-skill")
expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.")
expect(skills[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
}
})
test("returns empty array when no skills exist", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills).toEqual([])
},
})
})
test("discovers skills from .agents/skills/ directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".agents", "skills", "agent-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
),
)
const skill = yield* Skill.Service
const list = yield* skill.all()
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "agent-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
}),
{ git: true },
),
)
it.live("discovers global skills from ~/.agents/skills/ directory", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
},
})
yield* withHome(
tmp.path,
Effect.gen(function* () {
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(skillDir, "SKILL.md"),
`---
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(1)
const agentSkill = skills.find((s) => s.name === "agent-skill")
expect(agentSkill).toBeDefined()
expect(agentSkill!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
},
})
})
test("discovers global skills from ~/.agents/skills/ directory", async () => {
await using tmp = await tmpdir({ git: true })
const originalHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
await fs.mkdir(skillDir, { recursive: true })
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: global-agent-skill
description: A global skill from ~/.agents/skills for testing.
---
@@ -278,114 +274,119 @@ description: A global skill from ~/.agents/skills for testing.
This skill is loaded from the global home directory.
`,
),
)
)
yield* Effect.gen(function* () {
const skill = yield* Skill.Service
const list = yield* skill.all()
expect(list.length).toBe(1)
expect(list[0].name).toBe("global-agent-skill")
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
}).pipe(provideInstance(tmp.path))
}),
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(1)
expect(skills[0].name).toBe("global-agent-skill")
expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
expect(skills[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
}
})
test("discovers skills from both .claude/skills/ and .agents/skills/", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
await Bun.write(
path.join(claudeDir, "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
)
}),
)
it.live("discovers skills from both .claude/skills/ and .agents/skills/", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
Bun.write(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
),
Bun.write(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`---
await Bun.write(
path.join(agentDir, "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
),
]),
)
)
},
})
const skill = yield* Skill.Service
const list = yield* skill.all()
expect(list.length).toBe(2)
expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
}),
{ git: true },
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(2)
expect(skills.find((s) => s.name === "claude-skill")).toBeDefined()
expect(skills.find((s) => s.name === "agent-skill")).toBeDefined()
},
})
})
it.live("properly resolves directories that skills live in", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
Bun.write(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`---
test("properly resolves directories that skills live in", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const opencodeSkillDir = path.join(dir, ".opencode", "skill", "agent-skill")
const opencodeSkillsDir = path.join(dir, ".opencode", "skills", "agent-skill")
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
await Bun.write(
path.join(claudeDir, "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
),
Bun.write(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`---
)
await Bun.write(
path.join(agentDir, "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
),
Bun.write(
path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
`---
)
await Bun.write(
path.join(opencodeSkillDir, "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skill directory.
---
# OpenCode Skill
`,
),
Bun.write(
path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
`---
)
await Bun.write(
path.join(opencodeSkillsDir, "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skills directory.
---
# OpenCode Skill
`,
),
]),
)
)
},
})
const skill = yield* Skill.Service
expect((yield* skill.dirs()).length).toBe(4)
}),
{ git: true },
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await Skill.dirs()
expect(dirs.length).toBe(4)
},
})
})

View File

@@ -65,18 +65,6 @@ const readFileTime = (sessionID: SessionID, filepath: string) =>
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
async function onceBus<D extends BusEvent.Definition>(def: D) {
const result = Promise.withResolvers<void>()
const unsub = await subscribeBus(def, () => {
unsub()
result.resolve()
})
return {
wait: result.promise,
unsub,
}
}
describe("tool.edit", () => {
describe("creating new files", () => {
test("creates new file when oldString is empty", async () => {
@@ -140,25 +128,23 @@ describe("tool.edit", () => {
fn: async () => {
const { FileWatcher } = await import("../../src/file/watcher")
const updated = await onceBus(FileWatcher.Event.Updated)
const events: string[] = []
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
try {
const edit = await resolve()
await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "",
newString: "content",
},
ctx,
),
)
const edit = await resolve()
await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "",
newString: "content",
},
ctx,
),
)
await updated.wait
} finally {
updated.unsub()
}
expect(events).toContain("updated")
unsubUpdated()
},
})
})
@@ -373,25 +359,23 @@ describe("tool.edit", () => {
const { FileWatcher } = await import("../../src/file/watcher")
const updated = await onceBus(FileWatcher.Event.Updated)
const events: string[] = []
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
try {
const edit = await resolve()
await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "original",
newString: "modified",
},
ctx,
),
)
const edit = await resolve()
await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "original",
newString: "modified",
},
ctx,
),
)
await updated.wait
} finally {
updated.unsub()
}
expect(events).toContain("updated")
unsubUpdated()
},
})
})

View File

@@ -1,26 +1,22 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Effect, Layer, ManagedRuntime } from "effect"
import { GrepTool } from "../../src/tool/grep"
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "../../src/filesystem"
import { testEffect } from "../lib/effect"
const it = testEffect(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
}
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
@@ -35,59 +31,99 @@ const ctx = {
const projectRoot = path.join(__dirname, "../..")
describe("tool.grep", () => {
it.live("basic search", () =>
Effect.gen(function* () {
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* provideInstance(projectRoot)(
grep.execute(
{
pattern: "export",
path: path.join(projectRoot, "src/tool"),
include: "*.ts",
},
ctx,
),
)
expect(result.metadata.matches).toBeGreaterThan(0)
expect(result.output).toContain("Found")
}),
)
test("basic search", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const grep = await initGrep()
const result = await Effect.runPromise(
grep.execute(
{
pattern: "export",
path: path.join(projectRoot, "src/tool"),
include: "*.ts",
},
ctx,
),
)
expect(result.metadata.matches).toBeGreaterThan(0)
expect(result.output).toContain("Found")
},
})
})
it.live("no matches returns correct output", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
path: dir,
},
ctx,
test("no matches returns correct output", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await initGrep()
const result = await Effect.runPromise(
grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
path: tmp.path,
},
ctx,
),
)
expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found")
}),
),
)
},
})
})
it.live("finds matches in tmp instance", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line",
path: dir,
},
ctx,
test("handles CRLF line endings in output", async () => {
// This test verifies the regex split handles both \n and \r\n
await using tmp = await tmpdir({
init: async (dir) => {
// Create a test file with content
await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await initGrep()
const result = await Effect.runPromise(
grep.execute(
{
pattern: "line",
path: tmp.path,
},
ctx,
),
)
expect(result.metadata.matches).toBeGreaterThan(0)
}),
),
)
},
})
})
})
describe("CRLF regex handling", () => {
test("regex correctly splits Unix line endings", () => {
const unixOutput = "file1.txt|1|content1\nfile2.txt|2|content2\nfile3.txt|3|content3"
const lines = unixOutput.trim().split(/\r?\n/)
expect(lines.length).toBe(3)
expect(lines[0]).toBe("file1.txt|1|content1")
expect(lines[2]).toBe("file3.txt|3|content3")
})
test("regex correctly splits Windows CRLF line endings", () => {
const windowsOutput = "file1.txt|1|content1\r\nfile2.txt|2|content2\r\nfile3.txt|3|content3"
const lines = windowsOutput.trim().split(/\r?\n/)
expect(lines.length).toBe(3)
expect(lines[0]).toBe("file1.txt|1|content1")
expect(lines[2]).toBe("file3.txt|3|content3")
})
test("regex handles mixed line endings", () => {
const mixedOutput = "file1.txt|1|content1\nfile2.txt|2|content2\r\nfile3.txt|3|content3"
const lines = mixedOutput.trim().split(/\r?\n/)
expect(lines.length).toBe(3)
})
})

View File

@@ -1,152 +1,157 @@
import { afterEach, describe, expect } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Effect, Layer } from "effect"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
afterEach(async () => {
await Instance.disposeAll()
})
describe("tool.registry", () => {
it.live("loads tools from .opencode/tool (singular)", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const opencode = path.join(dir, ".opencode")
const tool = path.join(opencode, "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tool, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
),
)
test("loads tools from .opencode/tool (singular)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
it.live("loads tools from .opencode/tools (plural)", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const opencode = path.join(dir, ".opencode")
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
),
)
const toolDir = path.join(opencodeDir, "tool")
await fs.mkdir(toolDir, { recursive: true })
it.live("loads tools with external dependencies without crashing", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const opencode = path.join(dir, ".opencode")
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package.json"),
JSON.stringify({
name: "custom-tools",
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
}),
),
await Bun.write(
path.join(toolDir, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
)
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ids = await ToolRegistry.ids()
expect(ids).toContain("hello")
},
})
})
test("loads tools from .opencode/tools (plural)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const toolsDir = path.join(opencodeDir, "tools")
await fs.mkdir(toolsDir, { recursive: true })
await Bun.write(
path.join(toolsDir, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ids = await ToolRegistry.ids()
expect(ids).toContain("hello")
},
})
})
test("loads tools with external dependencies without crashing", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const toolsDir = path.join(opencodeDir, "tools")
await fs.mkdir(toolsDir, { recursive: true })
await Bun.write(
path.join(opencodeDir, "package.json"),
JSON.stringify({
name: "custom-tools",
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
}),
)
await Bun.write(
path.join(opencodeDir, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
},
}),
),
},
}),
)
const cowsay = path.join(opencode, "node_modules", "cowsay")
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
),
const cowsayDir = path.join(opencodeDir, "node_modules", "cowsay")
await fs.mkdir(cowsayDir, { recursive: true })
await Bun.write(
path.join(cowsayDir, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
)
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
),
await Bun.write(
path.join(cowsayDir, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
)
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "cowsay.ts"),
[
"import { say } from 'cowsay'",
"export default {",
" description: 'tool that imports cowsay at top level',",
" args: { text: { type: 'string' } },",
" execute: async ({ text }: { text: string }) => {",
" return say({ text })",
" },",
"}",
"",
].join("\n"),
),
await Bun.write(
path.join(toolsDir, "cowsay.ts"),
[
"import { say } from 'cowsay'",
"export default {",
" description: 'tool that imports cowsay at top level',",
" args: { text: { type: 'string' } },",
" execute: async ({ text }: { text: string }) => {",
" return say({ text })",
" },",
"}",
"",
].join("\n"),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ids = await ToolRegistry.ids()
expect(ids).toContain("cowsay")
}),
),
)
},
})
})
})

View File

@@ -1,7 +1,6 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Agent } from "../../src/agent/agent"
import { Skill } from "../../src/skill"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Truncate } from "../../src/tool/truncate"
import { afterEach, describe, expect, test } from "bun:test"
@@ -12,9 +11,8 @@ import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: SessionID.make("ses_test"),
@@ -30,92 +28,85 @@ afterEach(async () => {
await Instance.disposeAll()
})
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
describe("tool.skill", () => {
it.live("description lists skill location URL", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),
`---
test("description lists skill location URL", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "tool-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: tool-skill
description: Skill for tool tests.
---
# Tool Skill
`,
),
)
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
const registry = yield* ToolRegistry.Service
const desc =
(yield* registry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent: { name: "build", mode: "primary", permission: [], options: {} },
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
expect(desc).toContain("**tool-skill**: Skill for tool tests.")
}),
{ git: true },
),
)
)
},
})
it.live("description sorts skills by name and is stable across calls", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
for (const [name, description] of [
["zeta-skill", "Zeta skill."],
["alpha-skill", "Alpha skill."],
["middle-skill", "Middle skill."],
]) {
const skill = path.join(dir, ".opencode", "skill", name)
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),
`---
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const desc = await ToolRegistry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent: { name: "build", mode: "primary" as const, permission: [], options: {} },
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "")
expect(desc).toContain(`**tool-skill**: Skill for tool tests.`)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
test("description sorts skills by name and is stable across calls", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
for (const [name, description] of [
["zeta-skill", "Zeta skill."],
["alpha-skill", "Alpha skill."],
["middle-skill", "Middle skill."],
]) {
const skillDir = path.join(dir, ".opencode", "skill", name)
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}
`,
),
)
}
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
}
},
})
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const registry = yield* ToolRegistry.Service
const load = Effect.fnUntraced(function* () {
return (
(yield* registry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
)
})
const first = yield* load()
const second = yield* load()
const load = () =>
ToolRegistry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "")
const first = await load()
const second = await load()
expect(first).toBe(second)
@@ -126,10 +117,12 @@ description: ${description}
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)
expect(zeta).toBeGreaterThan(middle)
}),
{ git: true },
),
)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
test("execute returns skill content block with files", async () => {
await using tmp = await tmpdir({

View File

@@ -77,12 +77,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
workspace: config?.experimental_workspaceID,
}),
)
client.interceptors.response.use((response) => {
const contentType = response.headers.get("content-type")
if (contentType === "text/html")
throw new Error("Request is not supported by this version of OpenCode Server (Server responded with text/html)")
return response
})
return new OpencodeClient({ client })
const result = new OpencodeClient({ client })
return result
}

View File

@@ -33,13 +33,6 @@ export type EventProjectUpdated = {
properties: Project
}
export type EventServerInstanceDisposed = {
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventInstallationUpdated = {
type: "installation.updated"
properties: {
@@ -54,6 +47,13 @@ export type EventInstallationUpdateAvailable = {
}
}
export type EventServerInstanceDisposed = {
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventServerConnected = {
type: "server.connected"
properties: {
@@ -68,21 +68,6 @@ export type EventGlobalDisposed = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type EventLspClientDiagnostics = {
type: "lsp.client.diagnostics"
properties: {
@@ -230,6 +215,107 @@ export type EventSessionError = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
branch?: string
}
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
/**
* Duration in milliseconds
*/
duration?: number
}
}
export type EventTuiSessionSelect = {
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type EventMcpToolsChanged = {
type: "mcp.tools.changed"
properties: {
server: string
}
}
export type EventMcpBrowserOpenFailed = {
type: "mcp.browser.open.failed"
properties: {
mcpName: string
url: string
}
}
export type EventCommandExecuted = {
type: "command.executed"
properties: {
name: string
sessionID: string
arguments: string
messageID: string
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
@@ -360,92 +446,6 @@ export type EventSessionCompacted = {
}
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
/**
* Duration in milliseconds
*/
duration?: number
}
}
export type EventTuiSessionSelect = {
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type EventMcpToolsChanged = {
type: "mcp.tools.changed"
properties: {
server: string
}
}
export type EventMcpBrowserOpenFailed = {
type: "mcp.browser.open.failed"
properties: {
mcpName: string
url: string
}
}
export type EventCommandExecuted = {
type: "command.executed"
properties: {
name: string
sessionID: string
arguments: string
messageID: string
}
}
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
branch?: string
}
}
export type EventWorktreeReady = {
type: "worktree.ready"
properties: {
@@ -973,13 +973,11 @@ export type EventSessionDeleted = {
export type Event =
| EventProjectUpdated
| EventServerInstanceDisposed
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventServerInstanceDisposed
| EventServerConnected
| EventGlobalDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessagePartDelta
@@ -987,13 +985,9 @@ export type Event =
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventFileEdited
| EventFileWatcherUpdated
| EventVcsBranchUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
@@ -1001,7 +995,13 @@ export type Event =
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTodoUpdated
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventWorktreeReady
| EventWorktreeFailed
| EventPtyCreated

View File

@@ -7230,25 +7230,6 @@
},
"required": ["type", "properties"]
},
"Event.server.instance.disposed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "server.instance.disposed"
},
"properties": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"]
}
},
"required": ["type", "properties"]
},
"Event.installation.updated": {
"type": "object",
"properties": {
@@ -7287,6 +7268,25 @@
},
"required": ["type", "properties"]
},
"Event.server.instance.disposed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "server.instance.disposed"
},
"properties": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"]
}
},
"required": ["type", "properties"]
},
"Event.server.connected": {
"type": "object",
"properties": {
@@ -7315,60 +7315,6 @@
},
"required": ["type", "properties"]
},
"Event.file.edited": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.edited"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
}
},
"required": ["file"]
}
},
"required": ["type", "properties"]
},
"Event.file.watcher.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.watcher.updated"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"event": {
"anyOf": [
{
"type": "string",
"const": "add"
},
{
"type": "string",
"const": "change"
},
{
"type": "string",
"const": "unlink"
}
]
}
},
"required": ["file", "event"]
}
},
"required": ["type", "properties"]
},
"Event.lsp.client.diagnostics": {
"type": "object",
"properties": {
@@ -7785,6 +7731,264 @@
},
"required": ["type", "properties"]
},
"Event.file.edited": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.edited"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
}
},
"required": ["file"]
}
},
"required": ["type", "properties"]
},
"Event.file.watcher.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "file.watcher.updated"
},
"properties": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"event": {
"anyOf": [
{
"type": "string",
"const": "add"
},
{
"type": "string",
"const": "change"
},
{
"type": "string",
"const": "unlink"
}
]
}
},
"required": ["file", "event"]
}
},
"required": ["type", "properties"]
},
"Event.vcs.branch.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "vcs.branch.updated"
},
"properties": {
"type": "object",
"properties": {
"branch": {
"type": "string"
}
}
}
},
"required": ["type", "properties"]
},
"Event.tui.prompt.append": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "tui.prompt.append"
},
"properties": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": ["text"]
}
},
"required": ["type", "properties"]
},
"Event.tui.command.execute": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "tui.command.execute"
},
"properties": {
"type": "object",
"properties": {
"command": {
"anyOf": [
{
"type": "string",
"enum": [
"session.list",
"session.new",
"session.share",
"session.interrupt",
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"prompt.clear",
"prompt.submit",
"agent.cycle"
]
},
{
"type": "string"
}
]
}
},
"required": ["command"]
}
},
"required": ["type", "properties"]
},
"Event.tui.toast.show": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "tui.toast.show"
},
"properties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"message": {
"type": "string"
},
"variant": {
"type": "string",
"enum": ["info", "success", "warning", "error"]
},
"duration": {
"description": "Duration in milliseconds",
"default": 5000,
"type": "number"
}
},
"required": ["message", "variant"]
}
},
"required": ["type", "properties"]
},
"Event.tui.session.select": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "tui.session.select"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"description": "Session ID to navigate to",
"type": "string",
"pattern": "^ses.*"
}
},
"required": ["sessionID"]
}
},
"required": ["type", "properties"]
},
"Event.mcp.tools.changed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mcp.tools.changed"
},
"properties": {
"type": "object",
"properties": {
"server": {
"type": "string"
}
},
"required": ["server"]
}
},
"required": ["type", "properties"]
},
"Event.mcp.browser.open.failed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mcp.browser.open.failed"
},
"properties": {
"type": "object",
"properties": {
"mcpName": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["mcpName", "url"]
}
},
"required": ["type", "properties"]
},
"Event.command.executed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "command.executed"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"arguments": {
"type": "string"
},
"messageID": {
"type": "string",
"pattern": "^msg.*"
}
},
"required": ["name", "sessionID", "arguments", "messageID"]
}
},
"required": ["type", "properties"]
},
"QuestionOption": {
"type": "object",
"properties": {
@@ -8085,210 +8289,6 @@
},
"required": ["type", "properties"]
},
"Event.tui.prompt.append": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "tui.prompt.append"
},
"properties": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": ["text"]
}
},
"required": ["type", "properties"]
},
"Event.tui.command.execute": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "tui.command.execute"
},
"properties": {
"type": "object",
"properties": {
"command": {
"anyOf": [
{
"type": "string",
"enum": [
"session.list",
"session.new",
"session.share",
"session.interrupt",
"session.compact",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"prompt.clear",
"prompt.submit",
"agent.cycle"
]
},
{
"type": "string"
}
]
}
},
"required": ["command"]
}
},
"required": ["type", "properties"]
},
"Event.tui.toast.show": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "tui.toast.show"
},
"properties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"message": {
"type": "string"
},
"variant": {
"type": "string",
"enum": ["info", "success", "warning", "error"]
},
"duration": {
"description": "Duration in milliseconds",
"default": 5000,
"type": "number"
}
},
"required": ["message", "variant"]
}
},
"required": ["type", "properties"]
},
"Event.tui.session.select": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "tui.session.select"
},
"properties": {
"type": "object",
"properties": {
"sessionID": {
"description": "Session ID to navigate to",
"type": "string",
"pattern": "^ses.*"
}
},
"required": ["sessionID"]
}
},
"required": ["type", "properties"]
},
"Event.mcp.tools.changed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mcp.tools.changed"
},
"properties": {
"type": "object",
"properties": {
"server": {
"type": "string"
}
},
"required": ["server"]
}
},
"required": ["type", "properties"]
},
"Event.mcp.browser.open.failed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mcp.browser.open.failed"
},
"properties": {
"type": "object",
"properties": {
"mcpName": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": ["mcpName", "url"]
}
},
"required": ["type", "properties"]
},
"Event.command.executed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "command.executed"
},
"properties": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"sessionID": {
"type": "string",
"pattern": "^ses.*"
},
"arguments": {
"type": "string"
},
"messageID": {
"type": "string",
"pattern": "^msg.*"
}
},
"required": ["name", "sessionID", "arguments", "messageID"]
}
},
"required": ["type", "properties"]
},
"Event.vcs.branch.updated": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "vcs.branch.updated"
},
"properties": {
"type": "object",
"properties": {
"branch": {
"type": "string"
}
}
}
},
"required": ["type", "properties"]
},
"Event.worktree.ready": {
"type": "object",
"properties": {
@@ -9874,27 +9874,21 @@
{
"$ref": "#/components/schemas/Event.project.updated"
},
{
"$ref": "#/components/schemas/Event.server.instance.disposed"
},
{
"$ref": "#/components/schemas/Event.installation.updated"
},
{
"$ref": "#/components/schemas/Event.installation.update-available"
},
{
"$ref": "#/components/schemas/Event.server.instance.disposed"
},
{
"$ref": "#/components/schemas/Event.server.connected"
},
{
"$ref": "#/components/schemas/Event.global.disposed"
},
{
"$ref": "#/components/schemas/Event.file.edited"
},
{
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
},
@@ -9917,25 +9911,13 @@
"$ref": "#/components/schemas/Event.session.error"
},
{
"$ref": "#/components/schemas/Event.question.asked"
"$ref": "#/components/schemas/Event.file.edited"
},
{
"$ref": "#/components/schemas/Event.question.replied"
"$ref": "#/components/schemas/Event.file.watcher.updated"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{
"$ref": "#/components/schemas/Event.session.status"
},
{
"$ref": "#/components/schemas/Event.session.idle"
},
{
"$ref": "#/components/schemas/Event.session.compacted"
"$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$ref": "#/components/schemas/Event.tui.prompt.append"
@@ -9959,7 +9941,25 @@
"$ref": "#/components/schemas/Event.command.executed"
},
{
"$ref": "#/components/schemas/Event.vcs.branch.updated"
"$ref": "#/components/schemas/Event.question.asked"
},
{
"$ref": "#/components/schemas/Event.question.replied"
},
{
"$ref": "#/components/schemas/Event.question.rejected"
},
{
"$ref": "#/components/schemas/Event.todo.updated"
},
{
"$ref": "#/components/schemas/Event.session.status"
},
{
"$ref": "#/components/schemas/Event.session.idle"
},
{
"$ref": "#/components/schemas/Event.session.compacted"
},
{
"$ref": "#/components/schemas/Event.worktree.ready"

View File

@@ -1,5 +1,6 @@
import { parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs"
import { formatPatch, parsePatch, structuredPatch } from "diff"
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { formatPatch, structuredPatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
type LegacyDiff = {
@@ -40,50 +41,26 @@ function empty(file: string, key: string) {
}
function patch(diff: ReviewDiff) {
if (typeof diff.patch === "string") {
const [patch] = parsePatch(diff.patch)
const beforeLines = []
const afterLines = []
for (const hunk of patch.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("-")) {
beforeLines.push(line.slice(1))
} else if (line.startsWith("+")) {
afterLines.push(line.slice(1))
} else {
// context line (starts with ' ')
beforeLines.push(line.slice(1))
afterLines.push(line.slice(1))
}
}
}
return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch }
}
return {
before: "before" in diff && typeof diff.before === "string" ? diff.before : "",
after: "after" in diff && typeof diff.after === "string" ? diff.after : "",
patch: formatPatch(
structuredPatch(
diff.file,
diff.file,
"before" in diff && typeof diff.before === "string" ? diff.before : "",
"after" in diff && typeof diff.after === "string" ? diff.after : "",
"",
"",
{ context: Number.MAX_SAFE_INTEGER },
),
if (typeof diff.patch === "string") return diff.patch
return formatPatch(
structuredPatch(
diff.file,
diff.file,
"before" in diff && typeof diff.before === "string" ? diff.before : "",
"after" in diff && typeof diff.after === "string" ? diff.after : "",
"",
"",
{ context: Number.MAX_SAFE_INTEGER },
),
}
)
}
function file(file: string, patch: string, before: string, after: string) {
function file(file: string, patch: string) {
const hit = cache.get(patch)
if (hit) return hit
const value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
const key = sampledChecksum(patch) ?? file
const value = parsePatchFiles(patch, key).flatMap((item) => item.files)[0] ?? empty(file, key)
cache.set(patch, value)
return value
}
@@ -92,11 +69,11 @@ export function normalize(diff: ReviewDiff): ViewDiff {
const next = patch(diff)
return {
file: diff.file,
patch: next.patch,
patch: next,
additions: diff.additions,
deletions: diff.deletions,
status: diff.status,
fileDiff: file(diff.file, next.patch, next.before, next.after),
fileDiff: file(diff.file, next),
}
}