Compare commits

...

32 Commits

Author SHA1 Message Date
Kit Langton 21d7a85e76 refactor(lsp): remove async facade exports (#22321) 2026-04-13 12:47:52 -04:00
Kit Langton 663e798e76 refactor(provider): remove async facade exports (#22320) 2026-04-13 12:40:00 -04:00
Aiden Cline 5bc2d2498d test: ensure project and global instructions are loaded (#22317) 2026-04-13 11:34:38 -05:00
Kit Langton c22e34853d refactor(auth): remove async auth facade exports (#22306) 2026-04-13 12:31:43 -04:00
Kit Langton 6825b0bbc7 refactor(pty): remove async facade exports (#22305) 2026-04-13 11:47:05 -04:00
Kit Langton 3644581b55 refactor(file): stream ripgrep search parsing (#22303) 2026-04-13 11:39:37 -04:00
Kit Langton 79cc15335e fix: dispose e2e app runtime (#22316) 2026-04-13 11:36:56 -04:00
Kit Langton ca6200121b refactor: remove vcs async facade exports (#22304) 2026-04-13 11:22:20 -04:00
Kit Langton 7239b38b7f refactor(skill): remove async facade exports (#22308) 2026-04-13 11:18:10 -04:00
Kit Langton 9ae8dc2d01 refactor: remove ToolRegistry runtime facade (#22307) 2026-04-13 11:09:32 -04:00
opencode-agent[bot] 7164662be2 chore: generate 2026-04-13 14:18:05 +00:00
Brendan Allan 94f71f59a3 core: make InstanceBootstrap into an effect (#22274)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-04-13 10:16:40 -04:00
Kit Langton 3eb6508a64 refactor: share TUI terminal background detection (#22297) 2026-04-13 10:05:37 -04:00
Kit Langton 6fdb8ab90d refactor(file): add ripgrep search service (#22295) 2026-04-13 10:04:32 -04:00
Kit Langton 321bf1f8e1 refactor: finish small effect service adoption cleanups (#22094) 2026-04-13 09:17:13 -04:00
Brendan Allan 62bd023086 app: replace parsePatchFiles with parseDiffFromFile (#22270) 2026-04-13 17:19:14 +08:00
Brendan Allan cb1a50055c fix(electron): wait until ready before showing the main window (#22262) 2026-04-13 15:17:09 +08:00
opencode-agent[bot] 65e3348232 chore: update nix node_modules hashes 2026-04-13 06:02:50 +00:00
Brendan Allan a6b9f0dac1 app: align workspace load more button (#22251) 2026-04-13 13:58:35 +08:00
Brendan Allan 34f5bdbc99 app: fix scroll to bottom light mode style (#22250) 2026-04-13 13:55:33 +08:00
Aiden Cline 0b4fe14b0a fix: forgot to put alibaba case in last commit (#22249) 2026-04-13 00:39:12 -05:00
Aiden Cline 7230cd2683 feat: add alibaba pkg and cache support (#22248) 2026-04-13 00:08:07 -05:00
Aiden Cline a915fe74be tweak: adjust session getUsage function to use more up to date LanguageModelUsage instead of LanguageModelV2Usage (#22224) 2026-04-12 21:39:06 -05:00
Brendan Allan 26d35583c5 sdk: throw error if response has text/html content type (#21289) 2026-04-13 09:39:53 +08:00
Goni Zahavy ae17b416b8 fix(cli): auth login now asks for api key in handlePluginAuth (#21641)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-12 20:37:57 -05:00
Aiden Cline 8ffadde85c chore: rm git ignored files (#22200) 2026-04-12 15:52:55 -05:00
Dax Raad 3c0ad70653 ci: enable beta branch releases with auto-update support 2026-04-12 14:40:24 -04:00
Dax 264418c0cd fix(snapshot): complete gitignore respect for previously tracked files (#22172) 2026-04-12 14:05:46 -04:00
shafdev fa2c69f09c fix(opencode): remove spurious scripts and randomField from package.json (#22160) 2026-04-12 13:49:24 -04:00
Dax 113304a058 fix(snapshot): respect gitignore for previously tracked files (#22171) 2026-04-12 13:41:50 -04:00
Dax Raad 8c4d49c2bc ci: enable signed Windows builds on beta branch
Allows beta releases to include properly signed Windows CLI executables, ensuring consistent security verification across all release channels.
2026-04-12 13:16:38 -04:00
Dax Raad 2aa6110c6e ignore: exploration 2026-04-12 13:14:46 -04:00
86 changed files with 4128 additions and 69230 deletions
+3 -5
View File
@@ -114,7 +114,7 @@ jobs:
- build-cli - build-cli
- version - version
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' if: github.repository == 'anomalyco/opencode'
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -213,7 +213,6 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -390,7 +389,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' if: github.repository == 'anomalyco/opencode'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -591,13 +590,12 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: github.ref_name != 'beta'
with: with:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: needs.version.outputs.release && github.ref_name != 'beta' if: needs.version.outputs.release
with: with:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/latest-yml path: /tmp/latest-yml
+5
View File
@@ -319,6 +319,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
@@ -707,6 +708,8 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="], "@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/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=="], "@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=="],
@@ -5003,6 +5006,8 @@
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "@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/@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=="], "@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=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=", "x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=", "aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=", "aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE=" "x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
} }
} }
@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
<div class="relative w-full py-1"> <div class="relative w-full py-1">
<Button <Button
variant="ghost" variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10" class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
size="large" size="large"
onClick={(e: MouseEvent) => { onClick={(e: MouseEvent) => {
props.loadMore() props.loadMore()
@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
onClick={props.onResumeScroll} onClick={props.onResumeScroll}
> >
<div <div
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)]" 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)]"
style={{ style={{
"box-shadow": "box-shadow":
"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)", "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)",
}} }}
> >
<Icon name="arrow-down-to-line" size="small" /> <Icon name="arrow-down-to-line" size="small" />
@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
y: state.y, y: state.y,
width: state.width, width: state.width,
height: state.height, height: state.height,
show: true, show: false,
title: "OpenCode", title: "OpenCode",
icon: iconPath(), icon: iconPath(),
backgroundColor, backgroundColor,
@@ -94,6 +94,10 @@ export function createMainWindow(globals: Globals) {
wireZoom(win) wireZoom(win)
injectGlobals(win, globals) injectGlobals(win, globals)
win.once("ready-to-show", () => {
win.show()
})
return win return win
} }
+2 -2
View File
@@ -13,7 +13,7 @@
Use these rules when writing or migrating Effect code. 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 ## Core
@@ -51,7 +51,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
## Effect.cached for deduplication ## 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 ## Instance.bind — ALS for native callbacks
+1 -7
View File
@@ -14,18 +14,11 @@
"fix-node-pty": "bun run script/fix-node-pty.ts", "fix-node-pty": "bun run script/fix-node-pty.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
"lint": "echo 'Running lint checks...' && bun test --coverage",
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
"db": "bun drizzle-kit" "db": "bun drizzle-kit"
}, },
"bin": { "bin": {
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"randomField": "this-is-a-random-value-12345",
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
@@ -83,6 +76,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.93",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
+11 -2
View File
@@ -1,3 +1,5 @@
import { AppRuntime } from "@/effect/app-runtime"
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd() const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session" const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
@@ -16,14 +18,20 @@ const seed = async () => {
const { Project } = await import("../src/project/project") const { Project } = await import("../src/project/project")
const { ModelID, ProviderID } = await import("../src/provider/schema") const { ModelID, ProviderID } = await import("../src/provider/schema")
const { ToolRegistry } = await import("../src/tool/registry") const { ToolRegistry } = await import("../src/tool/registry")
const { Effect } = await import("effect")
try { try {
await Instance.provide({ await Instance.provide({
directory: dir, directory: dir,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => { fn: async () => {
await Config.waitForDependencies() await Config.waitForDependencies()
await ToolRegistry.ids() await AppRuntime.runPromise(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.ids()
}),
)
const session = await Session.create({ title }) const session = await Session.create({ title })
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
@@ -54,6 +62,7 @@ const seed = async () => {
}) })
} finally { } finally {
await Instance.disposeAll().catch(() => {}) await Instance.disposeAll().catch(() => {})
await AppRuntime.dispose().catch(() => {})
} }
} }
+137
View File
@@ -0,0 +1,137 @@
# 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.
@@ -178,7 +178,9 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist ## Migration checklist
Fully migrated (single namespace, InstanceState where needed, flattened facade): 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).
- [x] `Account``account/index.ts` - [x] `Account``account/index.ts`
- [x] `Agent``agent/agent.ts` - [x] `Agent``agent/agent.ts`
@@ -221,59 +223,16 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Provider``provider/provider.ts` - [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts` - [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts` - [x] `ShareNext``share/share-next.ts`
Still open:
- [x] `SessionTodo``session/todo.ts` - [x] `SessionTodo``session/todo.ts`
- [ ] `SyncEvent``sync/index.ts`
- [ ] `Workspace``control-plane/workspace.ts`
## Tool interface → Effect Still open at the service-shape level:
`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: - [ ] `SyncEvent``sync/index.ts` (deferred pending sync with James)
- [ ] `Workspace``control-plane/workspace.ts` (deferred pending sync with James)
1. Migrate each tool body to return Effects ## Tool migration
2. Keep `Tool.define()` inputs Effect-native
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
### Tool migration details Tool-specific migration guidance and checklist live in `tools.md`.
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 ## Effect service adoption in already-migrated code
@@ -281,27 +240,19 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i
### `Filesystem.*``AppFileSystem.Service` (yield in layer) ### `Filesystem.*``AppFileSystem.Service` (yield in layer)
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling - [x] `config/config.ts``installDependencies()` now uses `AppFileSystem`
- [ ] `config/config.ts` 5 remaining `Filesystem.*` calls in `installDependencies()` - [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
### `Process.spawn``ChildProcessSpawner` (yield in layer) ### `Process.spawn``ChildProcessSpawner` (yield in layer)
- [ ] `format/formatter.ts`2 remaining `Process.spawn()` checks (`air`, `uv`) - [x] `format/formatter.ts`direct `Process.spawn()` checks removed (`air`, `uv`)
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers - [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
## Filesystem consolidation ## Filesystem consolidation
`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. `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.
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched. Tool-specific filesystem cleanup notes live in `tools.md`.
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 ## Primitives & utilities
@@ -312,7 +263,9 @@ Current raw fs users that will convert during tool migration:
## Destroying the facades ## Destroying the facades
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. 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.
### Process ### Process
@@ -341,47 +294,14 @@ 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. - `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. - `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. - `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/routes/session.ts` converted; facade removed. - `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed. - `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers 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. - `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. - `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed. - `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.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 effectification
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. Route-handler migration guidance and checklist live in `routes.md`.
```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
+66
View File
@@ -0,0 +1,66 @@
# 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.
+99
View File
@@ -0,0 +1,99 @@
# 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.
+96
View File
@@ -0,0 +1,96 @@
# 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`
-19
View File
@@ -1,6 +1,5 @@
import path from "path" import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { Global } from "../global" import { Global } from "../global"
import { AppFileSystem } from "../filesystem" import { AppFileSystem } from "../filesystem"
@@ -89,22 +88,4 @@ export namespace Auth {
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) 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))
}
} }
+2 -1
View File
@@ -1,10 +1,11 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "../project/bootstrap" import { InstanceBootstrap } from "../project/bootstrap"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) { export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
return Instance.provide({ return Instance.provide({
directory, directory,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => { fn: async () => {
try { try {
const result = await cb() const result = await cb()
+17 -3
View File
@@ -12,6 +12,7 @@ import { Permission } from "../../../permission"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
import { AppRuntime } from "@/effect/app-runtime"
export const AgentCommand = cmd({ export const AgentCommand = cmd({
command: "agent <name>", command: "agent <name>",
@@ -71,11 +72,17 @@ export const AgentCommand = cmd({
}) })
async function getAvailableTools(agent: Agent.Info) { async function getAvailableTools(agent: Agent.Info) {
const model = agent.model ?? (await Provider.defaultModel()) return AppRuntime.runPromise(
return ToolRegistry.tools({ 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, ...model,
agent, agent,
}) })
}),
)
} }
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) { async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
@@ -118,7 +125,14 @@ function parseToolParams(input?: string) {
async function createToolContext(agent: Agent.Info) { async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` }) const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
const model = agent.model ?? (await Provider.defaultModel()) const model =
agent.model ??
(await AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
}),
))
const now = Date.now() const now = Date.now()
const message: MessageV2.Assistant = { const message: MessageV2.Assistant = {
id: messageID, id: messageID,
+14 -5
View File
@@ -1,4 +1,6 @@
import { LSP } from "../../../lsp" import { LSP } from "../../../lsp"
import { AppRuntime } from "../../../effect/app-runtime"
import { Effect } from "effect"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
import { Log } from "../../../util/log" import { Log } from "../../../util/log"
@@ -19,9 +21,16 @@ const DiagnosticsCommand = cmd({
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true) const out = await AppRuntime.runPromise(
await sleep(1000) LSP.Service.use((lsp) =>
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL) 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)
}) })
}, },
}) })
@@ -33,7 +42,7 @@ export const SymbolsCommand = cmd({
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("symbols") using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query) const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
process.stdout.write(JSON.stringify(results, null, 2) + EOL) process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}) })
}, },
@@ -46,7 +55,7 @@ export const DocumentSymbolsCommand = cmd({
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("document-symbols") using _ = Log.Default.time("document-symbols")
const results = await LSP.documentSymbol(args.uri) const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
process.stdout.write(JSON.stringify(results, null, 2) + EOL) process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}) })
}, },
+10 -3
View File
@@ -1,4 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep" import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
@@ -76,12 +77,18 @@ const SearchCommand = cmd({
description: "Limit number of results", description: "Limit number of results",
}), }),
async handler(args) { async handler(args) {
const results = await Ripgrep.search({ await bootstrap(process.cwd(), async () => {
cwd: process.cwd(), const results = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) =>
svc.search({
cwd: Instance.directory,
pattern: args.pattern, pattern: args.pattern,
glob: args.glob as string[] | undefined, glob: args.glob as string[] | undefined,
limit: args.limit, limit: args.limit,
}),
),
)
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
}) })
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}, },
}) })
+8 -1
View File
@@ -1,4 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Skill } from "../../../skill" import { Skill } from "../../../skill"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -9,7 +11,12 @@ export const SkillCommand = cmd({
builder: (yargs) => yargs, builder: (yargs) => yargs,
async handler() { async handler() {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const skills = await Skill.all() const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
process.stdout.write(JSON.stringify(skills, null, 2) + EOL) process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
}) })
}, },
+20 -10
View File
@@ -6,6 +6,8 @@ import { ModelsDev } from "../../provider/models"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { UI } from "../ui" import { UI } from "../ui"
import { EOL } from "os" import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const ModelsCommand = cmd({ export const ModelsCommand = cmd({
command: "models [provider]", command: "models [provider]",
@@ -35,12 +37,15 @@ export const ModelsCommand = cmd({
await Instance.provide({ await Instance.provide({
directory: process.cwd(), directory: process.cwd(),
async fn() { async fn() {
const providers = await Provider.list() await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
const providers = yield* svc.list()
function printModels(providerID: ProviderID, verbose?: boolean) { const print = (providerID: ProviderID, verbose?: boolean) => {
const provider = providers[providerID] const provider = providers[providerID]
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sortedModels) { for (const [modelID, model] of sorted) {
process.stdout.write(`${providerID}/${modelID}`) process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL) process.stdout.write(EOL)
if (verbose) { if (verbose) {
@@ -51,17 +56,18 @@ export const ModelsCommand = cmd({
} }
if (args.provider) { if (args.provider) {
const provider = providers[ProviderID.make(args.provider)] const providerID = ProviderID.make(args.provider)
const provider = providers[providerID]
if (!provider) { if (!provider) {
UI.error(`Provider not found: ${args.provider}`) yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
return return
} }
printModels(ProviderID.make(args.provider), args.verbose) yield* Effect.sync(() => print(providerID, args.verbose))
return return
} }
const providerIDs = Object.keys(providers).sort((a, b) => { const ids = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("opencode") const aIsOpencode = a.startsWith("opencode")
const bIsOpencode = b.startsWith("opencode") const bIsOpencode = b.startsWith("opencode")
if (aIsOpencode && !bIsOpencode) return -1 if (aIsOpencode && !bIsOpencode) return -1
@@ -69,9 +75,13 @@ export const ModelsCommand = cmd({
return a.localeCompare(b) return a.localeCompare(b)
}) })
for (const providerID of providerIDs) { yield* Effect.sync(() => {
printModels(ProviderID.make(providerID), args.verbose) for (const providerID of ids) {
print(ProviderID.make(providerID), args.verbose)
} }
})
}),
)
}, },
}) })
}, },
+45 -13
View File
@@ -1,4 +1,5 @@
import { Auth } from "../../auth" import { Auth } from "../../auth"
import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { UI } from "../ui" import { UI } from "../ui"
@@ -13,9 +14,18 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin" import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process" import { Process } from "../../util/process"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { Effect } from "effect"
type PluginAuth = NonNullable<Hooks["auth"]> 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> { async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0 let index = 0
if (methodName) { if (methodName) {
@@ -93,7 +103,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const saveProvider = result.provider ?? provider const saveProvider = result.provider ?? provider
if ("refresh" in result) { if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, { await put(saveProvider, {
type: "oauth", type: "oauth",
refresh, refresh,
access, access,
@@ -102,7 +112,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}) })
} }
if ("key" in result) { if ("key" in result) {
await Auth.set(saveProvider, { await put(saveProvider, {
type: "api", type: "api",
key: result.key, key: result.key,
}) })
@@ -125,7 +135,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const saveProvider = result.provider ?? provider const saveProvider = result.provider ?? provider
if ("refresh" in result) { if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, { await put(saveProvider, {
type: "oauth", type: "oauth",
refresh, refresh,
access, access,
@@ -134,7 +144,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}) })
} }
if ("key" in result) { if ("key" in result) {
await Auth.set(saveProvider, { await put(saveProvider, {
type: "api", type: "api",
key: result.key, key: result.key,
}) })
@@ -148,6 +158,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
} }
if (method.type === "api") { 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) { if (method.authorize) {
const result = await method.authorize(inputs) const result = await method.authorize(inputs)
if (result.type === "failed") { if (result.type === "failed") {
@@ -155,9 +171,9 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
} }
if (result.type === "success") { if (result.type === "success") {
const saveProvider = result.provider ?? provider const saveProvider = result.provider ?? provider
await Auth.set(saveProvider, { await put(saveProvider, {
type: "api", type: "api",
key: result.key, key: result.key ?? key,
}) })
prompts.log.success("Login successful") prompts.log.success("Login successful")
} }
@@ -215,7 +231,12 @@ export const ProvidersListCommand = cmd({
const homedir = os.homedir() const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Auth.all()) const results = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const database = await ModelsDev.get() const database = await ModelsDev.get()
for (const [providerID, result] of results) { for (const [providerID, result] of results) {
@@ -294,7 +315,7 @@ export const ProvidersLoginCommand = cmd({
prompts.outro("Done") prompts.outro("Done")
return return
} }
await Auth.set(url, { await put(url, {
type: "wellknown", type: "wellknown",
key: wellknown.auth.env, key: wellknown.auth.env,
token: token.trim(), token: token.trim(),
@@ -441,7 +462,7 @@ export const ProvidersLoginCommand = cmd({
validate: (x) => (x && x.length > 0 ? undefined : "Required"), validate: (x) => (x && x.length > 0 ? undefined : "Required"),
}) })
if (prompts.isCancel(key)) throw new UI.CancelledError() if (prompts.isCancel(key)) throw new UI.CancelledError()
await Auth.set(provider, { await put(provider, {
type: "api", type: "api",
key, key,
}) })
@@ -457,22 +478,33 @@ export const ProvidersLogoutCommand = cmd({
describe: "log out from a configured provider", describe: "log out from a configured provider",
async handler(_args) { async handler(_args) {
UI.empty() UI.empty()
const credentials = await Auth.all().then((x) => Object.entries(x)) const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
prompts.intro("Remove credential") prompts.intro("Remove credential")
if (credentials.length === 0) { if (credentials.length === 0) {
prompts.log.error("No credentials found") prompts.log.error("No credentials found")
return return
} }
const database = await ModelsDev.get() const database = await ModelsDev.get()
const providerID = await prompts.select({ const selected = await prompts.select({
message: "Select provider", message: "Select provider",
options: credentials.map(([key, value]) => ({ options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key, value: key,
})), })),
}) })
if (prompts.isCancel(providerID)) throw new UI.CancelledError() if (prompts.isCancel(selected)) throw new UI.CancelledError()
await Auth.remove(providerID) const providerID = selected as string
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
prompts.outro("Logout successful") prompts.outro("Logout successful")
}, },
}) })
+2 -61
View File
@@ -1,6 +1,7 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard" import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection" import { Selection } from "@tui/util/selection"
import { Terminal } from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route" import { RouteProvider, useRoute } from "@tui/context/route"
import { import {
@@ -60,66 +61,6 @@ import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error" 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 type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
@@ -178,7 +119,7 @@ export function tui(input: {
const unguard = win32InstallCtrlCGuard() const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput() win32DisableProcessedInput()
const mode = await getTerminalBackgroundColor() const mode = await Terminal.getTerminalBackgroundColor()
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
// the original console mode which re-enables ENABLE_PROCESSED_INPUT. // the original console mode which re-enables ENABLE_PROCESSED_INPUT.
@@ -2,6 +2,28 @@ import { RGBA } from "@opentui/core"
export namespace Terminal { export namespace Terminal {
export type Colors = Awaited<ReturnType<typeof colors>> 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). * Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values. * Uses OSC escape sequences to retrieve actual terminal color values.
@@ -31,46 +53,26 @@ export namespace Terminal {
clearTimeout(timeout) 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 handler = (data: Buffer) => {
const str = data.toString() const str = data.toString()
// Match OSC 11 (background color) // Match OSC 11 (background color)
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/) const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (bgMatch) { if (bgMatch) {
background = parseColor(bgMatch[1]) background = parse(bgMatch[1])
} }
// Match OSC 10 (foreground color) // Match OSC 10 (foreground color)
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/) const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
if (fgMatch) { if (fgMatch) {
foreground = parseColor(fgMatch[1]) foreground = parse(fgMatch[1])
} }
// Match OSC 4 (palette colors) // Match OSC 4 (palette colors)
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g) const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
for (const match of paletteMatches) { for (const match of paletteMatches) {
const index = parseInt(match[1]) const index = parseInt(match[1])
const color = parseColor(match[2]) const color = parse(match[2])
if (color) paletteColors[index] = color if (color) paletteColors[index] = color
} }
@@ -100,15 +102,36 @@ 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"> { export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
const result = await colors() if (!process.stdin.isTTY) return "dark"
if (!result.background) return "dark"
const { r, g, b } = result.background return new Promise((resolve) => {
// Calculate luminance using relative luminance formula let timeout: NodeJS.Timeout
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Determine if dark or light based on luminance threshold const cleanup = () => {
return luminance > 0.5 ? "light" : "dark" 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)
})
} }
} }
+2 -2
View File
@@ -7,10 +7,10 @@ import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),
@@ -74,7 +74,7 @@ export const rpc = {
async checkUpgrade(input: { directory: string }) { async checkUpgrade(input: { directory: string }) {
await Instance.provide({ await Instance.provide({
directory: input.directory, directory: input.directory,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => { fn: async () => {
await upgrade().catch(() => {}) await upgrade().catch(() => {})
}, },
+94 -56
View File
@@ -22,21 +22,19 @@ import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server" import { LSPServer } from "../lsp/server"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown" import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs" import { existsSync } from "fs"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { iife } from "@/util/iife"
import { Account } from "@/account" import { Account } from "@/account"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import type { ConsoleState } from "./console-state" import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, Context } from "effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm" import { Npm } from "@/npm"
@@ -140,53 +138,11 @@ export namespace Config {
} }
export type InstallInput = { export type InstallInput = {
signal?: AbortSignal
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void> waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
} }
export async function installDependencies(dir: string, input?: InstallInput) { type Package = {
if (!(await isWritable(dir))) return dependencies?: Record<string, string>
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[]) { function rel(item: string, patterns: string[]) {
@@ -1111,7 +1067,7 @@ export namespace Config {
type State = { type State = {
config: Info config: Info
directories: string[] directories: string[]
deps: Promise<void>[] deps: Fiber.Fiber<void, never>[]
consoleState: ConsoleState consoleState: ConsoleState
} }
@@ -1119,6 +1075,7 @@ export namespace Config {
readonly get: () => Effect.Effect<Info> readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info> readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState> readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, AppFileSystem.Error>
readonly update: (config: Info) => Effect.Effect<void> readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info> readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void> readonly invalidate: (wait?: boolean) => Effect.Effect<void>
@@ -1320,6 +1277,74 @@ export namespace Config {
return yield* cachedGlobal 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 loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie) const auth = yield* authSvc.all().pipe(Effect.orDie)
@@ -1402,7 +1427,7 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
} }
const deps: Promise<void>[] = [] const deps: Fiber.Fiber<void, never>[] = []
for (const dir of unique(directories)) { for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
@@ -1416,12 +1441,18 @@ export namespace Config {
} }
} }
const dep = iife(async () => { const dep = yield* installDependencies(dir).pipe(
await installDependencies(dir) Effect.exit,
}) Effect.tap((exit) =>
void dep.catch((err) => { Exit.isFailure(exit)
log.warn("background dependency install failed", { dir, error: err }) ? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
}) })
: Effect.void,
),
Effect.asVoid,
Effect.forkScoped,
)
deps.push(dep) deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
@@ -1558,7 +1589,9 @@ export namespace Config {
}) })
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined))) yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
}) })
const update = Effect.fn("Config.update")(function* (config: Info) { const update = Effect.fn("Config.update")(function* (config: Info) {
@@ -1613,6 +1646,7 @@ export namespace Config {
get, get,
getGlobal, getGlobal,
getConsoleState, getConsoleState,
installDependencies,
update, update,
updateGlobal, updateGlobal,
invalidate, invalidate,
@@ -1642,6 +1676,10 @@ export namespace Config {
return runPromise((svc) => svc.getConsoleState()) 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) { export async function update(config: Info) {
return runPromise((svc) => svc.update(config)) return runPromise((svc) => svc.update(config))
} }
+2 -2
View File
@@ -49,7 +49,7 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll( export const AppLayer = Layer.mergeAll(
Observability.layer, // Observability.layer,
AppFileSystem.defaultLayer, AppFileSystem.defaultLayer,
Bus.defaultLayer, Bus.defaultLayer,
Auth.defaultLayer, Auth.defaultLayer,
@@ -95,6 +95,6 @@ export const AppLayer = Layer.mergeAll(
Installation.defaultLayer, Installation.defaultLayer,
ShareNext.defaultLayer, ShareNext.defaultLayer,
SessionShare.defaultLayer, SessionShare.defaultLayer,
) ).pipe(Layer.provide(Observability.layer))
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap }) export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
@@ -1,10 +1,27 @@
import { Layer, ManagedRuntime } from "effect" import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service" import { memoMap } from "./run-service"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format" import { Format } from "@/format"
import { ShareNext } from "@/share/share-next" 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(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer) 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 BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
+134 -54
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global" import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
import z from "zod" import z from "zod"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context, Schema } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
@@ -94,8 +94,43 @@ export namespace Ripgrep {
const Result = z.union([Begin, Match, End, Summary]) 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 Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match> export type Match = z.infer<typeof Match>
export type Item = Match["data"]
export type Begin = z.infer<typeof Begin> export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End> export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary> export type Summary = z.infer<typeof Summary>
@@ -289,6 +324,13 @@ export namespace Ripgrep {
follow?: boolean follow?: boolean
maxDepth?: number maxDepth?: number
}) => Stream.Stream<string, PlatformError> }) => 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") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
@@ -298,6 +340,32 @@ export namespace Ripgrep {
Effect.gen(function* () { Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service 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: { const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string cwd: string
@@ -306,7 +374,7 @@ export namespace Ripgrep {
follow?: boolean follow?: boolean
maxDepth?: number maxDepth?: number
}) { }) {
const rgPath = yield* Effect.promise(() => filepath()) const rgPath = yield* bin()
const isDir = yield* afs.isDir(input.cwd) const isDir = yield* afs.isDir(input.cwd)
if (!isDir) { if (!isDir) {
return yield* Effect.die( return yield* Effect.die(
@@ -318,23 +386,77 @@ export namespace Ripgrep {
) )
} }
const args = [rgPath, "--files", "--glob=!.git/*"] const cmd = yield* args({
if (input.follow) args.push("--follow") mode: "files",
if (input.hidden !== false) args.push("--hidden") glob: input.glob,
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) hidden: input.hidden,
if (input.glob) { follow: input.follow,
for (const g of input.glob) { maxDepth: input.maxDepth,
args.push(`--glob=${g}`) })
}
}
return spawner return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd })) .streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0)) .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({ return Service.of({
files: (input) => Stream.unwrap(files(input)), files: (input) => Stream.unwrap(files(input)),
search,
}) })
}), }),
) )
@@ -401,46 +523,4 @@ export namespace Ripgrep {
return lines.join("\n") 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)
}
} }
+9 -21
View File
@@ -1,4 +1,3 @@
import { text } from "node:stream/consumers"
import { Npm } from "@/npm" import { Npm } from "@/npm"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -217,26 +216,16 @@ export const rlang: Info = {
name: "air", name: "air",
extensions: [".R"], extensions: [".R"],
async enabled() { async enabled() {
const airPath = which("air") const air = which("air")
if (airPath == null) return false if (air == null) return false
try { const output = await Process.text([air, "--help"], { nothrow: true })
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" // Check for "Air: An R language server and formatter"
const firstLine = output.split("\n")[0] const firstLine = output.text.split("\n")[0]
const hasR = firstLine.includes("R language") const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter") const hasFormatter = firstLine.includes("formatter")
if (hasR && hasFormatter) return ["air", "format", "$FILE"] if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"]
} catch {
return false
}
return false return false
}, },
} }
@@ -246,11 +235,10 @@ export const uvformat: Info = {
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
async enabled() { async enabled() {
if (await ruff.enabled()) return false if (await ruff.enabled()) return false
if (which("uv") !== null) { const uv = which("uv")
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) if (uv == null) return false
const code = await proc.exited const output = await Process.run([uv, "format", "--help"], { nothrow: true })
if (code === 0) return ["uv", "format", "--", "$FILE"] if (output.code === 0) return [uv, "format", "--", "$FILE"]
}
return false return false
}, },
} }
-32
View File
@@ -13,7 +13,6 @@ import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch" import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace LSP { export namespace LSP {
const log = Log.create({ service: "lsp" }) const log = Log.create({ service: "lsp" })
@@ -508,37 +507,6 @@ export namespace LSP {
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) 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 { export namespace Diagnostic {
const MAX_PER_FILE = 20 const MAX_PER_FILE = 20
+15 -13
View File
@@ -9,24 +9,26 @@ import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import * as Effect from "effect/Effect"
export async function InstanceBootstrap() { export const InstanceBootstrap = Effect.gen(function* () {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() yield* Plugin.Service.use((svc) => svc.init())
void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init())) yield* ShareNext.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init())) yield* Format.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
await LSP.init() yield* LSP.Service.use((svc) => svc.init())
File.init() yield* File.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init())) yield* FileWatcher.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
Vcs.init() yield* Vcs.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
Snapshot.init() yield* Snapshot.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
Bus.subscribe(Command.Event.Executed, async (payload) => { yield* Bus.Service.use((svc) =>
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) { if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(Instance.project.id) Project.setInitialized(Instance.project.id)
} }
}) }),
} )
}).pipe(Effect.withSpan("InstanceBootstrap"))
-19
View File
@@ -4,7 +4,6 @@ import path from "path"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git" import { Git } from "@/git"
@@ -231,22 +230,4 @@ export namespace Vcs {
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer), 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))
}
} }
-2
View File
@@ -1,2 +0,0 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>
File diff suppressed because it is too large Load Diff
+40 -47
View File
@@ -18,11 +18,11 @@ import { Flag } from "../flag/flag"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Global } from "../global" import { Global } from "../global"
import path from "path" import path from "path"
import { Filesystem } from "../util/filesystem"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger" import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem"
import { isRecord } from "@/util/record"
// Direct imports for bundled providers // Direct imports for bundled providers
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@@ -46,6 +46,7 @@ import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity" import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel" import { createVercel } from "@ai-sdk/vercel"
import { createVenice } from "venice-ai-sdk-provider" import { createVenice } from "venice-ai-sdk-provider"
import { createAlibaba } from "@ai-sdk/alibaba"
import { import {
createGitLab, createGitLab,
VERSION as GITLAB_PROVIDER_VERSION, VERSION as GITLAB_PROVIDER_VERSION,
@@ -145,6 +146,7 @@ export namespace Provider {
"@ai-sdk/togetherai": createTogetherAI, "@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity, "@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel, "@ai-sdk/vercel": createVercel,
"@ai-sdk/alibaba": createAlibaba,
"gitlab-ai-provider": createGitLab, "gitlab-ai-provider": createGitLab,
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
"venice-ai-sdk-provider": createVenice, "venice-ai-sdk-provider": createVenice,
@@ -1028,9 +1030,11 @@ export namespace Provider {
} }
} }
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service> = Layer.effect( const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service> =
Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const config = yield* Config.Service const config = yield* Config.Service
const auth = yield* Auth.Service const auth = yield* Auth.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
@@ -1138,13 +1142,20 @@ export namespace Provider {
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
}, },
output: { output: {
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, text:
model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
audio: audio:
model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false, model.modalities?.output?.includes("audio") ??
existingModel?.capabilities.output.audio ??
false,
image: image:
model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false, model.modalities?.output?.includes("image") ??
existingModel?.capabilities.output.image ??
false,
video: video:
model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false, model.modalities?.output?.includes("video") ??
existingModel?.capabilities.output.video ??
false,
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
}, },
interleaved: model.interleaved ?? false, interleaved: model.interleaved ?? false,
@@ -1217,12 +1228,16 @@ export namespace Provider {
const options = yield* Effect.promise(() => const options = yield* Effect.promise(() =>
plugin.auth!.loader!( plugin.auth!.loader!(
() => () =>
Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any, Effect.runPromise(
auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer)),
) as any,
database[plugin.auth!.provider], database[plugin.auth!.provider],
), ),
) )
const opts = options ?? {} const opts = options ?? {}
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts } const patch: Partial<Info> = providers[providerID]
? { options: opts }
: { source: "custom", options: opts }
mergeProvider(providerID, patch) mergeProvider(providerID, patch)
} }
@@ -1316,7 +1331,8 @@ export namespace Provider {
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
) )
delete provider.models[modelID] delete provider.models[modelID]
if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID] if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS)
delete provider.models[modelID]
if (model.status === "deprecated") delete provider.models[modelID] if (model.status === "deprecated") delete provider.models[modelID]
if ( if (
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
@@ -1427,7 +1443,8 @@ export namespace Provider {
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false) if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
signals.push(AbortSignal.timeout(options["timeout"])) signals.push(AbortSignal.timeout(options["timeout"]))
const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) const combined =
signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
if (combined) opts.signal = combined if (combined) opts.signal = combined
// Strip openai itemId metadata following what codex does // Strip openai itemId metadata following what codex does
@@ -1631,12 +1648,17 @@ export namespace Provider {
if (cfg.model) return parseModel(cfg.model) if (cfg.model) return parseModel(cfg.model)
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const recent = yield* Effect.promise(() => const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
Filesystem.readJson<{ Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
recent?: { providerID: ProviderID; modelID: ModelID }[] if (!isRecord(x) || !Array.isArray(x.recent)) return []
}>(path.join(Global.Path.state, "model.json")) return x.recent.flatMap((item) => {
.then((x): { providerID: ProviderID; modelID: ModelID }[] => (Array.isArray(x.recent) ? x.recent : [])) if (!isRecord(item)) return []
.catch((): { providerID: ProviderID; modelID: ModelID }[] => []), if (typeof item.providerID !== "string") return []
if (typeof item.modelID !== "string") return []
return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
})
}),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
) )
for (const entry of recent) { for (const entry of recent) {
const provider = s.providers[entry.providerID] const provider = s.providers[entry.providerID]
@@ -1663,42 +1685,13 @@ export namespace Provider {
export const defaultLayer = Layer.suspend(() => export const defaultLayer = Layer.suspend(() =>
layer.pipe( layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
), ),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
export async function getProvider(providerID: ProviderID) {
return runPromise((svc) => svc.getProvider(providerID))
}
export async function getModel(providerID: ProviderID, modelID: ModelID) {
return runPromise((svc) => svc.getModel(providerID, modelID))
}
export async function getLanguage(model: Model) {
return runPromise((svc) => svc.getLanguage(model))
}
export async function closest(providerID: ProviderID, query: string[]) {
return runPromise((svc) => svc.closest(providerID, query))
}
export async function getSmallModel(providerID: ProviderID) {
return runPromise((svc) => svc.getSmallModel(providerID))
}
export async function defaultModel() {
return runPromise((svc) => svc.defaultModel())
}
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
export function sort<T extends { id: string }>(models: T[]) { export function sort<T extends { id: string }>(models: T[]) {
return sortBy( return sortBy(
+5 -1
View File
@@ -209,6 +209,9 @@ export namespace ProviderTransform {
copilot: { copilot: {
copilot_cache_control: { type: "ephemeral" }, copilot_cache_control: { type: "ephemeral" },
}, },
alibaba: {
cacheControl: { type: "ephemeral" },
},
} }
for (const msg of unique([...system, ...final])) { for (const msg of unique([...system, ...final])) {
@@ -285,7 +288,8 @@ export namespace ProviderTransform {
model.api.id.includes("claude") || model.api.id.includes("claude") ||
model.id.includes("anthropic") || model.id.includes("anthropic") ||
model.id.includes("claude") || model.id.includes("claude") ||
model.api.npm === "@ai-sdk/anthropic") && model.api.npm === "@ai-sdk/anthropic" ||
model.api.npm === "@ai-sdk/alibaba") &&
model.api.npm !== "@ai-sdk/gateway" model.api.npm !== "@ai-sdk/gateway"
) { ) {
msgs = applyCaching(msgs, model) msgs = applyCaching(msgs, model)
-31
View File
@@ -1,7 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import type { Proc } from "#pty" import type { Proc } from "#pty"
import z from "zod" import z from "zod"
@@ -361,34 +360,4 @@ export namespace Pty {
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer)) 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))
}
} }
+14 -2
View File
@@ -1,5 +1,7 @@
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { AppRuntime } from "@/effect/app-runtime"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Effect } from "effect"
import { ProviderID } from "@/provider/schema" import { ProviderID } from "@/provider/schema"
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
@@ -39,7 +41,12 @@ export function ControlPlaneRoutes(): Hono {
async (c) => { async (c) => {
const providerID = c.req.valid("param").providerID const providerID = c.req.valid("param").providerID
const info = c.req.valid("json") const info = c.req.valid("json")
await Auth.set(providerID, info) await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set(providerID, info)
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -69,7 +76,12 @@ export function ControlPlaneRoutes(): Hono {
), ),
async (c) => { async (c) => {
const providerID = c.req.valid("param").providerID const providerID = c.req.valid("param").providerID
await Auth.remove(providerID) await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -7,6 +7,8 @@ import { mapValues } from "remeda"
import { errors } from "../error" import { errors } from "../error"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -82,7 +84,12 @@ export const ConfigRoutes = lazy(() =>
}), }),
async (c) => { async (c) => {
using _ = log.time("providers") using _ = log.time("providers")
const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) const providers = await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
return mapValues(yield* svc.list(), (item) => item)
}),
)
return c.json({ return c.json({
providers: Object.values(providers), providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
@@ -162,7 +162,13 @@ export const ExperimentalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await ToolRegistry.ids()) const ids = await AppRuntime.runPromise(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
return yield* registry.ids()
}),
)
return c.json(ids)
}, },
) )
.get( .get(
@@ -205,11 +211,17 @@ export const ExperimentalRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const { provider, model } = c.req.valid("query") const { provider, model } = c.req.valid("query")
const tools = await ToolRegistry.tools({ 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), providerID: ProviderID.make(provider),
modelID: ModelID.make(model), modelID: ModelID.make(model),
agent: await Agent.get(await Agent.defaultAgent()), agent: yield* agents.get(yield* agents.defaultAgent()),
}) })
}),
)
return c.json( return c.json(
tools.map((t) => ({ tools.map((t) => ({
id: t.id, id: t.id,
+5 -11
View File
@@ -1,6 +1,7 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { AppRuntime } from "../../effect/app-runtime"
import { File } from "../../file" import { File } from "../../file"
import { Ripgrep } from "../../file/ripgrep" import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp" import { LSP } from "../../lsp"
@@ -34,12 +35,10 @@ export const FileRoutes = lazy(() =>
), ),
async (c) => { async (c) => {
const pattern = c.req.valid("query").pattern const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({ const result = await AppRuntime.runPromise(
cwd: Instance.directory, Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
pattern, )
limit: 10, return c.json(result.items)
})
return c.json(result)
}, },
) )
.get( .get(
@@ -106,11 +105,6 @@ export const FileRoutes = lazy(() =>
}), }),
), ),
async (c) => { async (c) => {
/*
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
*/
return c.json([]) return c.json([])
}, },
) )
+27 -7
View File
@@ -1,6 +1,7 @@
import { describeRoute, resolver, validator } from "hono-openapi" import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono" import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { Format } from "../../format" import { Format } from "../../format"
import { TuiRoutes } from "./tui" import { TuiRoutes } from "./tui"
@@ -119,11 +120,17 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()]) return c.json(
return c.json({ await AppRuntime.runPromise(
branch, Effect.gen(function* () {
default_branch, const vcs = yield* Vcs.Service
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
concurrency: 2,
}) })
return { branch, default_branch }
}),
),
)
}, },
) )
.get( .get(
@@ -150,7 +157,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}), }),
), ),
async (c) => { async (c) => {
return c.json(await Vcs.diff(c.req.valid("query").mode)) return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff(c.req.valid("query").mode)
}),
),
)
}, },
) )
.get( .get(
@@ -215,7 +229,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
const skills = await Skill.all() const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
return c.json(skills) return c.json(skills)
}, },
) )
@@ -237,7 +256,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await LSP.status()) const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
return c.json(items)
}, },
) )
.get( .get(
@@ -10,6 +10,7 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Session } from "@/session" import { Session } from "@/session"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { AppRuntime } from "@/effect/app-runtime"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -66,7 +67,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
if (!workspaceID) { if (!workspaceID) {
return Instance.provide({ return Instance.provide({
directory, directory,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() { async fn() {
return next() return next()
}, },
@@ -103,7 +104,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
fn: () => fn: () =>
Instance.provide({ Instance.provide({
directory: target.directory, directory: target.directory,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() { async fn() {
return next() return next()
}, },
@@ -8,6 +8,7 @@ import { ProjectID } from "../../project/schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { InstanceBootstrap } from "../../project/bootstrap" import { InstanceBootstrap } from "../../project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
export const ProjectRoutes = lazy(() => export const ProjectRoutes = lazy(() =>
new Hono() new Hono()
@@ -83,7 +84,7 @@ export const ProjectRoutes = lazy(() =>
directory: dir, directory: dir,
worktree: dir, worktree: dir,
project: next, project: next,
init: InstanceBootstrap, init: () => AppRuntime.runPromise(InstanceBootstrap),
}) })
return c.json(next) return c.json(next)
}, },
@@ -11,6 +11,7 @@ import { mapValues } from "remeda"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { Effect } from "effect"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -40,27 +41,35 @@ export const ProviderRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const config = await Config.get() 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 disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
const allProviders = await ModelsDev.get() for (const [key, value] of Object.entries(all)) {
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filteredProviders[key] = value filtered[key] = value
} }
} }
const connected = yield* svc.list()
const connected = await Provider.list()
const providers = Object.assign( const providers = Object.assign(
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
connected, connected,
) )
return c.json({ return {
all: Object.values(providers), all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: Object.keys(connected), connected: Object.keys(connected),
}
}),
)
return c.json({
all: result.all,
default: result.default,
connected: result.connected,
}) })
}, },
) )
+56 -8
View File
@@ -1,7 +1,9 @@
import { Hono, type MiddlewareHandler } from "hono" import { Hono, type MiddlewareHandler } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import z from "zod" import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty" import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema" import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db" import { NotFoundError } from "../../storage/db"
@@ -27,7 +29,14 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}, },
}), }),
async (c) => { async (c) => {
return c.json(await Pty.list()) return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
),
)
}, },
) )
.post( .post(
@@ -50,7 +59,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("json", Pty.CreateInput), validator("json", Pty.CreateInput),
async (c) => { async (c) => {
const info = await Pty.create(c.req.valid("json")) const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.create(c.req.valid("json"))
}),
)
return c.json(info) return c.json(info)
}, },
) )
@@ -74,7 +88,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => { async (c) => {
const info = await Pty.get(c.req.valid("param").ptyID) const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(c.req.valid("param").ptyID)
}),
)
if (!info) { if (!info) {
throw new NotFoundError({ message: "Session not found" }) throw new NotFoundError({ message: "Session not found" })
} }
@@ -102,7 +121,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput), validator("json", Pty.UpdateInput),
async (c) => { async (c) => {
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json")) 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"))
}),
)
return c.json(info) return c.json(info)
}, },
) )
@@ -126,7 +150,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => { async (c) => {
await Pty.remove(c.req.valid("param").ptyID) await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* pty.remove(c.req.valid("param").ptyID)
}),
)
return c.json(true) return c.json(true)
}, },
) )
@@ -150,6 +179,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}), }),
validator("param", z.object({ ptyID: PtyID.zod })), validator("param", z.object({ ptyID: PtyID.zod })),
upgradeWebSocket(async (c) => { upgradeWebSocket(async (c) => {
type Handler = {
onMessage: (message: string | ArrayBuffer) => void
onClose: () => void
}
const id = PtyID.zod.parse(c.req.param("ptyID")) const id = PtyID.zod.parse(c.req.param("ptyID"))
const cursor = (() => { const cursor = (() => {
const value = c.req.query("cursor") const value = c.req.query("cursor")
@@ -158,8 +192,17 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
if (!Number.isSafeInteger(parsed) || parsed < -1) return if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed return parsed
})() })()
let handler: Awaited<ReturnType<typeof Pty.connect>> let handler: Handler | undefined
if (!(await Pty.get(id))) throw new Error("Session not found") if (
!(await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(id)
}),
))
) {
throw new Error("Session not found")
}
type Socket = { type Socket = {
readyState: number readyState: number
@@ -185,7 +228,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
ws.close() ws.close()
return return
} }
handler = await Pty.connect(id, socket, cursor) handler = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, socket, cursor)
}),
)
ready = true ready = true
for (const msg of pending) handler?.onMessage(msg) for (const msg of pending) handler?.onMessage(msg)
pending.length = 0 pending.length = 0
+9 -7
View File
@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Decimal } from "decimal.js" import { Decimal } from "decimal.js"
import z from "zod" import z from "zod"
import { type ProviderMetadata } from "ai" import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Installation } from "../installation" import { Installation } from "../installation"
@@ -28,7 +28,6 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Global } from "@/global" import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { Effect, Layer, Option, Context } from "effect" import { Effect, Layer, Option, Context } from "effect"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -240,7 +239,7 @@ export namespace Session {
export const getUsage = (input: { export const getUsage = (input: {
model: Provider.Model model: Provider.Model
usage: LanguageModelV2Usage usage: LanguageModelUsage
metadata?: ProviderMetadata metadata?: ProviderMetadata
}) => { }) => {
const safe = (value: number) => { const safe = (value: number) => {
@@ -249,11 +248,14 @@ export namespace Session {
} }
const inputTokens = safe(input.usage.inputTokens ?? 0) const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0) const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0) const cacheReadInputTokens = safe(
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
)
const cacheWriteInputTokens = safe( const cacheWriteInputTokens = safe(
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? (input.usage.inputTokenDetails?.cacheWriteTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// google-vertex-anthropic returns metadata under "vertex" key // google-vertex-anthropic returns metadata under "vertex" key
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
@@ -274,7 +276,7 @@ export namespace Session {
const tokens = { const tokens = {
total, total,
input: adjustedInputTokens, input: adjustedInputTokens,
output: outputTokens - reasoningTokens, output: safe(outputTokens - reasoningTokens),
reasoning: reasoningTokens, reasoning: reasoningTokens,
cache: { cache: {
write: cacheWriteInputTokens, write: cacheWriteInputTokens,
+18 -8
View File
@@ -94,14 +94,24 @@ export namespace LLM {
modelID: input.model.id, modelID: input.model.id,
providerID: input.model.providerID, providerID: input.model.providerID,
}) })
const [language, cfg, provider, auth] = await Promise.all([ const [language, cfg, provider, info] = await Effect.runPromise(
Provider.getLanguage(input.model), Effect.gen(function* () {
Config.get(), const auth = yield* Auth.Service
Provider.getProvider(input.model.providerID), const cfg = yield* Config.Service
Auth.get(input.model.providerID), 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))),
)
// TODO: move this to a proper hook // TODO: move this to a proper hook
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth" const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth"
const system: string[] = [] const system: string[] = []
system.push( system.push(
@@ -200,7 +210,7 @@ export namespace LLM {
}, },
) )
const tools = await resolveTools(input) const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present // 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. // when message history contains tool calls, even if no tools are being used.
-19
View File
@@ -7,7 +7,6 @@ import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Global } from "@/global" import { Global } from "@/global"
import { Permission } from "@/permission" import { Permission } from "@/permission"
@@ -262,22 +261,4 @@ export namespace Skill {
.map((skill) => `- **${skill.name}**: ${skill.description}`), .map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n") ].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))
}
} }
+85 -5
View File
@@ -177,8 +177,39 @@ export namespace Snapshot {
const all = Array.from(new Set([...tracked, ...untracked])) const all = Array.from(new Set([...tracked, ...untracked]))
if (!all.length) return if (!all.length) return
// Filter out files that are now gitignored even if previously tracked
// Files may have been tracked before being gitignored, so we need to check
// against the source project's current gitignore rules
// Use --no-index to check purely against patterns (ignoring whether file is tracked)
const checkArgs = [
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--",
...all,
]
const check = yield* git(checkArgs, { cwd: state.directory })
const ignored =
check.code === 0 ? new Set(check.text.trim().split("\n").filter(Boolean)) : new Set<string>()
const filtered = all.filter((item) => !ignored.has(item))
// Remove newly-ignored files from snapshot index to prevent re-adding
if (ignored.size > 0) {
const ignoredFiles = Array.from(ignored)
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
yield* git([...cfg, ...args(["rm", "--cached", "-f", "--", ...ignoredFiles])], {
cwd: state.directory,
})
}
if (!filtered.length) return
const large = (yield* Effect.all( const large = (yield* Effect.all(
all.map((item) => filtered.map((item) =>
fs fs
.stat(path.join(state.directory, item)) .stat(path.join(state.directory, item))
.pipe(Effect.catch(() => Effect.void)) .pipe(Effect.catch(() => Effect.void))
@@ -259,14 +290,39 @@ export namespace Snapshot {
log.warn("failed to get diff", { hash, exitCode: result.code }) log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] } return { hash, files: [] }
} }
return { const files = result.text
hash,
files: result.text
.trim() .trim()
.split("\n") .split("\n")
.map((x) => x.trim()) .map((x) => x.trim())
.filter(Boolean) .filter(Boolean)
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
// Filter out files that are now gitignored
if (files.length > 0) {
const checkArgs = [
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--",
...files,
]
const check = yield* git(checkArgs, { cwd: state.directory })
if (check.code === 0) {
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
const filtered = files.filter((item) => !ignored.has(item))
return {
hash,
files: filtered.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
}
}
}
return {
hash,
files: files.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
} }
}), }),
) )
@@ -616,6 +672,30 @@ export namespace Snapshot {
} satisfies Row, } satisfies Row,
] ]
}) })
// Filter out files that are now gitignored
if (rows.length > 0) {
const files = rows.map((r) => r.file)
const checkArgs = [
...quote,
"--git-dir",
path.join(state.worktree, ".git"),
"--work-tree",
state.worktree,
"check-ignore",
"--no-index",
"--",
...files,
]
const check = yield* git(checkArgs, { cwd: state.directory })
if (check.code === 0) {
const ignored = new Set(check.text.trim().split("\n").filter(Boolean))
const filtered = rows.filter((r) => !ignored.has(r.file))
rows.length = 0
rows.push(...filtered)
}
}
const step = 100 const step = 100
const patch = (file: string, before: string, after: string) => const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
+55 -88
View File
@@ -1,11 +1,8 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect, Option } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { ChildProcess } from "effect/unstable/process" import { AppFileSystem } from "../filesystem"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import DESCRIPTION from "./grep.txt" import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
@@ -17,7 +14,8 @@ const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.define( export const GrepTool = Tool.define(
"grep", "grep",
Effect.gen(function* () { Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner const fs = yield* AppFileSystem.Service
const rg = yield* Ripgrep.Service
return { return {
description: DESCRIPTION, description: DESCRIPTION,
@@ -28,6 +26,11 @@ export const GrepTool = Tool.define(
}), }),
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
const empty = {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
if (!params.pattern) { if (!params.pattern) {
throw new Error("pattern is required") throw new Error("pattern is required")
} }
@@ -43,92 +46,58 @@ export const GrepTool = Tool.define(
}, },
}) })
let searchPath = params.path ?? Instance.directory const searchPath = AppFileSystem.resolve(
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) path.isAbsolute(params.path ?? Instance.directory)
? (params.path ?? Instance.directory)
: path.join(Instance.directory, params.path ?? "."),
)
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
const rgPath = yield* Effect.promise(() => Ripgrep.filepath()) const result = yield* rg.search({
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern] cwd: searchPath,
if (params.include) { pattern: params.pattern,
args.push("--glob", params.include) glob: params.include ? [params.include] : undefined,
}
args.push(searchPath)
const result = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(rgPath, args, {
stdin: "ignore",
}),
)
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 { 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) if (result.items.length === 0) return empty
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 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 limit = 100 const limit = 100
const truncated = matches.length > limit const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) { if (finalMatches.length === 0) return empty
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
const totalMatches = matches.length const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`] const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
@@ -143,10 +112,8 @@ export const GrepTool = Tool.define(
outputLines.push(`${match.path}:`) outputLines.push(`${match.path}:`)
} }
const truncatedLineText = const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." outputLines.push(` Line ${match.line}: ${truncatedLineText}`)
: match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
} }
if (truncated) { if (truncated) {
@@ -156,7 +123,7 @@ export const GrepTool = Tool.define(
) )
} }
if (hasErrors) { if (result.partial) {
outputLines.push("") outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)") outputLines.push("(Some paths were inaccessible and skipped)")
} }
-15
View File
@@ -36,7 +36,6 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format" import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
import { Question } from "../question" import { Question } from "../question"
import { Todo } from "../session/todo" import { Todo } from "../session/todo"
@@ -344,18 +343,4 @@ export namespace ToolRegistry {
Layer.provide(Truncate.defaultLayer), 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))
}
} }
+74 -27
View File
@@ -10,59 +10,106 @@ export namespace Message {
})), })),
) )
export class File extends Schema.Class<File>("Message.File")({ export class Source extends Schema.Class<Source>("Message.Source")({
url: Schema.String, start: Schema.Number,
end: Schema.Number,
text: Schema.String,
}) {}
export class FileAttachment extends Schema.Class<FileAttachment>("Message.File.Attachment")({
uri: Schema.String,
mime: Schema.String, mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) { }) {
static create(url: string) { static create(url: string) {
return new File({ return new FileAttachment({
url, uri: url,
mime: "text/plain", mime: "text/plain",
}) })
} }
} }
export class UserContent extends Schema.Class<UserContent>("Message.User.Content")({ export class AgentAttachment extends Schema.Class<AgentAttachment>("Message.Agent.Attachment")({
text: Schema.String, name: Schema.String,
synthetic: Schema.Boolean.pipe(Schema.optional), source: Source.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
files: Schema.Array(File).pipe(Schema.optional),
}) {} }) {}
export class User extends Schema.Class<User>("Message.User")({ export class User extends Schema.Class<User>("Message.User")({
id: ID, id: ID,
type: Schema.Literal("user"), type: Schema.Literal("user"),
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
time: Schema.Struct({ time: Schema.Struct({
created: Schema.DateTimeUtc, created: Schema.DateTimeUtc,
}), }),
content: UserContent,
}) { }) {
static create(content: Schema.Schema.Type<typeof UserContent>) { static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
const msg = new User({ const msg = new User({
id: ID.create(), id: ID.create(),
type: "user", type: "user",
...input,
time: { time: {
created: Effect.runSync(DateTime.now), created: Effect.runSync(DateTime.now),
}, },
content,
}) })
return msg return msg
} }
static file(url: string) {
return new File({
url,
mime: "text/plain",
})
}
} }
export namespace User {} export class Synthetic extends Schema.Class<Synthetic>("Message.Synthetic")({
id: ID,
type: Schema.Literal("synthetic"),
text: Schema.String,
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
}) {}
export class Request extends Schema.Class<Request>("Message.Request")({
id: ID,
type: Schema.Literal("start"),
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
variant: Schema.String.pipe(Schema.optional),
}),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
}) {}
export class Text extends Schema.Class<Text>("Message.Text")({
id: ID,
type: Schema.Literal("text"),
text: Schema.String,
time: Schema.Struct({
created: Schema.DateTimeUtc,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {}
export class Complete extends Schema.Class<Complete>("Message.Complete")({
id: ID,
type: Schema.Literal("complete"),
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
cost: Schema.Number,
tokens: Schema.Struct({
total: Schema.Number,
input: Schema.Number,
output: Schema.Number,
reasoning: Schema.Number,
cache: Schema.Struct({
read: Schema.Number,
write: Schema.Number,
}),
}),
}) {}
export const Info = Schema.Union([User, Text])
export type Info = Schema.Schema.Type<typeof Info>
} }
const msg = Message.User.create({
text: "Hello world",
files: [Message.File.create("file://example.com/file.txt")],
})
console.log(JSON.stringify(msg, null, 2))
+71
View File
@@ -0,0 +1,71 @@
import { Context, Layer, Schema, Effect } from "effect"
import { Message } from "./message"
import { Struct } from "effect"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
export namespace SessionV2 {
export const ID = SessionID
export type ID = Schema.Schema.Type<typeof ID>
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
...Struct.omit(Message.User.fields, ["time", "type"]),
id: Schema.optionalKey(Message.ID),
sessionID: SessionV2.ID,
}) {}
export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
id: Schema.optionalKey(SessionV2.ID),
}) {}
export class Info extends Schema.Class<Info>("Session.Info")({
id: SessionV2.ID,
model: Schema.Struct({
id: Schema.String,
providerID: Schema.String,
modelID: Schema.String,
}).pipe(Schema.optional),
}) {}
export interface Interface {
fromID: (id: SessionV2.ID) => Effect.Effect<Info>
create: (input: CreateInput) => Effect.Effect<Info>
prompt: (input: PromptInput) => Effect.Effect<Message.User>
}
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
export const layer = Layer.effect(Service)(
Effect.gen(function* () {
const session = yield* Session.Service
const create: Interface["create"] = Effect.fn("Session.create")(function* (input) {
throw new Error("Not implemented")
})
const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (input) {
throw new Error("Not implemented")
})
const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
const match = yield* session.get(id)
return fromV1(match)
})
return Service.of({
create,
prompt,
fromID,
})
}),
)
function fromV1(input: Session.Info): Info {
return new Info({
id: SessionV2.ID.make(input.id),
})
}
}
+2 -1
View File
@@ -17,6 +17,7 @@ import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node" import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
@@ -266,7 +267,7 @@ export namespace Worktree {
const booted = yield* Effect.promise(() => const booted = yield* Effect.promise(() =>
Instance.provide({ Instance.provide({
directory: info.directory, directory: info.directory,
init: InstanceBootstrap, init: () => BootstrapRuntime.runPromise(InstanceBootstrap),
fn: () => undefined, fn: () => undefined,
}) })
.then(() => true) .then(() => true)
+51 -23
View File
@@ -1,58 +1,86 @@
import { test, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Auth } from "../../src/auth" import { Auth } from "../../src/auth"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
test("set normalizes trailing slashes in keys", async () => { const node = CrossSpawnSpawner.defaultLayer
await Auth.set("https://example.com/", {
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", type: "wellknown",
key: "TOKEN", key: "TOKEN",
token: "abc", token: "abc",
}) })
const data = await Auth.all() const data = yield* auth.all()
expect(data["https://example.com"]).toBeDefined() expect(data["https://example.com"]).toBeDefined()
expect(data["https://example.com/"]).toBeUndefined() expect(data["https://example.com/"]).toBeUndefined()
}) }),
),
)
test("set cleans up pre-existing trailing-slash entry", async () => { it.live("set cleans up pre-existing trailing-slash entry", () =>
// Simulate a pre-fix entry with trailing slash provideTmpdirInstance(() =>
await Auth.set("https://example.com/", { Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com/", {
type: "wellknown", type: "wellknown",
key: "TOKEN", key: "TOKEN",
token: "old", token: "old",
}) })
// Re-login with normalized key (as the CLI does post-fix) yield* auth.set("https://example.com", {
await Auth.set("https://example.com", {
type: "wellknown", type: "wellknown",
key: "TOKEN", key: "TOKEN",
token: "new", token: "new",
}) })
const data = await Auth.all() const data = yield* auth.all()
const keys = Object.keys(data).filter((k) => k.includes("example.com")) const keys = Object.keys(data).filter((key) => key.includes("example.com"))
expect(keys).toEqual(["https://example.com"]) expect(keys).toEqual(["https://example.com"])
const entry = data["https://example.com"]! const entry = data["https://example.com"]!
expect(entry.type).toBe("wellknown") expect(entry.type).toBe("wellknown")
if (entry.type === "wellknown") expect(entry.token).toBe("new") if (entry.type === "wellknown") expect(entry.token).toBe("new")
}) }),
),
)
test("remove deletes both trailing-slash and normalized keys", async () => { it.live("remove deletes both trailing-slash and normalized keys", () =>
await Auth.set("https://example.com", { provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("https://example.com", {
type: "wellknown", type: "wellknown",
key: "TOKEN", key: "TOKEN",
token: "abc", token: "abc",
}) })
await Auth.remove("https://example.com/") yield* auth.remove("https://example.com/")
const data = await Auth.all() const data = yield* auth.all()
expect(data["https://example.com"]).toBeUndefined() expect(data["https://example.com"]).toBeUndefined()
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 () => { it.live("set and remove are no-ops on keys without trailing slashes", () =>
await Auth.set("anthropic", { provideTmpdirInstance(() =>
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set("anthropic", {
type: "api", type: "api",
key: "sk-test", key: "sk-test",
}) })
const data = await Auth.all() const data = yield* auth.all()
expect(data["anthropic"]).toBeDefined() expect(data["anthropic"]).toBeDefined()
await Auth.remove("anthropic") yield* auth.remove("anthropic")
const after = await Auth.all() const after = yield* auth.all()
expect(after["anthropic"]).toBeUndefined() expect(after["anthropic"]).toBeUndefined()
}),
),
)
}) })
@@ -1,10 +1,10 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Stream } from "effect" import { Deferred, Effect, Layer, Stream } from "effect"
import z from "zod" import z from "zod"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event" import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -13,9 +13,7 @@ const TestEvent = {
Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })), Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })),
} }
const node = NodeChildProcessSpawner.layer.pipe( const node = CrossSpawnSpawner.defaultLayer
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
)
const live = Layer.mergeAll(Bus.layer, node) const live = Layer.mergeAll(Bus.layer, node)
@@ -6,7 +6,6 @@ import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { Global } from "../../../src/global" import { Global } from "../../../src/global"
import { TuiConfig } from "../../../src/config/tui" import { TuiConfig } from "../../../src/config/tui"
import { Config } from "../../../src/config/config"
import { Filesystem } from "../../../src/util/filesystem" import { Filesystem } from "../../../src/util/filesystem"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
@@ -325,7 +324,6 @@ export default {
}) })
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
try { try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true) expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
@@ -407,7 +405,6 @@ export default {
await TuiPluginRuntime.dispose() await TuiPluginRuntime.dispose()
cwd.mockRestore() cwd.mockRestore()
wait.mockRestore() wait.mockRestore()
install.mockRestore()
if (backup === undefined) { if (backup === undefined) {
await fs.rm(globalConfigPath, { force: true }) await fs.rm(globalConfigPath, { force: true })
} else { } else {
@@ -701,7 +698,6 @@ test("updates installed theme when plugin metadata changes", async () => {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
const api = () => const api = () =>
createTuiPluginApi({ createTuiPluginApi({
@@ -746,7 +742,6 @@ test("updates installed theme when plugin metadata changes", async () => {
await TuiPluginRuntime.dispose() await TuiPluginRuntime.dispose()
cwd.mockRestore() cwd.mockRestore()
wait.mockRestore() wait.mockRestore()
install.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE delete process.env.OPENCODE_PLUGIN_META_FILE
} }
}) })
+92 -73
View File
@@ -1,5 +1,5 @@
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test" import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
import { Effect, Layer, Option } from "effect" import { Deferred, Effect, Fiber, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
@@ -7,8 +7,9 @@ import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account" import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture" import { tmpdir, tmpdirScoped } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ /** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
const infra = CrossSpawnSpawner.defaultLayer.pipe( const infra = CrossSpawnSpawner.defaultLayer.pipe(
@@ -32,6 +33,18 @@ const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}), 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) // Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -817,89 +830,83 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
} }
}) })
test("dedupes concurrent config dependency installs for the same dir", async () => { it.live("dedupes concurrent config dependency installs for the same dir", () =>
await using tmp = await tmpdir() Effect.gen(function* () {
const dir = path.join(tmp.path, "a") const tmp = yield* tmpdirScoped()
await fs.mkdir(dir, { recursive: true }) const dir = path.join(tmp, "a")
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const ticks: number[] = []
let calls = 0 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 online = spyOn(Network, "online").mockReturnValue(false)
const targetDir = dir 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) => { const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
const hit = path.normalize(d) === path.normalize(targetDir) if (path.normalize(d) !== target) return
if (hit) {
calls += 1 calls += 1
start() Deferred.doneUnsafe(ready, Effect.void)
await gate await Effect.runPromise(Deferred.await(hold))
}
const mod = path.join(d, "node_modules", "@opencode-ai", "plugin") const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true }) await fs.mkdir(mod, { recursive: true })
await Filesystem.write( await Filesystem.write(
path.join(mod, "package.json"), path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }), JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
) )
if (hit) {
start()
await gate
}
}) })
try { yield* Effect.addFinalizer(() =>
const first = Config.installDependencies(dir) Effect.sync(() => {
await ready
const second = Config.installDependencies(dir, {
waitTick: (tick) => {
ticks.push(tick.attempt)
blocked()
blocked = () => {}
},
})
await waiting
done()
await Promise.all([first, second])
} finally {
online.mockRestore() online.mockRestore()
run.mockRestore() run.mockRestore()
} }),
)
expect(calls).toBe(2) const first = yield* installDeps(dir).pipe(Effect.forkScoped)
expect(ticks.length).toBeGreaterThan(0) yield* Deferred.await(ready)
expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
})
test("serializes config dependency installs across dirs", async () => { let done = false
const second = yield* installDeps(dir, {
waitTick: () => {
Deferred.doneUnsafe(blocked, Effect.void)
},
}).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 if (process.platform !== "win32") return
await using tmp = await tmpdir() const tmp = yield* tmpdirScoped()
const a = path.join(tmp.path, "a") const a = path.join(tmp, "a")
const b = path.join(tmp.path, "b") const b = path.join(tmp, "b")
await fs.mkdir(a, { recursive: true }) yield* Effect.promise(() => fs.mkdir(a, { recursive: true }))
await fs.mkdir(b, { recursive: true }) yield* Effect.promise(() => fs.mkdir(b, { recursive: true }))
let calls = 0 let calls = 0
let open = 0 let open = 0
let peak = 0 let peak = 0
let start = () => {} const ready = Deferred.makeUnsafe<void>()
let done = () => {} const blocked = Deferred.makeUnsafe<void>()
const ready = new Promise<void>((resolve) => { const hold = Deferred.makeUnsafe<void>()
start = resolve
})
const gate = new Promise<void>((resolve) => {
done = resolve
})
const online = spyOn(Network, "online").mockReturnValue(false) const online = spyOn(Network, "online").mockReturnValue(false)
const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => { const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
@@ -910,8 +917,8 @@ test("serializes config dependency installs across dirs", async () => {
open += 1 open += 1
peak = Math.max(peak, open) peak = Math.max(peak, open)
if (calls === 1) { if (calls === 1) {
start() Deferred.doneUnsafe(ready, Effect.void)
await gate await Effect.runPromise(Deferred.await(hold))
} }
} }
const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin") const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
@@ -925,20 +932,32 @@ test("serializes config dependency installs across dirs", async () => {
} }
}) })
try { yield* Effect.addFinalizer(() =>
const first = Config.installDependencies(a) Effect.sync(() => {
await ready
const second = Config.installDependencies(b)
done()
await Promise.all([first, second])
} finally {
online.mockRestore() online.mockRestore()
run.mockRestore() run.mockRestore()
} }),
)
const first = yield* installDeps(a).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
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(calls).toBe(2)
expect(peak).toBe(1) expect(peak).toBe(1)
}) }),
)
test("resolves scoped npm plugins in config", async () => { test("resolves scoped npm plugins in config", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
+26 -6
View File
@@ -38,7 +38,9 @@ describe("file.ripgrep", () => {
expect(hasVisible).toBe(true) expect(hasVisible).toBe(true)
expect(hasHidden).toBe(false) expect(hasHidden).toBe(false)
}) })
})
describe("Ripgrep.Service", () => {
test("search returns empty when nothing matches", async () => { test("search returns empty when nothing matches", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
@@ -46,16 +48,34 @@ describe("file.ripgrep", () => {
}, },
}) })
const hits = await Ripgrep.search({ const result = await Effect.gen(function* () {
cwd: tmp.path, const rg = yield* Ripgrep.Service
pattern: "needle", 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([])
}) })
expect(hits).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 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")
}) })
})
describe("Ripgrep.Service", () => {
test("files returns stream of filenames", async () => { test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
+29 -29
View File
@@ -1,55 +1,55 @@
import { describe, expect, spyOn, test } from "bun:test" import { describe, expect, spyOn } from "bun:test"
import path from "path" import path from "path"
import * as Lsp from "../../src/lsp/index" import { Effect, Layer } from "effect"
import { LSP } from "../../src/lsp"
import { LSPServer } from "../../src/lsp/server" import { LSPServer } from "../../src/lsp/server"
import { Instance } from "../../src/project/instance" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
describe("lsp.spawn", () => { describe("lsp.spawn", () => {
test("does not spawn builtin LSP for files outside instance", async () => { it.live("does not spawn builtin LSP for files outside instance", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try { try {
await Instance.provide({ yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
directory: tmp.path, yield* lsp.hover({
fn: async () => { file: path.join(dir, "..", "hover.ts"),
await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts"))
await Lsp.LSP.hover({
file: path.join(tmp.path, "..", "hover.ts"),
line: 0, line: 0,
character: 0, character: 0,
}) })
},
})
expect(spy).toHaveBeenCalledTimes(0) expect(spy).toHaveBeenCalledTimes(0)
} finally { } finally {
spy.mockRestore() spy.mockRestore()
await Instance.disposeAll()
} }
}) }),
),
),
)
test("would spawn builtin LSP for files inside instance", async () => { it.live("would spawn builtin LSP for files inside instance", () =>
await using tmp = await tmpdir() provideTmpdirInstance((dir) =>
LSP.Service.use((lsp) =>
Effect.gen(function* () {
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
try { try {
await Instance.provide({ yield* lsp.hover({
directory: tmp.path, file: path.join(dir, "src", "inside.ts"),
fn: async () => {
await Lsp.LSP.hover({
file: path.join(tmp.path, "src", "inside.ts"),
line: 0, line: 0,
character: 0, character: 0,
}) })
},
})
expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledTimes(1)
} finally { } finally {
spy.mockRestore() spy.mockRestore()
await Instance.disposeAll()
} }
}) }),
),
),
)
}) })
+69 -64
View File
@@ -1,23 +1,13 @@
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import path from "path" import path from "path"
import * as Lsp from "../../src/lsp/index" import { Effect, Layer } from "effect"
import { LSP } from "../../src/lsp"
import { LSPServer } from "../../src/lsp/server" import { LSPServer } from "../../src/lsp/server"
import { Instance } from "../../src/project/instance" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
function withInstance(fn: (dir: string) => Promise<void>) { const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
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", () => { describe("LSP service lifecycle", () => {
let spawnSpy: ReturnType<typeof spyOn> let spawnSpy: ReturnType<typeof spyOn>
@@ -30,97 +20,112 @@ describe("LSP service lifecycle", () => {
spawnSpy.mockRestore() spawnSpy.mockRestore()
}) })
test( it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init())))
"init() completes without error",
withInstance(async () => {
await Lsp.LSP.init()
}),
)
test( it.live("status() returns empty array initially", () =>
"status() returns empty array initially", provideTmpdirInstance(() =>
withInstance(async () => { LSP.Service.use((lsp) =>
const result = await Lsp.LSP.status() Effect.gen(function* () {
const result = yield* lsp.status()
expect(Array.isArray(result)).toBe(true) expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0) expect(result.length).toBe(0)
}), }),
),
),
) )
test( it.live("diagnostics() returns empty object initially", () =>
"diagnostics() returns empty object initially", provideTmpdirInstance(() =>
withInstance(async () => { LSP.Service.use((lsp) =>
const result = await Lsp.LSP.diagnostics() Effect.gen(function* () {
const result = yield* lsp.diagnostics()
expect(typeof result).toBe("object") expect(typeof result).toBe("object")
expect(Object.keys(result).length).toBe(0) expect(Object.keys(result).length).toBe(0)
}), }),
),
),
) )
test( it.live("hasClients() returns true for .ts files in instance", () =>
"hasClients() returns true for .ts files in instance", provideTmpdirInstance((dir) =>
withInstance(async (dir) => { LSP.Service.use((lsp) =>
const result = await Lsp.LSP.hasClients(path.join(dir, "test.ts")) Effect.gen(function* () {
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
expect(result).toBe(true) expect(result).toBe(true)
}), }),
),
),
) )
test( it.live("hasClients() returns false for files outside instance", () =>
"hasClients() returns false for files outside instance", provideTmpdirInstance((dir) =>
withInstance(async (dir) => { LSP.Service.use((lsp) =>
const result = await Lsp.LSP.hasClients(path.join(dir, "..", "outside.ts")) Effect.gen(function* () {
// hasClients checks servers but doesn't check containsPath — getClients does const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts"))
// 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") expect(typeof result).toBe("boolean")
}), }),
),
),
) )
test( it.live("workspaceSymbol() returns empty array with no clients", () =>
"workspaceSymbol() returns empty array with no clients", provideTmpdirInstance(() =>
withInstance(async () => { LSP.Service.use((lsp) =>
const result = await Lsp.LSP.workspaceSymbol("test") Effect.gen(function* () {
const result = yield* lsp.workspaceSymbol("test")
expect(Array.isArray(result)).toBe(true) expect(Array.isArray(result)).toBe(true)
expect(result.length).toBe(0) expect(result.length).toBe(0)
}), }),
),
),
) )
test( it.live("definition() returns empty array for unknown file", () =>
"definition() returns empty array for unknown file", provideTmpdirInstance((dir) =>
withInstance(async (dir) => { LSP.Service.use((lsp) =>
const result = await Lsp.LSP.definition({ Effect.gen(function* () {
const result = yield* lsp.definition({
file: path.join(dir, "nonexistent.ts"), file: path.join(dir, "nonexistent.ts"),
line: 0, line: 0,
character: 0, character: 0,
}) })
expect(Array.isArray(result)).toBe(true) expect(Array.isArray(result)).toBe(true)
}), }),
),
),
) )
test( it.live("references() returns empty array for unknown file", () =>
"references() returns empty array for unknown file", provideTmpdirInstance((dir) =>
withInstance(async (dir) => { LSP.Service.use((lsp) =>
const result = await Lsp.LSP.references({ Effect.gen(function* () {
const result = yield* lsp.references({
file: path.join(dir, "nonexistent.ts"), file: path.join(dir, "nonexistent.ts"),
line: 0, line: 0,
character: 0, character: 0,
}) })
expect(Array.isArray(result)).toBe(true) expect(Array.isArray(result)).toBe(true)
}), }),
),
),
) )
test( it.live("multiple init() calls are idempotent", () =>
"multiple init() calls are idempotent", provideTmpdirInstance(() =>
withInstance(async () => { LSP.Service.use((lsp) =>
await Lsp.LSP.init() Effect.gen(function* () {
await Lsp.LSP.init() yield* lsp.init()
await Lsp.LSP.init() yield* lsp.init()
// Should not throw or create duplicate state yield* lsp.init()
}), }),
),
),
) )
}) })
describe("LSP.Diagnostic", () => { describe("LSP.Diagnostic", () => {
test("pretty() formats error diagnostic", () => { test("pretty() formats error diagnostic", () => {
const result = Lsp.LSP.Diagnostic.pretty({ const result = LSP.Diagnostic.pretty({
range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } }, range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } },
message: "Type 'string' is not assignable to type 'number'", message: "Type 'string' is not assignable to type 'number'",
severity: 1, severity: 1,
@@ -129,7 +134,7 @@ describe("LSP.Diagnostic", () => {
}) })
test("pretty() formats warning diagnostic", () => { test("pretty() formats warning diagnostic", () => {
const result = Lsp.LSP.Diagnostic.pretty({ const result = LSP.Diagnostic.pretty({
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
message: "Unused variable", message: "Unused variable",
severity: 2, severity: 2,
@@ -138,7 +143,7 @@ describe("LSP.Diagnostic", () => {
}) })
test("pretty() defaults to ERROR when no severity", () => { test("pretty() defaults to ERROR when no severity", () => {
const result = Lsp.LSP.Diagnostic.pretty({ const result = LSP.Diagnostic.pretty({
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
message: "Something wrong", message: "Something wrong",
} as any) } as any)
+69 -12
View File
@@ -1,5 +1,6 @@
import { $ } from "bun" import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -20,8 +21,14 @@ async function withVcs(directory: string, body: () => Promise<void>) {
return Instance.provide({ return Instance.provide({
directory, directory,
fn: async () => { fn: async () => {
void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init())) await AppRuntime.runPromise(
Vcs.init() Effect.gen(function* () {
const watcher = yield* FileWatcher.Service
const vcs = yield* Vcs.Service
yield* watcher.init()
yield* vcs.init()
}),
)
await Bun.sleep(500) await Bun.sleep(500)
await body() await body()
}, },
@@ -32,7 +39,12 @@ function withVcsOnly(directory: string, body: () => Promise<void>) {
return Instance.provide({ return Instance.provide({
directory, directory,
fn: async () => { fn: async () => {
Vcs.init() await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.init()
}),
)
await body() await body()
}, },
}) })
@@ -80,7 +92,12 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await withVcs(tmp.path, async () => { await withVcs(tmp.path, async () => {
const branch = await Vcs.branch() const branch = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.branch()
}),
)
expect(branch).toBeDefined() expect(branch).toBeDefined()
expect(typeof branch).toBe("string") expect(typeof branch).toBe("string")
}) })
@@ -90,7 +107,12 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withVcs(tmp.path, async () => { await withVcs(tmp.path, async () => {
const branch = await Vcs.branch() const branch = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.branch()
}),
)
expect(branch).toBeUndefined() expect(branch).toBeUndefined()
}) })
}) })
@@ -123,7 +145,12 @@ describeVcs("Vcs", () => {
await fs.writeFile(head, `ref: refs/heads/${branch}\n`) await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
await pending await pending
const current = await Vcs.branch() const current = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.branch()
}),
)
expect(current).toBe(branch) expect(current).toBe(branch)
}) })
}) })
@@ -139,7 +166,12 @@ describe("Vcs diff", () => {
await $`git branch -M main`.cwd(tmp.path).quiet() await $`git branch -M main`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async () => { await withVcsOnly(tmp.path, async () => {
const branch = await Vcs.defaultBranch() const branch = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.defaultBranch()
}),
)
expect(branch).toBe("main") expect(branch).toBe("main")
}) })
}) })
@@ -150,7 +182,12 @@ describe("Vcs diff", () => {
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async () => { await withVcsOnly(tmp.path, async () => {
const branch = await Vcs.defaultBranch() const branch = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.defaultBranch()
}),
)
expect(branch).toBe("trunk") expect(branch).toBe("trunk")
}) })
}) })
@@ -163,7 +200,12 @@ describe("Vcs diff", () => {
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
await withVcsOnly(dir, async () => { await withVcsOnly(dir, async () => {
const [branch, base] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()]) const [branch, base] = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
}),
)
expect(branch).toBe("feature/test") expect(branch).toBe("feature/test")
expect(base).toBe("main") expect(base).toBe("main")
}) })
@@ -177,7 +219,12 @@ describe("Vcs diff", () => {
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
await withVcsOnly(tmp.path, async () => { await withVcsOnly(tmp.path, async () => {
const diff = await Vcs.diff("git") const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
expect(diff).toEqual( expect(diff).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -194,7 +241,12 @@ describe("Vcs diff", () => {
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
await withVcsOnly(tmp.path, async () => { await withVcsOnly(tmp.path, async () => {
const diff = await Vcs.diff("git") const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
expect(diff).toEqual( expect(diff).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -215,7 +267,12 @@ describe("Vcs diff", () => {
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async () => { await withVcsOnly(tmp.path, async () => {
const diff = await Vcs.diff("branch") const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("branch")
}),
)
expect(diff).toEqual( expect(diff).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -9,6 +9,17 @@ import { Provider } from "../../src/provider/provider"
import { Env } from "../../src/env" import { Env } from "../../src/env"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem" 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 () => { test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
@@ -35,7 +46,7 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
Env.set("AWS_PROFILE", "default") Env.set("AWS_PROFILE", "default")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
}, },
@@ -60,7 +71,7 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
Env.set("AWS_PROFILE", "default") Env.set("AWS_PROFILE", "default")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
}, },
@@ -116,7 +127,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
Env.set("AWS_BEARER_TOKEN_BEDROCK", "") Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
}, },
@@ -161,7 +172,7 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
Env.set("AWS_ACCESS_KEY_ID", "test-key-id") Env.set("AWS_ACCESS_KEY_ID", "test-key-id")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
}, },
@@ -192,7 +203,7 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
Env.set("AWS_PROFILE", "default") Env.set("AWS_PROFILE", "default")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com", "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
@@ -228,7 +239,7 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
Env.set("AWS_ACCESS_KEY_ID", "") Env.set("AWS_ACCESS_KEY_ID", "")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
}, },
@@ -268,7 +279,7 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
Env.set("AWS_PROFILE", "default") Env.set("AWS_PROFILE", "default")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// The model should exist with the us. prefix // The model should exist with the us. prefix
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
@@ -305,7 +316,7 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
Env.set("AWS_PROFILE", "default") Env.set("AWS_PROFILE", "default")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}, },
@@ -341,7 +352,7 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
Env.set("AWS_PROFILE", "default") Env.set("AWS_PROFILE", "default")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}, },
@@ -377,7 +388,7 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
Env.set("AWS_PROFILE", "default") Env.set("AWS_PROFILE", "default")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock]).toBeDefined()
// Non-prefixed model should still be registered // Non-prefixed model should still be registered
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
@@ -30,7 +30,7 @@
// Env.set("GITLAB_TOKEN", "test-gitlab-token") // Env.set("GITLAB_TOKEN", "test-gitlab-token")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token") // expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token")
// }, // },
@@ -62,7 +62,7 @@
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com") // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com") // expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com")
// }, // },
@@ -100,7 +100,7 @@
// Env.set("GITLAB_TOKEN", "") // Env.set("GITLAB_TOKEN", "")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// }, // },
// }) // })
@@ -135,7 +135,7 @@
// Env.set("GITLAB_TOKEN", "") // Env.set("GITLAB_TOKEN", "")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token") // expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token")
// }, // },
@@ -167,7 +167,7 @@
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal") // expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal")
// }, // },
@@ -198,7 +198,7 @@
// Env.set("GITLAB_TOKEN", "env-token") // Env.set("GITLAB_TOKEN", "env-token")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// }, // },
// }) // })
@@ -221,7 +221,7 @@
// Env.set("GITLAB_TOKEN", "test-token") // Env.set("GITLAB_TOKEN", "test-token")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain( // expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain(
// "context-1m-2025-08-07", // "context-1m-2025-08-07",
@@ -257,7 +257,7 @@
// Env.set("GITLAB_TOKEN", "test-token") // Env.set("GITLAB_TOKEN", "test-token")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined() // expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined()
// expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true) // expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
@@ -282,7 +282,7 @@
// Env.set("GITLAB_TOKEN", "test-token") // Env.set("GITLAB_TOKEN", "test-token")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// const models = Object.keys(providers[ProviderID.gitlab].models) // const models = Object.keys(providers[ProviderID.gitlab].models)
// expect(models.length).toBeGreaterThan(0) // expect(models.length).toBeGreaterThan(0)
@@ -306,7 +306,7 @@
// Env.set("GITLAB_TOKEN", "test-token") // Env.set("GITLAB_TOKEN", "test-token")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// const gitlab = providers[ProviderID.gitlab] // const gitlab = providers[ProviderID.gitlab]
// expect(gitlab).toBeDefined() // expect(gitlab).toBeDefined()
// gitlab.models["duo-workflow-sonnet-4-6"] = { // gitlab.models["duo-workflow-sonnet-4-6"] = {
@@ -332,10 +332,10 @@
// release_date: "", // release_date: "",
// variants: {}, // variants: {},
// } // }
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6")) // const model = await getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
// expect(model).toBeDefined() // expect(model).toBeDefined()
// expect(model.options?.workflowRef).toBe("claude_sonnet_4_6") // expect(model.options?.workflowRef).toBe("claude_sonnet_4_6")
// const language = await Provider.getLanguage(model) // const language = await getLanguage(model)
// expect(language).toBeDefined() // expect(language).toBeDefined()
// expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel) // expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel)
// }, // },
@@ -354,11 +354,11 @@
// Env.set("GITLAB_TOKEN", "test-token") // Env.set("GITLAB_TOKEN", "test-token")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// expect(providers[ProviderID.gitlab]).toBeDefined() // expect(providers[ProviderID.gitlab]).toBeDefined()
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) // const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
// expect(model).toBeDefined() // expect(model).toBeDefined()
// const language = await Provider.getLanguage(model) // const language = await getLanguage(model)
// expect(language).toBeDefined() // expect(language).toBeDefined()
// expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel) // expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel)
// }, // },
@@ -377,10 +377,10 @@
// Env.set("GITLAB_TOKEN", "test-token") // Env.set("GITLAB_TOKEN", "test-token")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// const gitlab = providers[ProviderID.gitlab] // const gitlab = providers[ProviderID.gitlab]
// expect(gitlab.options?.featureFlags).toBeDefined() // expect(gitlab.options?.featureFlags).toBeDefined()
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5")) // const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
// expect(model).toBeDefined() // expect(model).toBeDefined()
// expect(model.options).toBeDefined() // expect(model.options).toBeDefined()
// }, // },
@@ -401,7 +401,7 @@
// Env.set("GITLAB_TOKEN", "test-token") // Env.set("GITLAB_TOKEN", "test-token")
// }, // },
// fn: async () => { // fn: async () => {
// const providers = await Provider.list() // const providers = await list()
// const models = Object.keys(providers[ProviderID.gitlab].models) // const models = Object.keys(providers[ProviderID.gitlab].models)
// expect(models).toContain("duo-chat-haiku-4-5") // expect(models).toContain("duo-chat-haiku-4-5")
// expect(models).toContain("duo-chat-sonnet-4-5") // expect(models).toContain("duo-chat-sonnet-4-5")
+117 -78
View File
@@ -11,8 +11,47 @@ import { Provider } from "../../src/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema" import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { Env } from "../../src/env" import { Env } from "../../src/env"
import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) { 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>>) {
const item = providers[ProviderID.make("opencode")] const item = providers[ProviderID.make("opencode")]
expect(item).toBeDefined() expect(item).toBeDefined()
return Object.values(item.models).filter((model) => model.cost.input > 0).length return Object.values(item.models).filter((model) => model.cost.input > 0).length
@@ -35,7 +74,7 @@ test("provider loaded from env variable", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
// Provider should retain its connection source even if custom loaders // Provider should retain its connection source even if custom loaders
// merge additional options. // merge additional options.
@@ -66,7 +105,7 @@ test("provider loaded from config with apiKey option", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
}, },
}) })
@@ -90,7 +129,7 @@ test("disabled_providers excludes provider", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeUndefined() expect(providers[ProviderID.anthropic]).toBeUndefined()
}, },
}) })
@@ -115,7 +154,7 @@ test("enabled_providers restricts to only listed providers", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key") Env.set("OPENAI_API_KEY", "test-openai-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined() expect(providers[ProviderID.openai]).toBeUndefined()
}, },
@@ -144,7 +183,7 @@ test("model whitelist filters models for provider", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models) const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514") expect(models).toContain("claude-sonnet-4-20250514")
@@ -175,7 +214,7 @@ test("model blacklist excludes specific models", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models) const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).not.toContain("claude-sonnet-4-20250514") expect(models).not.toContain("claude-sonnet-4-20250514")
@@ -210,7 +249,7 @@ test("custom model alias via config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias") expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
@@ -253,7 +292,7 @@ test("custom provider with npm package", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("custom-provider")]).toBeDefined() expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider") expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined() expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
@@ -286,7 +325,7 @@ test("env variable takes precedence, config merges options", async () => {
Env.set("ANTHROPIC_API_KEY", "env-api-key") Env.set("ANTHROPIC_API_KEY", "env-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
// Config options should be merged // Config options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000) expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
@@ -312,11 +351,11 @@ test("getModel returns model for valid provider/model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
expect(model).toBeDefined() expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic") expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-20250514") expect(String(model.id)).toBe("claude-sonnet-4-20250514")
const language = await Provider.getLanguage(model) const language = await getLanguage(model)
expect(language).toBeDefined() expect(language).toBeDefined()
}, },
}) })
@@ -339,7 +378,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
expect(Provider.getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow()
}, },
}) })
}) })
@@ -358,7 +397,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
expect(Provider.getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow()
}, },
}) })
}) })
@@ -392,7 +431,7 @@ test("defaultModel returns first available model when no config set", async () =
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const model = await Provider.defaultModel() const model = await defaultModel()
expect(model.providerID).toBeDefined() expect(model.providerID).toBeDefined()
expect(model.modelID).toBeDefined() expect(model.modelID).toBeDefined()
}, },
@@ -417,7 +456,7 @@ test("defaultModel respects config model setting", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const model = await Provider.defaultModel() const model = await defaultModel()
expect(String(model.providerID)).toBe("anthropic") expect(String(model.providerID)).toBe("anthropic")
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
}, },
@@ -456,7 +495,7 @@ test("provider with baseURL from config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("custom-openai")]).toBeDefined() expect(providers[ProviderID.make("custom-openai")]).toBeDefined()
expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1")
}, },
@@ -494,7 +533,7 @@ test("model cost defaults to zero when not specified", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.make("test-provider")].models["test-model"] const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.cost.input).toBe(0) expect(model.cost.input).toBe(0)
expect(model.cost.output).toBe(0) expect(model.cost.output).toBe(0)
@@ -532,7 +571,7 @@ test("model options are merged from existing model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value") expect(model.options.customOption).toBe("custom-value")
}, },
@@ -561,7 +600,7 @@ test("provider removed when all models filtered out", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeUndefined() expect(providers[ProviderID.anthropic]).toBeUndefined()
}, },
}) })
@@ -584,7 +623,7 @@ test("closest finds model by partial match", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const result = await Provider.closest(ProviderID.anthropic, ["sonnet-4"]) const result = await closest(ProviderID.anthropic, ["sonnet-4"])
expect(result).toBeDefined() expect(result).toBeDefined()
expect(String(result?.providerID)).toBe("anthropic") expect(String(result?.providerID)).toBe("anthropic")
expect(String(result?.modelID)).toContain("sonnet-4") expect(String(result?.modelID)).toContain("sonnet-4")
@@ -606,7 +645,7 @@ test("closest returns undefined for nonexistent provider", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const result = await Provider.closest(ProviderID.make("nonexistent"), ["model"]) const result = await closest(ProviderID.make("nonexistent"), ["model"])
expect(result).toBeUndefined() expect(result).toBeUndefined()
}, },
}) })
@@ -639,10 +678,10 @@ test("getModel uses realIdByKey for aliased models", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("my-sonnet")) const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet"))
expect(model).toBeDefined() expect(model).toBeDefined()
expect(String(model.id)).toBe("my-sonnet") expect(String(model.id)).toBe("my-sonnet")
expect(model.name).toBe("My Sonnet Alias") expect(model.name).toBe("My Sonnet Alias")
@@ -682,7 +721,7 @@ test("provider api field sets model api.url", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
// api field is stored on model.api.url, used by getSDK to set baseURL // 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") expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1")
}, },
@@ -722,7 +761,7 @@ test("explicit baseURL overrides api field", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1") expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1")
}, },
}) })
@@ -754,7 +793,7 @@ test("model inherits properties from existing database model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.name).toBe("Custom Name for Sonnet") expect(model.name).toBe("Custom Name for Sonnet")
expect(model.capabilities.toolcall).toBe(true) expect(model.capabilities.toolcall).toBe(true)
@@ -782,7 +821,7 @@ test("disabled_providers prevents loading even with env var", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key") Env.set("OPENAI_API_KEY", "test-openai-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.openai]).toBeUndefined() expect(providers[ProviderID.openai]).toBeUndefined()
}, },
}) })
@@ -807,7 +846,7 @@ test("enabled_providers with empty array allows no providers", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key") Env.set("OPENAI_API_KEY", "test-openai-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(Object.keys(providers).length).toBe(0) expect(Object.keys(providers).length).toBe(0)
}, },
}) })
@@ -836,7 +875,7 @@ test("whitelist and blacklist can be combined", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models) const models = Object.keys(providers[ProviderID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514") expect(models).toContain("claude-sonnet-4-20250514")
@@ -875,7 +914,7 @@ test("model modalities default correctly", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.make("test-provider")].models["test-model"] const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.capabilities.input.text).toBe(true) expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.output.text).toBe(true) expect(model.capabilities.output.text).toBe(true)
@@ -918,7 +957,7 @@ test("model with custom cost values", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.make("test-provider")].models["test-model"] const model = providers[ProviderID.make("test-provider")].models["test-model"]
expect(model.cost.input).toBe(5) expect(model.cost.input).toBe(5)
expect(model.cost.output).toBe(15) expect(model.cost.output).toBe(15)
@@ -945,7 +984,7 @@ test("getSmallModel returns appropriate small model", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const model = await Provider.getSmallModel(ProviderID.anthropic) const model = await getSmallModel(ProviderID.anthropic)
expect(model).toBeDefined() expect(model).toBeDefined()
expect(model?.id).toContain("haiku") expect(model?.id).toContain("haiku")
}, },
@@ -970,7 +1009,7 @@ test("getSmallModel respects config small_model override", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const model = await Provider.getSmallModel(ProviderID.anthropic) const model = await getSmallModel(ProviderID.anthropic)
expect(model).toBeDefined() expect(model).toBeDefined()
expect(String(model?.providerID)).toBe("anthropic") expect(String(model?.providerID)).toBe("anthropic")
expect(String(model?.id)).toBe("claude-sonnet-4-20250514") expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
@@ -1019,7 +1058,7 @@ test("multiple providers can be configured simultaneously", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key") Env.set("OPENAI_API_KEY", "test-openai-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined()
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
@@ -1060,7 +1099,7 @@ test("provider with custom npm package", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("local-llm")]).toBeDefined() 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")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1") expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1")
@@ -1097,7 +1136,7 @@ test("model alias name defaults to alias key when id differs", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
}, },
}) })
@@ -1137,7 +1176,7 @@ test("provider with multiple env var options only includes apiKey when single en
Env.set("MULTI_ENV_KEY_1", "test-key") Env.set("MULTI_ENV_KEY_1", "test-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("multi-env")]).toBeDefined() expect(providers[ProviderID.make("multi-env")]).toBeDefined()
// When multiple env options exist, key should NOT be auto-set // When multiple env options exist, key should NOT be auto-set
expect(providers[ProviderID.make("multi-env")].key).toBeUndefined() expect(providers[ProviderID.make("multi-env")].key).toBeUndefined()
@@ -1179,7 +1218,7 @@ test("provider with single env var includes apiKey automatically", async () => {
Env.set("SINGLE_ENV_KEY", "my-api-key") Env.set("SINGLE_ENV_KEY", "my-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("single-env")]).toBeDefined() expect(providers[ProviderID.make("single-env")]).toBeDefined()
// Single env option should auto-set key // Single env option should auto-set key
expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key") expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key")
@@ -1216,7 +1255,7 @@ test("model cost overrides existing cost values", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.cost.input).toBe(999) expect(model.cost.input).toBe(999)
expect(model.cost.output).toBe(888) expect(model.cost.output).toBe(888)
@@ -1263,7 +1302,7 @@ test("completely new provider not in database can be configured", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined() expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined()
expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New") expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New")
const model = providers[ProviderID.make("brand-new-provider")].models["new-model"] const model = providers[ProviderID.make("brand-new-provider")].models["new-model"]
@@ -1297,7 +1336,7 @@ test("disabled_providers and enabled_providers interaction", async () => {
Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
// anthropic: in enabled, not in disabled = allowed // anthropic: in enabled, not in disabled = allowed
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
// openai: in enabled, but also in disabled = NOT allowed // openai: in enabled, but also in disabled = NOT allowed
@@ -1337,7 +1376,7 @@ test("model with tool_call false", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false) expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false)
}, },
}) })
@@ -1372,7 +1411,7 @@ test("model defaults tool_call to true when not specified", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true) expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true)
}, },
}) })
@@ -1411,7 +1450,7 @@ test("model headers are preserved", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.make("headers-provider")].models["model"] const model = providers[ProviderID.make("headers-provider")].models["model"]
expect(model.headers).toEqual({ expect(model.headers).toEqual({
"X-Custom-Header": "custom-value", "X-Custom-Header": "custom-value",
@@ -1454,7 +1493,7 @@ test("provider env fallback - second env var used if first missing", async () =>
Env.set("FALLBACK_KEY", "fallback-api-key") Env.set("FALLBACK_KEY", "fallback-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
// Provider should load because fallback env var is set // Provider should load because fallback env var is set
expect(providers[ProviderID.make("fallback-env")]).toBeDefined() expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
}, },
@@ -1478,8 +1517,8 @@ test("getModel returns consistent results", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const model1 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model2 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
expect(model1.providerID).toEqual(model2.providerID) expect(model1.providerID).toEqual(model2.providerID)
expect(model1.id).toEqual(model2.id) expect(model1.id).toEqual(model2.id)
expect(model1).toEqual(model2) expect(model1).toEqual(model2)
@@ -1516,7 +1555,7 @@ test("provider name defaults to id when not in database", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id") expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id")
}, },
}) })
@@ -1540,7 +1579,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
}, },
fn: async () => { fn: async () => {
try { try {
await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
expect(true).toBe(false) // Should not reach here expect(true).toBe(false) // Should not reach here
} catch (e: any) { } catch (e: any) {
expect(e.data.suggestions).toBeDefined() expect(e.data.suggestions).toBeDefined()
@@ -1568,7 +1607,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
}, },
fn: async () => { fn: async () => {
try { try {
await Provider.getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
expect(true).toBe(false) // Should not reach here expect(true).toBe(false) // Should not reach here
} catch (e: any) { } catch (e: any) {
expect(e.data.suggestions).toBeDefined() expect(e.data.suggestions).toBeDefined()
@@ -1592,7 +1631,7 @@ test("getProvider returns undefined for nonexistent provider", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const provider = await Provider.getProvider(ProviderID.make("nonexistent")) const provider = await getProvider(ProviderID.make("nonexistent"))
expect(provider).toBeUndefined() expect(provider).toBeUndefined()
}, },
}) })
@@ -1615,7 +1654,7 @@ test("getProvider returns provider info", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const provider = await Provider.getProvider(ProviderID.anthropic) const provider = await getProvider(ProviderID.anthropic)
expect(provider).toBeDefined() expect(provider).toBeDefined()
expect(String(provider?.id)).toBe("anthropic") expect(String(provider?.id)).toBe("anthropic")
}, },
@@ -1639,7 +1678,7 @@ test("closest returns undefined when no partial match found", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const result = await Provider.closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"])
expect(result).toBeUndefined() expect(result).toBeUndefined()
}, },
}) })
@@ -1663,7 +1702,7 @@ test("closest checks multiple query terms in order", async () => {
}, },
fn: async () => { fn: async () => {
// First term won't match, second will // First term won't match, second will
const result = await Provider.closest(ProviderID.anthropic, ["nonexistent", "haiku"]) const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"])
expect(result).toBeDefined() expect(result).toBeDefined()
expect(result?.modelID).toContain("haiku") expect(result?.modelID).toContain("haiku")
}, },
@@ -1699,7 +1738,7 @@ test("model limit defaults to zero when not specified", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.make("no-limit")].models["model"] const model = providers[ProviderID.make("no-limit")].models["model"]
expect(model.limit.context).toBe(0) expect(model.limit.context).toBe(0)
expect(model.limit.output).toBe(0) expect(model.limit.output).toBe(0)
@@ -1734,7 +1773,7 @@ test("provider options are deeply merged", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
// Custom options should be merged // Custom options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value") expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value")
@@ -1772,7 +1811,7 @@ test("custom model inherits npm package from models.dev provider config", async
Env.set("OPENAI_API_KEY", "test-api-key") Env.set("OPENAI_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.openai].models["my-custom-model"] const model = providers[ProviderID.openai].models["my-custom-model"]
expect(model).toBeDefined() expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai") expect(model.api.npm).toBe("@ai-sdk/openai")
@@ -1807,7 +1846,7 @@ test("custom model inherits api.url from models.dev provider", async () => {
Env.set("OPENROUTER_API_KEY", "test-api-key") Env.set("OPENROUTER_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.openrouter]).toBeDefined() expect(providers[ProviderID.openrouter]).toBeDefined()
// New model not in database should inherit api.url from provider // New model not in database should inherit api.url from provider
@@ -1908,7 +1947,7 @@ test("model variants are generated for reasoning models", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
// Claude sonnet 4 has reasoning capability // Claude sonnet 4 has reasoning capability
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.capabilities.reasoning).toBe(true) expect(model.capabilities.reasoning).toBe(true)
@@ -1946,7 +1985,7 @@ test("model variants can be disabled via config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined() expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined() expect(model.variants!["high"]).toBeUndefined()
@@ -1989,7 +2028,7 @@ test("model variants can be customized via config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined() expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000) expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
@@ -2028,7 +2067,7 @@ test("disabled key is stripped from variant config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined() expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined() expect(model.variants!["max"].disabled).toBeUndefined()
@@ -2066,7 +2105,7 @@ test("all variants can be disabled via config", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined() expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0) expect(Object.keys(model.variants!).length).toBe(0)
@@ -2104,7 +2143,7 @@ test("variant config merges with generated variants", async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key") Env.set("ANTHROPIC_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined() expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option // Should have both the generated thinking config and the custom option
@@ -2142,7 +2181,7 @@ test("variants filtered in second pass for database models", async () => {
Env.set("OPENAI_API_KEY", "test-api-key") Env.set("OPENAI_API_KEY", "test-api-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.openai].models["gpt-5"] const model = providers[ProviderID.openai].models["gpt-5"]
expect(model.variants).toBeDefined() expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined() expect(model.variants!["high"]).toBeUndefined()
@@ -2188,7 +2227,7 @@ test("custom model with variants enabled and disabled", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"] const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"]
expect(model.variants).toBeDefined() expect(model.variants).toBeDefined()
// Enabled variants should exist // Enabled variants should exist
@@ -2246,7 +2285,7 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
}, },
@@ -2291,7 +2330,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
expect(model).toBeDefined() expect(model).toBeDefined()
@@ -2319,7 +2358,7 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
Env.set("CLOUDFLARE_API_TOKEN", "test-token") Env.set("CLOUDFLARE_API_TOKEN", "test-token")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
}, },
}) })
@@ -2351,7 +2390,7 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
Env.set("CLOUDFLARE_API_TOKEN", "test-token") Env.set("CLOUDFLARE_API_TOKEN", "test-token")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
invoked_by: "test", invoked_by: "test",
@@ -2399,7 +2438,7 @@ test("plugin config providers persist after instance dispose", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await Plugin.init() await Plugin.init()
return Provider.list() return list()
}, },
}) })
expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")]).toBeDefined()
@@ -2409,7 +2448,7 @@ test("plugin config providers persist after instance dispose", async () => {
const second = await Instance.provide({ const second = await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => Provider.list(), fn: async () => list(),
}) })
expect(second[ProviderID.make("demo")]).toBeDefined() expect(second[ProviderID.make("demo")]).toBeDefined()
expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
@@ -2445,7 +2484,7 @@ test("plugin config enabled and disabled providers are honored", async () => {
Env.set("OPENAI_API_KEY", "test-openai-key") Env.set("OPENAI_API_KEY", "test-openai-key")
}, },
fn: async () => { fn: async () => {
const providers = await Provider.list() const providers = await list()
expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined() expect(providers[ProviderID.openai]).toBeUndefined()
}, },
@@ -2466,7 +2505,7 @@ test("opencode loader keeps paid models when config apiKey is present", async ()
const none = await Instance.provide({ const none = await Instance.provide({
directory: base.path, directory: base.path,
fn: async () => paid(await Provider.list()), fn: async () => paid(await list()),
}) })
await using keyed = await tmpdir({ await using keyed = await tmpdir({
@@ -2489,7 +2528,7 @@ test("opencode loader keeps paid models when config apiKey is present", async ()
const keyedCount = await Instance.provide({ const keyedCount = await Instance.provide({
directory: keyed.path, directory: keyed.path,
fn: async () => paid(await Provider.list()), fn: async () => paid(await list()),
}) })
expect(none).toBe(0) expect(none).toBe(0)
@@ -2510,7 +2549,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
const none = await Instance.provide({ const none = await Instance.provide({
directory: base.path, directory: base.path,
fn: async () => paid(await Provider.list()), fn: async () => paid(await list()),
}) })
await using keyed = await tmpdir({ await using keyed = await tmpdir({
@@ -2544,7 +2583,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
const keyedCount = await Instance.provide({ const keyedCount = await Instance.provide({
directory: keyed.path, directory: keyed.path,
fn: async () => paid(await Provider.list()), fn: async () => paid(await list()),
}) })
expect(none).toBe(0) expect(none).toBe(0)
@@ -1842,6 +1842,11 @@ describe("ProviderTransform.message - cache control on gateway", () => {
type: "ephemeral", type: "ephemeral",
}, },
}, },
alibaba: {
cacheControl: {
type: "ephemeral",
},
},
}) })
}) })
@@ -1894,6 +1899,11 @@ describe("ProviderTransform.message - cache control on gateway", () => {
type: "ephemeral", type: "ephemeral",
}, },
}, },
alibaba: {
cacheControl: {
type: "ephemeral",
},
},
}) })
}) })
}) })
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test" 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 { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty" import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -10,9 +12,12 @@ describe("pty", () => {
await Instance.provide({ await Instance.provide({
directory: dir.path, directory: dir.path,
fn: async () => { fn: () =>
const a = await Pty.create({ command: "cat", title: "a" }) AppRuntime.runPromise(
const b = await Pty.create({ command: "cat", title: "b" }) 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 { try {
const outA: string[] = [] const outA: string[] = []
const outB: string[] = [] const outB: string[] = []
@@ -28,30 +33,27 @@ describe("pty", () => {
}, },
} }
// Connect "a" first with ws. yield* pty.connect(a.id, ws as any)
Pty.connect(a.id, ws as any)
// Now "reuse" the same ws object for another connection.
ws.data = { events: { connection: "b" } } ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => { ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
} }
Pty.connect(b.id, ws as any) yield* pty.connect(b.id, ws as any)
// Clear connect metadata writes.
outA.length = 0 outA.length = 0
outB.length = 0 outB.length = 0
// Output from a must never show up in b. yield* pty.write(a.id, "AAA\n")
Pty.write(a.id, "AAA\n") yield* Effect.promise(() => sleep(100))
await sleep(100)
expect(outB.join("")).not.toContain("AAA") expect(outB.join("")).not.toContain("AAA")
} finally { } finally {
await Pty.remove(a.id) yield* pty.remove(a.id)
await Pty.remove(b.id) yield* pty.remove(b.id)
} }
}, }),
),
}) })
}) })
@@ -60,8 +62,11 @@ describe("pty", () => {
await Instance.provide({ await Instance.provide({
directory: dir.path, directory: dir.path,
fn: async () => { fn: () =>
const a = await Pty.create({ command: "cat", title: "a" }) AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* pty.create({ command: "cat", title: "a" })
try { try {
const outA: string[] = [] const outA: string[] = []
const outB: string[] = [] const outB: string[] = []
@@ -77,25 +82,23 @@ describe("pty", () => {
}, },
} }
// Connect "a" first. yield* pty.connect(a.id, ws as any)
Pty.connect(a.id, ws as any)
outA.length = 0 outA.length = 0
// Simulate Bun reusing the same websocket object for another
// connection before the next onOpen calls Pty.connect.
ws.data = { events: { connection: "b" } } ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => { ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
} }
Pty.write(a.id, "AAA\n") yield* pty.write(a.id, "AAA\n")
await sleep(100) yield* Effect.promise(() => sleep(100))
expect(outB.join("")).not.toContain("AAA") expect(outB.join("")).not.toContain("AAA")
} finally { } finally {
await Pty.remove(a.id) yield* pty.remove(a.id)
} }
}, }),
),
}) })
}) })
@@ -104,8 +107,11 @@ describe("pty", () => {
await Instance.provide({ await Instance.provide({
directory: dir.path, directory: dir.path,
fn: async () => { fn: () =>
const a = await Pty.create({ command: "cat", title: "a" }) AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* pty.create({ command: "cat", title: "a" })
try { try {
const out: string[] = [] const out: string[] = []
@@ -121,21 +127,20 @@ describe("pty", () => {
}, },
} }
Pty.connect(a.id, ws as any) yield* pty.connect(a.id, ws as any)
out.length = 0 out.length = 0
// Mutating fields on ws.data should not look like a new
// connection lifecycle when the object identity stays stable.
ctx.connId = 2 ctx.connId = 2
Pty.write(a.id, "AAA\n") yield* pty.write(a.id, "AAA\n")
await sleep(100) yield* Effect.promise(() => sleep(100))
expect(out.join("")).toContain("AAA") expect(out.join("")).toContain("AAA")
} finally { } finally {
await Pty.remove(a.id) yield* pty.remove(a.id)
} }
}, }),
),
}) })
}) })
}) })
+24 -14
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty" import { Pty } from "../../src/pty"
import type { PtyID } from "../../src/pty/schema" import type { PtyID } from "../../src/pty/schema"
@@ -27,7 +29,10 @@ describe("pty", () => {
await Instance.provide({ await Instance.provide({
directory: dir.path, directory: dir.path,
fn: async () => { fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
const off = [ const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
@@ -37,23 +42,24 @@ describe("pty", () => {
let id: PtyID | undefined let id: PtyID | undefined
try { try {
const info = await Pty.create({ const info = yield* pty.create({
command: "/usr/bin/env", command: "/usr/bin/env",
args: ["sh", "-c", "sleep 0.1"], args: ["sh", "-c", "sleep 0.1"],
title: "sleep", title: "sleep",
}) })
id = info.id id = info.id
await wait(() => pick(log, id!).includes("exited")) yield* Effect.promise(() => wait(() => pick(log, id!).includes("exited")))
await Pty.remove(id) yield* pty.remove(id)
await wait(() => pick(log, id!).length >= 3) yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3))
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
} finally { } finally {
off.forEach((x) => x()) off.forEach((x) => x())
if (id) await Pty.remove(id) if (id) yield* pty.remove(id)
} }
}, }),
),
}) })
}) })
@@ -64,7 +70,10 @@ describe("pty", () => {
await Instance.provide({ await Instance.provide({
directory: dir.path, directory: dir.path,
fn: async () => { fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
const off = [ const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
@@ -74,19 +83,20 @@ describe("pty", () => {
let id: PtyID | undefined let id: PtyID | undefined
try { try {
const info = await Pty.create({ command: "/bin/sh", title: "sh" }) const info = yield* pty.create({ command: "/bin/sh", title: "sh" })
id = info.id id = info.id
await sleep(100) yield* Effect.promise(() => sleep(100))
await Pty.remove(id) yield* pty.remove(id)
await wait(() => pick(log, id!).length >= 3) yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3))
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
} finally { } finally {
off.forEach((x) => x()) off.forEach((x) => x())
if (id) await Pty.remove(id) if (id) yield* pty.remove(id)
} }
}, }),
),
}) })
}) })
}) })
+18 -8
View File
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test" 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 { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty" import { Pty } from "../../src/pty"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
@@ -17,14 +19,18 @@ describe("pty shell args", () => {
await using dir = await tmpdir() await using dir = await tmpdir()
await Instance.provide({ await Instance.provide({
directory: dir.path, directory: dir.path,
fn: async () => { fn: () =>
const info = await Pty.create({ command: ps, title: "pwsh" }) AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* pty.create({ command: ps, title: "pwsh" })
try { try {
expect(info.args).toEqual([]) expect(info.args).toEqual([])
} finally { } finally {
await Pty.remove(info.id) yield* pty.remove(info.id)
} }
}, }),
),
}) })
}, },
{ timeout: 30000 }, { timeout: 30000 },
@@ -43,14 +49,18 @@ describe("pty shell args", () => {
await using dir = await tmpdir() await using dir = await tmpdir()
await Instance.provide({ await Instance.provide({
directory: dir.path, directory: dir.path,
fn: async () => { fn: () =>
const info = await Pty.create({ command: bash, title: "bash" }) AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* pty.create({ command: bash, title: "bash" })
try { try {
expect(info.args).toEqual(["-l"]) expect(info.args).toEqual(["-l"])
} finally { } finally {
await Pty.remove(info.id) yield* pty.remove(info.id)
} }
}, }),
),
}) })
}, },
{ timeout: 30000 }, { timeout: 30000 },
@@ -43,7 +43,6 @@ describe("project.initGit endpoint", () => {
worktree: tmp.path, worktree: tmp.path,
}) })
expect(reloadSpy).toHaveBeenCalledTimes(1) 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( expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe(
true, true,
) )
@@ -1005,6 +1005,15 @@ describe("session.getUsage", () => {
inputTokens: 1000, inputTokens: 1000,
outputTokens: 500, outputTokens: 500,
totalTokens: 1500, totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}, },
}) })
@@ -1023,7 +1032,15 @@ describe("session.getUsage", () => {
inputTokens: 1000, inputTokens: 1000,
outputTokens: 500, outputTokens: 500,
totalTokens: 1500, totalTokens: 1500,
cachedInputTokens: 200, inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}, },
}) })
@@ -1039,6 +1056,15 @@ describe("session.getUsage", () => {
inputTokens: 1000, inputTokens: 1000,
outputTokens: 500, outputTokens: 500,
totalTokens: 1500, totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}, },
metadata: { metadata: {
anthropic: { anthropic: {
@@ -1059,7 +1085,15 @@ describe("session.getUsage", () => {
inputTokens: 1000, inputTokens: 1000,
outputTokens: 500, outputTokens: 500,
totalTokens: 1500, totalTokens: 1500,
cachedInputTokens: 200, inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}, },
metadata: { metadata: {
anthropic: {}, anthropic: {},
@@ -1078,8 +1112,16 @@ describe("session.getUsage", () => {
inputTokens: 1000, inputTokens: 1000,
outputTokens: 500, outputTokens: 500,
totalTokens: 1500, totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 400,
reasoningTokens: 100, reasoningTokens: 100,
}, },
},
}) })
expect(result.tokens.input).toBe(1000) expect(result.tokens.input).toBe(1000)
@@ -1104,8 +1146,16 @@ describe("session.getUsage", () => {
inputTokens: 0, inputTokens: 0,
outputTokens: 1_000_000, outputTokens: 1_000_000,
totalTokens: 1_000_000, totalTokens: 1_000_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 750_000,
reasoningTokens: 250_000, reasoningTokens: 250_000,
}, },
},
}) })
expect(result.tokens.output).toBe(750_000) expect(result.tokens.output).toBe(750_000)
@@ -1121,6 +1171,15 @@ describe("session.getUsage", () => {
inputTokens: 0, inputTokens: 0,
outputTokens: 0, outputTokens: 0,
totalTokens: 0, totalTokens: 0,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}, },
}) })
@@ -1148,6 +1207,15 @@ describe("session.getUsage", () => {
inputTokens: 1_000_000, inputTokens: 1_000_000,
outputTokens: 100_000, outputTokens: 100_000,
totalTokens: 1_100_000, totalTokens: 1_100_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}, },
}) })
@@ -1163,7 +1231,15 @@ describe("session.getUsage", () => {
inputTokens: 1000, inputTokens: 1000,
outputTokens: 500, outputTokens: 500,
totalTokens: 1500, totalTokens: 1500,
cachedInputTokens: 200, inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
} }
if (npm === "@ai-sdk/amazon-bedrock") { if (npm === "@ai-sdk/amazon-bedrock") {
const result = Session.getUsage({ const result = Session.getUsage({
@@ -1214,7 +1290,15 @@ describe("session.getUsage", () => {
inputTokens: 1000, inputTokens: 1000,
outputTokens: 500, outputTokens: 500,
totalTokens: 1500, totalTokens: 1500,
cachedInputTokens: 200, inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}, },
metadata: { metadata: {
vertex: { vertex: {
@@ -219,6 +219,59 @@ describe("Instruction.resolve", () => {
test.todo("fetches remote instructions from config URLs via HttpClient", () => {}) 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", () => { describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
let originalConfigDir: string | undefined let originalConfigDir: string | undefined
+19 -9
View File
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { tool, type ModelMessage } from "ai" import { tool, type ModelMessage } from "ai"
import { Cause, Exit, Stream } from "effect" import { Cause, Effect, Exit, Stream } from "effect"
import z from "zod" import z from "zod"
import { makeRuntime } from "../../src/effect/run-service" import { makeRuntime } from "../../src/effect/run-service"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session/llm"
@@ -15,6 +15,16 @@ import { tmpdir } from "../fixture/fixture"
import type { Agent } from "../../src/agent/agent" import type { Agent } from "../../src/agent/agent"
import type { MessageV2 } from "../../src/session/message-v2" import type { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema" 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", () => { describe("session.llm.hasToolCalls", () => {
test("returns false for empty messages array", () => { test("returns false for empty messages array", () => {
@@ -325,7 +335,7 @@ describe("session.llm.stream", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-1") const sessionID = SessionID.make("session-test-1")
const agent = { const agent = {
name: "test", name: "test",
@@ -416,7 +426,7 @@ describe("session.llm.stream", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-raw-abort") const sessionID = SessionID.make("session-test-raw-abort")
const agent = { const agent = {
name: "test", name: "test",
@@ -490,7 +500,7 @@ describe("session.llm.stream", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-service-abort") const sessionID = SessionID.make("session-test-service-abort")
const agent = { const agent = {
name: "test", name: "test",
@@ -581,7 +591,7 @@ describe("session.llm.stream", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-tools") const sessionID = SessionID.make("session-test-tools")
const agent = { const agent = {
name: "test", name: "test",
@@ -699,7 +709,7 @@ describe("session.llm.stream", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id)) const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
const sessionID = SessionID.make("session-test-2") const sessionID = SessionID.make("session-test-2")
const agent = { const agent = {
name: "test", name: "test",
@@ -819,7 +829,7 @@ describe("session.llm.stream", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id)) const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
const sessionID = SessionID.make("session-test-data-url") const sessionID = SessionID.make("session-test-data-url")
const agent = { const agent = {
name: "test", name: "test",
@@ -942,7 +952,7 @@ describe("session.llm.stream", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-3") const sessionID = SessionID.make("session-test-3")
const agent = { const agent = {
name: "test", name: "test",
@@ -1043,7 +1053,7 @@ describe("session.llm.stream", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-4") const sessionID = SessionID.make("session-test-4")
const agent = { const agent = {
name: "test", name: "test",
@@ -204,7 +204,7 @@ const it = testEffect(makeHttp())
const unix = process.platform !== "win32" ? it.live : it.live.skip const unix = process.platform !== "win32" ? it.live : it.live.skip
// Config that registers a custom "test" provider with a "test-model" model // Config that registers a custom "test" provider with a "test-model" model
// so Provider.getModel("test", "test-model") succeeds inside the loop. // so provider model lookup succeeds inside the loop.
const cfg = { const cfg = {
provider: { provider: {
test: { test: {
+236 -237
View File
@@ -1,13 +1,15 @@
import { afterEach, test, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { Instance } from "../../src/project/instance" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture" import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
afterEach(async () => { const node = CrossSpawnSpawner.defaultLayer
await Instance.disposeAll()
}) const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node))
async function createGlobalSkill(homeDir: string) { async function createGlobalSkill(homeDir: string) {
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
@@ -26,13 +28,28 @@ This skill is loaded from the global home directory.
) )
} }
test("discovers skills from .opencode/skill/ directory", async () => { const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
await using tmp = await tmpdir({ Effect.acquireUseRelease(
git: true, Effect.sync(() => {
init: async (dir) => { const prev = process.env.OPENCODE_TEST_HOME
const skillDir = path.join(dir, ".opencode", "skill", "test-skill") process.env.OPENCODE_TEST_HOME = home
await Bun.write( return prev
path.join(skillDir, "SKILL.md"), }),
() => 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"),
`--- `---
name: test-skill name: test-skill
description: A test skill for verification. description: A test skill for verification.
@@ -42,30 +59,30 @@ description: A test skill for verification.
Instructions here. Instructions here.
`, `,
),
) )
},
})
await Instance.provide({ const skill = yield* Skill.Service
directory: tmp.path, const list = yield* skill.all()
fn: async () => { expect(list.length).toBe(1)
const skills = await Skill.all() const item = list.find((x) => x.name === "test-skill")
expect(skills.length).toBe(1) expect(item).toBeDefined()
const testSkill = skills.find((s) => s.name === "test-skill") expect(item!.description).toBe("A test skill for verification.")
expect(testSkill).toBeDefined() expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
expect(testSkill!.description).toBe("A test skill for verification.") }),
expect(testSkill!.location).toContain(path.join("skill", "test-skill", "SKILL.md")) { git: true },
}, ),
}) )
})
test("returns skill directories from Skill.dirs", async () => { it.live("returns skill directories from Skill.dirs", () =>
await using tmp = await tmpdir({ provideTmpdirInstance(
git: true, (dir) =>
init: async (dir) => { withHome(
const skillDir = path.join(dir, ".opencode", "skill", "dir-skill") dir,
await Bun.write( Effect.gen(function* () {
path.join(skillDir, "SKILL.md"), yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"),
`--- `---
name: dir-skill name: dir-skill
description: Skill for dirs test. description: Skill for dirs test.
@@ -73,36 +90,27 @@ description: Skill for dirs test.
# Dir Skill # Dir Skill
`, `,
),
) )
},
})
const home = process.env.OPENCODE_TEST_HOME const skill = yield* Skill.Service
process.env.OPENCODE_TEST_HOME = tmp.path const dirs = yield* skill.dirs()
expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill"))
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) expect(dirs.length).toBe(1)
}, }),
}) ),
} finally { { git: true },
process.env.OPENCODE_TEST_HOME = home ),
} )
})
test("discovers multiple skills from .opencode/skill/ directory", async () => { it.live("discovers multiple skills from .opencode/skill/ directory", () =>
await using tmp = await tmpdir({ provideTmpdirInstance(
git: true, (dir) =>
init: async (dir) => { Effect.gen(function* () {
const skillDir1 = path.join(dir, ".opencode", "skill", "skill-one") yield* Effect.promise(() =>
const skillDir2 = path.join(dir, ".opencode", "skill", "skill-two") Promise.all([
await Bun.write( Bun.write(
path.join(skillDir1, "SKILL.md"), path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"),
`--- `---
name: skill-one name: skill-one
description: First test skill. description: First test skill.
@@ -110,9 +118,9 @@ description: First test skill.
# Skill One # Skill One
`, `,
) ),
await Bun.write( Bun.write(
path.join(skillDir2, "SKILL.md"), path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"),
`--- `---
name: skill-two name: skill-two
description: Second test skill. description: Second test skill.
@@ -120,52 +128,48 @@ description: Second test skill.
# Skill Two # Skill Two
`, `,
),
]),
) )
},
})
await Instance.provide({ const skill = yield* Skill.Service
directory: tmp.path, const list = yield* skill.all()
fn: async () => { expect(list.length).toBe(2)
const skills = await Skill.all() expect(list.find((x) => x.name === "skill-one")).toBeDefined()
expect(skills.length).toBe(2) expect(list.find((x) => x.name === "skill-two")).toBeDefined()
expect(skills.find((s) => s.name === "skill-one")).toBeDefined() }),
expect(skills.find((s) => s.name === "skill-two")).toBeDefined() { git: true },
}, ),
}) )
})
test("skips skills with missing frontmatter", async () => { it.live("skips skills with missing frontmatter", () =>
await using tmp = await tmpdir({ provideTmpdirInstance(
git: true, (dir) =>
init: async (dir) => { Effect.gen(function* () {
const skillDir = path.join(dir, ".opencode", "skill", "no-frontmatter") yield* Effect.promise(() =>
await Bun.write( Bun.write(
path.join(skillDir, "SKILL.md"), path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"),
`# No Frontmatter `# No Frontmatter
Just some content without YAML frontmatter. Just some content without YAML frontmatter.
`, `,
),
) )
},
})
await Instance.provide({ const skill = yield* Skill.Service
directory: tmp.path, expect(yield* skill.all()).toEqual([])
fn: async () => { }),
const skills = await Skill.all() { git: true },
expect(skills).toEqual([]) ),
}, )
})
})
test("discovers skills from .claude/skills/ directory", async () => { it.live("discovers skills from .claude/skills/ directory", () =>
await using tmp = await tmpdir({ provideTmpdirInstance(
git: true, (dir) =>
init: async (dir) => { Effect.gen(function* () {
const skillDir = path.join(dir, ".claude", "skills", "claude-skill") yield* Effect.promise(() =>
await Bun.write( Bun.write(
path.join(skillDir, "SKILL.md"), path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`--- `---
name: claude-skill name: claude-skill
description: A skill in the .claude/skills directory. description: A skill in the .claude/skills directory.
@@ -173,64 +177,62 @@ description: A skill in the .claude/skills directory.
# Claude Skill # Claude Skill
`, `,
),
) )
},
})
await Instance.provide({ const skill = yield* Skill.Service
directory: tmp.path, const list = yield* skill.all()
fn: async () => { expect(list.length).toBe(1)
const skills = await Skill.all() const item = list.find((x) => x.name === "claude-skill")
expect(skills.length).toBe(1) expect(item).toBeDefined()
const claudeSkill = skills.find((s) => s.name === "claude-skill") expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
expect(claudeSkill).toBeDefined() }),
expect(claudeSkill!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md")) { git: true },
}, ),
}) )
})
test("discovers global skills from ~/.claude/skills/ directory", async () => { it.live("discovers global skills from ~/.claude/skills/ directory", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const originalHome = process.env.OPENCODE_TEST_HOME yield* withHome(
process.env.OPENCODE_TEST_HOME = tmp.path 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))
}),
)
}),
)
try { it.live("returns empty array when no skills exist", () =>
await createGlobalSkill(tmp.path) provideTmpdirInstance(
await Instance.provide({ () =>
directory: tmp.path, Effect.gen(function* () {
fn: async () => { const skill = yield* Skill.Service
const skills = await Skill.all() expect(yield* skill.all()).toEqual([])
expect(skills.length).toBe(1) }),
expect(skills[0].name).toBe("global-test-skill") { git: true },
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 () => { it.live("discovers skills from .agents/skills/ directory", () =>
await using tmp = await tmpdir({ git: true }) provideTmpdirInstance(
(dir) =>
await Instance.provide({ Effect.gen(function* () {
directory: tmp.path, yield* Effect.promise(() =>
fn: async () => { Bun.write(
const skills = await Skill.all() path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
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 name: agent-skill
description: A skill in the .agents/skills directory. description: A skill in the .agents/skills directory.
@@ -238,32 +240,34 @@ description: A skill in the .agents/skills directory.
# Agent Skill # Agent Skill
`, `,
),
) )
},
})
await Instance.provide({ const skill = yield* Skill.Service
directory: tmp.path, const list = yield* skill.all()
fn: async () => { expect(list.length).toBe(1)
const skills = await Skill.all() const item = list.find((x) => x.name === "agent-skill")
expect(skills.length).toBe(1) expect(item).toBeDefined()
const agentSkill = skills.find((s) => s.name === "agent-skill") expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
expect(agentSkill).toBeDefined() }),
expect(agentSkill!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md")) { git: true },
}, ),
}) )
})
test("discovers global skills from ~/.agents/skills/ directory", async () => { it.live("discovers global skills from ~/.agents/skills/ directory", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const originalHome = process.env.OPENCODE_TEST_HOME yield* withHome(
process.env.OPENCODE_TEST_HOME = tmp.path tmp.path,
Effect.gen(function* () {
try {
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill") const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
await fs.mkdir(skillDir, { recursive: true }) yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true }))
await Bun.write( yield* Effect.promise(() =>
Bun.write(
path.join(skillDir, "SKILL.md"), path.join(skillDir, "SKILL.md"),
`--- `---
name: global-agent-skill name: global-agent-skill
@@ -274,31 +278,30 @@ description: A global skill from ~/.agents/skills for testing.
This skill is loaded from the global home directory. This skill is loaded from the global home directory.
`, `,
),
) )
await Instance.provide({ yield* Effect.gen(function* () {
directory: tmp.path, const skill = yield* Skill.Service
fn: async () => { const list = yield* skill.all()
const skills = await Skill.all() expect(list.length).toBe(1)
expect(skills.length).toBe(1) expect(list[0].name).toBe("global-agent-skill")
expect(skills[0].name).toBe("global-agent-skill") expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
expect(skills[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"))
expect(skills[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md")) }).pipe(provideInstance(tmp.path))
}, }),
}) )
} finally { }),
process.env.OPENCODE_TEST_HOME = originalHome )
}
})
test("discovers skills from both .claude/skills/ and .agents/skills/", async () => { it.live("discovers skills from both .claude/skills/ and .agents/skills/", () =>
await using tmp = await tmpdir({ provideTmpdirInstance(
git: true, (dir) =>
init: async (dir) => { Effect.gen(function* () {
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill") yield* Effect.promise(() =>
const agentDir = path.join(dir, ".agents", "skills", "agent-skill") Promise.all([
await Bun.write( Bun.write(
path.join(claudeDir, "SKILL.md"), path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`--- `---
name: claude-skill name: claude-skill
description: A skill in the .claude/skills directory. description: A skill in the .claude/skills directory.
@@ -306,9 +309,9 @@ description: A skill in the .claude/skills directory.
# Claude Skill # Claude Skill
`, `,
) ),
await Bun.write( Bun.write(
path.join(agentDir, "SKILL.md"), path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`--- `---
name: agent-skill name: agent-skill
description: A skill in the .agents/skills directory. description: A skill in the .agents/skills directory.
@@ -316,31 +319,28 @@ description: A skill in the .agents/skills directory.
# Agent Skill # Agent Skill
`, `,
),
]),
) )
},
})
await Instance.provide({ const skill = yield* Skill.Service
directory: tmp.path, const list = yield* skill.all()
fn: async () => { expect(list.length).toBe(2)
const skills = await Skill.all() expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
expect(skills.length).toBe(2) expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
expect(skills.find((s) => s.name === "claude-skill")).toBeDefined() }),
expect(skills.find((s) => s.name === "agent-skill")).toBeDefined() { git: true },
}, ),
}) )
})
test("properly resolves directories that skills live in", async () => { it.live("properly resolves directories that skills live in", () =>
await using tmp = await tmpdir({ provideTmpdirInstance(
git: true, (dir) =>
init: async (dir) => { Effect.gen(function* () {
const opencodeSkillDir = path.join(dir, ".opencode", "skill", "agent-skill") yield* Effect.promise(() =>
const opencodeSkillsDir = path.join(dir, ".opencode", "skills", "agent-skill") Promise.all([
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill") Bun.write(
const agentDir = path.join(dir, ".agents", "skills", "agent-skill") path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
await Bun.write(
path.join(claudeDir, "SKILL.md"),
`--- `---
name: claude-skill name: claude-skill
description: A skill in the .claude/skills directory. description: A skill in the .claude/skills directory.
@@ -348,9 +348,9 @@ description: A skill in the .claude/skills directory.
# Claude Skill # Claude Skill
`, `,
) ),
await Bun.write( Bun.write(
path.join(agentDir, "SKILL.md"), path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`--- `---
name: agent-skill name: agent-skill
description: A skill in the .agents/skills directory. description: A skill in the .agents/skills directory.
@@ -358,9 +358,9 @@ description: A skill in the .agents/skills directory.
# Agent Skill # Agent Skill
`, `,
) ),
await Bun.write( Bun.write(
path.join(opencodeSkillDir, "SKILL.md"), path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
`--- `---
name: opencode-skill name: opencode-skill
description: A skill in the .opencode/skill directory. description: A skill in the .opencode/skill directory.
@@ -368,9 +368,9 @@ description: A skill in the .opencode/skill directory.
# OpenCode Skill # OpenCode Skill
`, `,
) ),
await Bun.write( Bun.write(
path.join(opencodeSkillsDir, "SKILL.md"), path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
`--- `---
name: opencode-skill name: opencode-skill
description: A skill in the .opencode/skills directory. description: A skill in the .opencode/skills directory.
@@ -378,15 +378,14 @@ description: A skill in the .opencode/skills directory.
# OpenCode Skill # OpenCode Skill
`, `,
),
]),
) )
},
})
await Instance.provide({ const skill = yield* Skill.Service
directory: tmp.path, expect((yield* skill.dirs()).length).toBe(4)
fn: async () => { }),
const dirs = await Skill.dirs() { git: true },
expect(dirs.length).toBe(4) ),
}, )
})
}) })
@@ -511,6 +511,49 @@ test("circular symlinks", async () => {
}) })
}) })
test("source project gitignore is respected - ignored files are not snapshotted", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
// Create gitignore BEFORE any tracking
await Filesystem.write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n")
await Filesystem.write(`${dir}/tracked.txt`, "tracked content")
await Filesystem.write(`${dir}/ignored.ignored`, "ignored content")
await $`mkdir -p ${dir}/build`.quiet()
await Filesystem.write(`${dir}/build/output.js`, "build output")
await Filesystem.write(`${dir}/normal.js`, "normal js")
await $`git add .`.cwd(dir).quiet()
await $`git commit -m init`.cwd(dir).quiet()
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Modify tracked files and create new ones - some ignored, some not
await Filesystem.write(`${tmp.path}/tracked.txt`, "modified tracked")
await Filesystem.write(`${tmp.path}/new.ignored`, "new ignored")
await Filesystem.write(`${tmp.path}/new-tracked.txt`, "new tracked")
await Filesystem.write(`${tmp.path}/build/new-build.js`, "new build file")
const patch = await Snapshot.patch(before!)
// Modified and new tracked files should be in snapshot
expect(patch.files).toContain(fwd(tmp.path, "new-tracked.txt"))
expect(patch.files).toContain(fwd(tmp.path, "tracked.txt"))
// Ignored files should NOT be in snapshot
expect(patch.files).not.toContain(fwd(tmp.path, "new.ignored"))
expect(patch.files).not.toContain(fwd(tmp.path, "ignored.ignored"))
expect(patch.files).not.toContain(fwd(tmp.path, "build/output.js"))
expect(patch.files).not.toContain(fwd(tmp.path, "build/new-build.js"))
},
})
})
test("gitignore changes", async () => { test("gitignore changes", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide({ await Instance.provide({
@@ -535,6 +578,75 @@ test("gitignore changes", async () => {
}) })
}) })
test("files tracked in snapshot but now gitignored are filtered out", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// First, create a file and snapshot it
await Filesystem.write(`${tmp.path}/later-ignored.txt`, "initial content")
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Modify the file (so it appears in diff-files)
await Filesystem.write(`${tmp.path}/later-ignored.txt`, "modified content")
// Now add gitignore that would exclude this file
await Filesystem.write(`${tmp.path}/.gitignore`, "later-ignored.txt\n")
// Also create another tracked file
await Filesystem.write(`${tmp.path}/still-tracked.txt`, "new tracked file")
const patch = await Snapshot.patch(before!)
// The file that is now gitignored should NOT appear, even though it was
// previously tracked and modified
expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt"))
// The gitignore file itself should appear
expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
// Other tracked files should appear
expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt"))
},
})
})
test("gitignore updated between track calls filters from diff", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// a.txt is already committed from bootstrap - track it in snapshot
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Modify a.txt (so it appears in diff-files)
await Filesystem.write(`${tmp.path}/a.txt`, "modified content")
// Now add gitignore that would exclude a.txt
await Filesystem.write(`${tmp.path}/.gitignore`, "a.txt\n")
// Also modify b.txt which is not gitignored
await Filesystem.write(`${tmp.path}/b.txt`, "also modified")
// Second track - should not include a.txt even though it changed
const after = await Snapshot.track()
expect(after).toBeTruthy()
// Verify a.txt is NOT in the diff between snapshots
const diffs = await Snapshot.diffFull(before!, after!)
expect(diffs.some((x) => x.file === "a.txt")).toBe(false)
// But .gitignore should be in the diff
expect(diffs.some((x) => x.file === ".gitignore")).toBe(true)
// b.txt should be in the diff (not gitignored)
expect(diffs.some((x) => x.file === "b.txt")).toBe(true)
},
})
})
test("git info exclude changes", async () => { test("git info exclude changes", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide({ await Instance.provide({
+24 -8
View File
@@ -65,6 +65,18 @@ const readFileTime = (sessionID: SessionID, filepath: string) =>
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) => const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback))) 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("tool.edit", () => {
describe("creating new files", () => { describe("creating new files", () => {
test("creates new file when oldString is empty", async () => { test("creates new file when oldString is empty", async () => {
@@ -128,9 +140,9 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
const { FileWatcher } = await import("../../src/file/watcher") const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = [] const updated = await onceBus(FileWatcher.Event.Updated)
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
try {
const edit = await resolve() const edit = await resolve()
await Effect.runPromise( await Effect.runPromise(
edit.execute( edit.execute(
@@ -143,8 +155,10 @@ describe("tool.edit", () => {
), ),
) )
expect(events).toContain("updated") await updated.wait
unsubUpdated() } finally {
updated.unsub()
}
}, },
}) })
}) })
@@ -359,9 +373,9 @@ describe("tool.edit", () => {
const { FileWatcher } = await import("../../src/file/watcher") const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = [] const updated = await onceBus(FileWatcher.Event.Updated)
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
try {
const edit = await resolve() const edit = await resolve()
await Effect.runPromise( await Effect.runPromise(
edit.execute( edit.execute(
@@ -374,8 +388,10 @@ describe("tool.edit", () => {
), ),
) )
expect(events).toContain("updated") await updated.wait
unsubUpdated() } finally {
updated.unsub()
}
}, },
}) })
}) })
+47 -83
View File
@@ -1,22 +1,26 @@
import { describe, expect, test } from "bun:test" import { describe, expect } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer } from "effect"
import { GrepTool } from "../../src/tool/grep" import { GrepTool } from "../../src/tool/grep"
import { Instance } from "../../src/project/instance" import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "../../src/filesystem"
import { testEffect } from "../lib/effect"
const runtime = ManagedRuntime.make( const it = testEffect(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer), Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
) )
function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
}
const ctx = { const ctx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""), messageID: MessageID.make(""),
@@ -31,12 +35,11 @@ const ctx = {
const projectRoot = path.join(__dirname, "../..") const projectRoot = path.join(__dirname, "../..")
describe("tool.grep", () => { describe("tool.grep", () => {
test("basic search", async () => { it.live("basic search", () =>
await Instance.provide({ Effect.gen(function* () {
directory: projectRoot, const info = yield* GrepTool
fn: async () => { const grep = yield* info.init()
const grep = await initGrep() const result = yield* provideInstance(projectRoot)(
const result = await Effect.runPromise(
grep.execute( grep.execute(
{ {
pattern: "export", pattern: "export",
@@ -48,82 +51,43 @@ describe("tool.grep", () => {
) )
expect(result.metadata.matches).toBeGreaterThan(0) expect(result.metadata.matches).toBeGreaterThan(0)
expect(result.output).toContain("Found") expect(result.output).toContain("Found")
}, }),
}) )
})
test("no matches returns correct output", async () => { it.live("no matches returns correct output", () =>
await using tmp = await tmpdir({ provideTmpdirInstance((dir) =>
init: async (dir) => { Effect.gen(function* () {
await Bun.write(path.join(dir, "test.txt"), "hello world") yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world"))
}, const info = yield* GrepTool
}) const grep = yield* info.init()
await Instance.provide({ const result = yield* grep.execute(
directory: tmp.path,
fn: async () => {
const grep = await initGrep()
const result = await Effect.runPromise(
grep.execute(
{ {
pattern: "xyznonexistentpatternxyz123", pattern: "xyznonexistentpatternxyz123",
path: tmp.path, path: dir,
}, },
ctx, ctx,
),
) )
expect(result.metadata.matches).toBe(0) expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found") expect(result.output).toBe("No files found")
}, }),
})
})
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)
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,
}) )
}) 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)
})
}) })
+72 -77
View File
@@ -1,26 +1,31 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture" import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { ToolRegistry } from "../../src/tool/registry" 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 () => { afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
describe("tool.registry", () => { describe("tool.registry", () => {
test("loads tools from .opencode/tool (singular)", async () => { it.live("loads tools from .opencode/tool (singular)", () =>
await using tmp = await tmpdir({ provideTmpdirInstance((dir) =>
init: async (dir) => { Effect.gen(function* () {
const opencodeDir = path.join(dir, ".opencode") const opencode = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true }) const tool = path.join(opencode, "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
const toolDir = path.join(opencodeDir, "tool") yield* Effect.promise(() =>
await fs.mkdir(toolDir, { recursive: true }) Bun.write(
path.join(tool, "hello.ts"),
await Bun.write(
path.join(toolDir, "hello.ts"),
[ [
"export default {", "export default {",
" description: 'hello tool',", " description: 'hello tool',",
@@ -31,30 +36,24 @@ describe("tool.registry", () => {
"}", "}",
"", "",
].join("\n"), ].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("hello") expect(ids).toContain("hello")
}, }),
}) ),
}) )
test("loads tools from .opencode/tools (plural)", async () => { it.live("loads tools from .opencode/tools (plural)", () =>
await using tmp = await tmpdir({ provideTmpdirInstance((dir) =>
init: async (dir) => { Effect.gen(function* () {
const opencodeDir = path.join(dir, ".opencode") const opencode = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true }) const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
const toolsDir = path.join(opencodeDir, "tools") yield* Effect.promise(() =>
await fs.mkdir(toolsDir, { recursive: true }) Bun.write(
path.join(tools, "hello.ts"),
await Bun.write(
path.join(toolsDir, "hello.ts"),
[ [
"export default {", "export default {",
" description: 'hello tool',", " description: 'hello tool',",
@@ -65,30 +64,24 @@ describe("tool.registry", () => {
"}", "}",
"", "",
].join("\n"), ].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("hello") expect(ids).toContain("hello")
}, }),
}) ),
}) )
test("loads tools with external dependencies without crashing", async () => { it.live("loads tools with external dependencies without crashing", () =>
await using tmp = await tmpdir({ provideTmpdirInstance((dir) =>
init: async (dir) => { Effect.gen(function* () {
const opencodeDir = path.join(dir, ".opencode") const opencode = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true }) const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
const toolsDir = path.join(opencodeDir, "tools") yield* Effect.promise(() =>
await fs.mkdir(toolsDir, { recursive: true }) Bun.write(
path.join(opencode, "package.json"),
await Bun.write(
path.join(opencodeDir, "package.json"),
JSON.stringify({ JSON.stringify({
name: "custom-tools", name: "custom-tools",
dependencies: { dependencies: {
@@ -96,10 +89,11 @@ describe("tool.registry", () => {
cowsay: "^1.6.0", cowsay: "^1.6.0",
}, },
}), }),
),
) )
yield* Effect.promise(() =>
await Bun.write( Bun.write(
path.join(opencodeDir, "package-lock.json"), path.join(opencode, "package-lock.json"),
JSON.stringify({ JSON.stringify({
name: "custom-tools", name: "custom-tools",
lockfileVersion: 3, lockfileVersion: 3,
@@ -112,25 +106,30 @@ describe("tool.registry", () => {
}, },
}, },
}), }),
),
) )
const cowsayDir = path.join(opencodeDir, "node_modules", "cowsay") const cowsay = path.join(opencode, "node_modules", "cowsay")
await fs.mkdir(cowsayDir, { recursive: true }) yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
await Bun.write( yield* Effect.promise(() =>
path.join(cowsayDir, "package.json"), Bun.write(
path.join(cowsay, "package.json"),
JSON.stringify({ JSON.stringify({
name: "cowsay", name: "cowsay",
type: "module", type: "module",
exports: "./index.js", exports: "./index.js",
}), }),
),
) )
await Bun.write( yield* Effect.promise(() =>
path.join(cowsayDir, "index.js"), Bun.write(
path.join(cowsay, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
),
) )
yield* Effect.promise(() =>
await Bun.write( Bun.write(
path.join(toolsDir, "cowsay.ts"), path.join(tools, "cowsay.ts"),
[ [
"import { say } from 'cowsay'", "import { say } from 'cowsay'",
"export default {", "export default {",
@@ -142,16 +141,12 @@ describe("tool.registry", () => {
"}", "}",
"", "",
].join("\n"), ].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") expect(ids).toContain("cowsay")
}, }),
}) ),
}) )
}) })
+60 -53
View File
@@ -1,6 +1,7 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
@@ -11,8 +12,9 @@ import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill" import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
import { tmpdir } from "../fixture/fixture" import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
const baseCtx: Omit<Tool.Context, "ask"> = { const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
@@ -28,14 +30,19 @@ afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
describe("tool.skill", () => { describe("tool.skill", () => {
test("description lists skill location URL", async () => { it.live("description lists skill location URL", () =>
await using tmp = await tmpdir({ provideTmpdirInstance(
git: true, (dir) =>
init: async (dir) => { Effect.gen(function* () {
const skillDir = path.join(dir, ".opencode", "skill", "tool-skill") const skill = path.join(dir, ".opencode", "skill", "tool-skill")
await Bun.write( yield* Effect.promise(() =>
path.join(skillDir, "SKILL.md"), Bun.write(
path.join(skill, "SKILL.md"),
`--- `---
name: tool-skill name: tool-skill
description: Skill for tool tests. description: Skill for tool tests.
@@ -43,42 +50,41 @@ description: Skill for tool tests.
# Tool Skill # Tool Skill
`, `,
),
) )
},
})
const home = process.env.OPENCODE_TEST_HOME const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
try { Effect.sync(() => {
await Instance.provide({ process.env.OPENCODE_TEST_HOME = home
directory: tmp.path, }),
fn: async () => { )
const desc = await ToolRegistry.tools({ const registry = yield* ToolRegistry.Service
const desc =
(yield* registry.tools({
providerID: "opencode" as any, providerID: "opencode" as any,
modelID: "gpt-5" as any, modelID: "gpt-5" as any,
agent: { name: "build", mode: "primary" as const, permission: [], options: {} }, agent: { name: "build", mode: "primary", permission: [], options: {} },
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") })).find((tool) => tool.id === SkillTool.id)?.description ?? ""
expect(desc).toContain(`**tool-skill**: Skill for tool tests.`) expect(desc).toContain("**tool-skill**: Skill for tool tests.")
}, }),
}) { git: true },
} finally { ),
process.env.OPENCODE_TEST_HOME = home )
}
})
test("description sorts skills by name and is stable across calls", async () => { it.live("description sorts skills by name and is stable across calls", () =>
await using tmp = await tmpdir({ provideTmpdirInstance(
git: true, (dir) =>
init: async (dir) => { Effect.gen(function* () {
for (const [name, description] of [ for (const [name, description] of [
["zeta-skill", "Zeta skill."], ["zeta-skill", "Zeta skill."],
["alpha-skill", "Alpha skill."], ["alpha-skill", "Alpha skill."],
["middle-skill", "Middle skill."], ["middle-skill", "Middle skill."],
]) { ]) {
const skillDir = path.join(dir, ".opencode", "skill", name) const skill = path.join(dir, ".opencode", "skill", name)
await Bun.write( yield* Effect.promise(() =>
path.join(skillDir, "SKILL.md"), Bun.write(
path.join(skill, "SKILL.md"),
`--- `---
name: ${name} name: ${name}
description: ${description} description: ${description}
@@ -86,27 +92,30 @@ description: ${description}
# ${name} # ${name}
`, `,
),
) )
} }
},
})
const home = process.env.OPENCODE_TEST_HOME const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const load = () => const registry = yield* ToolRegistry.Service
ToolRegistry.tools({ const load = Effect.fnUntraced(function* () {
return (
(yield* registry.tools({
providerID: "opencode" as any, providerID: "opencode" as any,
modelID: "gpt-5" as any, modelID: "gpt-5" as any,
agent, agent,
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") })).find((tool) => tool.id === SkillTool.id)?.description ?? ""
const first = await load() )
const second = await load() })
const first = yield* load()
const second = yield* load()
expect(first).toBe(second) expect(first).toBe(second)
@@ -117,12 +126,10 @@ description: ${description}
expect(alpha).toBeGreaterThan(-1) expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha) expect(middle).toBeGreaterThan(alpha)
expect(zeta).toBeGreaterThan(middle) expect(zeta).toBeGreaterThan(middle)
}, }),
}) { git: true },
} finally { ),
process.env.OPENCODE_TEST_HOME = home )
}
})
test("execute returns skill content block with files", async () => { test("execute returns skill content block with files", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
+8 -2
View File
@@ -77,6 +77,12 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
workspace: config?.experimental_workspaceID, workspace: config?.experimental_workspaceID,
}), }),
) )
const result = new OpencodeClient({ client }) client.interceptors.response.use((response) => {
return result 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 })
} }
+119 -119
View File
@@ -33,6 +33,13 @@ export type EventProjectUpdated = {
properties: Project properties: Project
} }
export type EventServerInstanceDisposed = {
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventInstallationUpdated = { export type EventInstallationUpdated = {
type: "installation.updated" type: "installation.updated"
properties: { properties: {
@@ -47,13 +54,6 @@ export type EventInstallationUpdateAvailable = {
} }
} }
export type EventServerInstanceDisposed = {
type: "server.instance.disposed"
properties: {
directory: string
}
}
export type EventServerConnected = { export type EventServerConnected = {
type: "server.connected" type: "server.connected"
properties: { properties: {
@@ -68,6 +68,21 @@ 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 = { export type EventLspClientDiagnostics = {
type: "lsp.client.diagnostics" type: "lsp.client.diagnostics"
properties: { properties: {
@@ -215,107 +230,6 @@ 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 = { export type QuestionOption = {
/** /**
* Display text (1-5 words, concise) * Display text (1-5 words, concise)
@@ -446,6 +360,92 @@ 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 = { export type EventWorktreeReady = {
type: "worktree.ready" type: "worktree.ready"
properties: { properties: {
@@ -973,11 +973,13 @@ export type EventSessionDeleted = {
export type Event = export type Event =
| EventProjectUpdated | EventProjectUpdated
| EventServerInstanceDisposed
| EventInstallationUpdated | EventInstallationUpdated
| EventInstallationUpdateAvailable | EventInstallationUpdateAvailable
| EventServerInstanceDisposed
| EventServerConnected | EventServerConnected
| EventGlobalDisposed | EventGlobalDisposed
| EventFileEdited
| EventFileWatcherUpdated
| EventLspClientDiagnostics | EventLspClientDiagnostics
| EventLspUpdated | EventLspUpdated
| EventMessagePartDelta | EventMessagePartDelta
@@ -985,16 +987,6 @@ export type Event =
| EventPermissionReplied | EventPermissionReplied
| EventSessionDiff | EventSessionDiff
| EventSessionError | EventSessionError
| EventFileEdited
| EventFileWatcherUpdated
| EventVcsBranchUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventQuestionAsked | EventQuestionAsked
| EventQuestionReplied | EventQuestionReplied
| EventQuestionRejected | EventQuestionRejected
@@ -1002,6 +994,14 @@ export type Event =
| EventSessionStatus | EventSessionStatus
| EventSessionIdle | EventSessionIdle
| EventSessionCompacted | EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
| EventVcsBranchUpdated
| EventWorktreeReady | EventWorktreeReady
| EventWorktreeFailed | EventWorktreeFailed
| EventPtyCreated | EventPtyCreated
+302 -302
View File
@@ -7230,6 +7230,25 @@
}, },
"required": ["type", "properties"] "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": { "Event.installation.updated": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -7268,25 +7287,6 @@
}, },
"required": ["type", "properties"] "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": { "Event.server.connected": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -7315,6 +7315,60 @@
}, },
"required": ["type", "properties"] "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": { "Event.lsp.client.diagnostics": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -7731,264 +7785,6 @@
}, },
"required": ["type", "properties"] "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": { "QuestionOption": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8289,6 +8085,210 @@
}, },
"required": ["type", "properties"] "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": { "Event.worktree.ready": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -9874,21 +9874,27 @@
{ {
"$ref": "#/components/schemas/Event.project.updated" "$ref": "#/components/schemas/Event.project.updated"
}, },
{
"$ref": "#/components/schemas/Event.server.instance.disposed"
},
{ {
"$ref": "#/components/schemas/Event.installation.updated" "$ref": "#/components/schemas/Event.installation.updated"
}, },
{ {
"$ref": "#/components/schemas/Event.installation.update-available" "$ref": "#/components/schemas/Event.installation.update-available"
}, },
{
"$ref": "#/components/schemas/Event.server.instance.disposed"
},
{ {
"$ref": "#/components/schemas/Event.server.connected" "$ref": "#/components/schemas/Event.server.connected"
}, },
{ {
"$ref": "#/components/schemas/Event.global.disposed" "$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" "$ref": "#/components/schemas/Event.lsp.client.diagnostics"
}, },
@@ -9911,13 +9917,25 @@
"$ref": "#/components/schemas/Event.session.error" "$ref": "#/components/schemas/Event.session.error"
}, },
{ {
"$ref": "#/components/schemas/Event.file.edited" "$ref": "#/components/schemas/Event.question.asked"
}, },
{ {
"$ref": "#/components/schemas/Event.file.watcher.updated" "$ref": "#/components/schemas/Event.question.replied"
}, },
{ {
"$ref": "#/components/schemas/Event.vcs.branch.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.tui.prompt.append" "$ref": "#/components/schemas/Event.tui.prompt.append"
@@ -9941,25 +9959,7 @@
"$ref": "#/components/schemas/Event.command.executed" "$ref": "#/components/schemas/Event.command.executed"
}, },
{ {
"$ref": "#/components/schemas/Event.question.asked" "$ref": "#/components/schemas/Event.vcs.branch.updated"
},
{
"$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" "$ref": "#/components/schemas/Event.worktree.ready"
+34 -11
View File
@@ -1,6 +1,5 @@
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs"
import { sampledChecksum } from "@opencode-ai/util/encode" import { formatPatch, parsePatch, structuredPatch } from "diff"
import { formatPatch, structuredPatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
type LegacyDiff = { type LegacyDiff = {
@@ -41,8 +40,32 @@ function empty(file: string, key: string) {
} }
function patch(diff: ReviewDiff) { function patch(diff: ReviewDiff) {
if (typeof diff.patch === "string") return diff.patch if (typeof diff.patch === "string") {
return formatPatch( 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( structuredPatch(
diff.file, diff.file,
diff.file, diff.file,
@@ -52,15 +75,15 @@ function patch(diff: ReviewDiff) {
"", "",
{ context: Number.MAX_SAFE_INTEGER }, { context: Number.MAX_SAFE_INTEGER },
), ),
) ),
}
} }
function file(file: string, patch: string) { function file(file: string, patch: string, before: string, after: string) {
const hit = cache.get(patch) const hit = cache.get(patch)
if (hit) return hit if (hit) return hit
const key = sampledChecksum(patch) ?? file const value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
const value = parsePatchFiles(patch, key).flatMap((item) => item.files)[0] ?? empty(file, key)
cache.set(patch, value) cache.set(patch, value)
return value return value
} }
@@ -69,11 +92,11 @@ export function normalize(diff: ReviewDiff): ViewDiff {
const next = patch(diff) const next = patch(diff)
return { return {
file: diff.file, file: diff.file,
patch: next, patch: next.patch,
additions: diff.additions, additions: diff.additions,
deletions: diff.deletions, deletions: diff.deletions,
status: diff.status, status: diff.status,
fileDiff: file(diff.file, next), fileDiff: file(diff.file, next.patch, next.before, next.after),
} }
} }