Compare commits

..
Author SHA1 Message Date
Kit Langton 13dc12fe6d refactor: reuse containsPath in file service 2026-04-17 22:17:13 -04:00
Kit Langton c74350dc22 refactor: use InstanceState context in File service 2026-04-17 22:16:41 -04:00
Kit Langton 5fa1673341 refactor: use InstanceState context in File service (#23015) 2026-04-17 22:08:57 -04:00
opencode-agent[bot] daaa1c7e26 chore: generate 2026-04-18 02:03:30 +00:00
Kit Langton 1fae784b81 feat(effect-zod): add ZodPreprocess annotation for pre-parse transforms (#23222) 2026-04-18 02:02:37 +00:00
Aiden Cline 81b7b58a5e fix: gh copilot issue w/ haiku (eager_input_streaming not supported) (#23223) 2026-04-17 20:57:48 -05:00
opencode-agent[bot] 866188a643 chore: generate 2026-04-18 01:48:50 +00:00
Kit Langton e6fd57165e refactor: remove ambient instance reads from lsp (#23023) 2026-04-17 21:47:59 -04:00
Kit Langton a5d99e7a3c refactor: pass formatter instance context explicitly (#23020) 2026-04-18 01:22:36 +00:00
opencode-agent[bot] a92c75e5f4 chore: generate 2026-04-18 01:21:01 +00:00
Kit Langton 826fd3350c refactor(config): migrate Server + Layout to Effect Schema (#23216) 2026-04-18 01:20:06 +00:00
Kit Langton 23a2d01282 fix(observability): standardize session telemetry attrs (#23213) 2026-04-17 21:14:23 -04:00
Kit Langton 5181f9b4e1 refactor(config): drop ZodOverride from PositiveInt in provider.ts (#23215) 2026-04-18 01:04:40 +00:00
opencode-agent[bot] f52ae28432 chore: generate 2026-04-18 00:56:33 +00:00
Kit Langton 36119ff173 feat(effect-zod): translate Schema.withDecodingDefault into zod .default() (#23207) 2026-04-17 20:55:38 -04:00
Kit Langton bb90f3bbf9 feat(effect-zod): translate well-known filters into native Zod methods (#23209) 2026-04-17 20:50:36 -04:00
Kit Langton 05cdb7c107 refactor(v2): tag session unions and exhaustively match events (#23201) 2026-04-18 00:29:26 +00:00
Kit Langton b493dabfe6 docs(effect): refresh migration status specs (#23206) 2026-04-18 00:29:26 +00:00
opencode-agent[bot] c4816f944e chore: generate 2026-04-18 00:29:26 +00:00
Kit Langton 211136e3a8 feat(effect-zod): transform support + walk memoization + flattened checks (#23203) 2026-04-18 00:29:26 +00:00
opencode-agent[bot] cf0a53c501 chore: generate 2026-04-18 00:29:26 +00:00
Kit Langton 2899984819 refactor(config): migrate provider (Model + Info) to Effect Schema (#23197) 2026-04-18 00:29:26 +00:00
Kit Langton eafbe5c57c refactor(server): align route-span attrs with OTel semantic conventions (#23198) 2026-04-18 00:29:26 +00:00
Kit Langton 7b98f544ff feat(effect-zod): add catchall (StructWithRest) support to the walker (#23186) 2026-04-18 00:29:26 +00:00
Kit Langton b5aba5807c feat(tui): show session ID in sidebar on non-prod channels (#23185) 2026-04-18 00:29:26 +00:00
Kit Langton d5c4c26b4b feat(server): auto-tag route spans with route params (session.id, message.id, …) (#23189) 2026-04-18 00:29:26 +00:00
opencode a35b8a95c2 release: v1.4.11 2026-04-18 00:29:16 +00:00
Dax cded68a2e2 refactor(npm): use object-based package spec for install API (#23181) 2026-04-17 17:30:50 -04:00
Aiden Cline 0068ccec35 fix: ensure copilot model list filters out disabled models (#23176) 2026-04-17 16:27:32 -05:00
57 changed files with 1642 additions and 713 deletions
+16 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.10",
"version": "1.4.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -458,7 +458,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -493,7 +493,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -508,7 +508,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.4.10",
"version": "1.4.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -532,7 +532,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -660,7 +660,6 @@
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/openai@3.0.53": "patches/@ai-sdk%2Fopenai@3.0.53.patch",
},
"overrides": {
"@types/bun": "catalog:",
-1
View File
@@ -127,7 +127,6 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@ai-sdk/openai@3.0.53": "patches/@ai-sdk%2Fopenai@3.0.53.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.4.10",
"version": "1.4.11",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.4.10",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.4.10",
"version": "1.4.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.4.10",
"version": "1.4.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.4.10",
"version": "1.4.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.10",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.4.10",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.4.10",
"version": "1.4.11",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.4.10"
version = "1.4.11"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.4.10",
"version": "1.4.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.10",
"version": "1.4.11",
"name": "opencode",
"type": "module",
"license": "MIT",
+32 -49
View File
@@ -1,12 +1,13 @@
# Facade removal checklist
Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`.
As of 2026-04-13, latest `origin/dev`:
Current status on this branch:
- `src/` still has 15 `makeRuntime(...)` call sites.
- 13 of those are still in scope for facade removal.
- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
- `src/` has 5 `makeRuntime(...)` call sites total.
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
Recent progress:
@@ -15,8 +16,9 @@ Recent progress:
## Priority hotspots
- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
## Completed Batches
@@ -184,53 +186,34 @@ These were the recurring mistakes and useful corrections from the first two batc
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
## Next batch
## Remaining work
Recommended next five, in order:
Most of the original facade-removal backlog is already done. The practical remaining work is narrower now:
1. `src/permission/index.ts`
2. `src/agent/agent.ts`
3. `src/session/summary.ts`
4. `src/session/revert.ts`
5. `src/mcp/auth.ts`
Why this batch:
- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
After that batch, the expected follow-up is the main session cluster:
1. `src/session/index.ts`
2. `src/session/prompt.ts`
3. `src/session/compaction.ts`
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
## Checklist
- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
- [x] `src/file/index.ts` (`File`) - facades removed and merged.
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service`
- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
- [x] `src/file/index.ts` (`File`) - facades removed and merged
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
- [x] `src/config/config.ts` (`Config`) - facades removed and merged
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged
## Excluded `makeRuntime(...)` sites
+27 -27
View File
@@ -76,7 +76,7 @@ Many route boundaries still use Zod-first validators. That does not block all ex
### 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.
Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
@@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda
### 1. Finish the prerequisites first
- continue route-handler effectification in `server/instance/*.ts`
- continue route-handler effectification in `server/routes/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
@@ -98,9 +98,9 @@ The current server composition, middleware, and docs flow are Hono-centered toda
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`
- `server/routes/instance/question.ts`
- `server/routes/instance/provider.ts`
- `server/routes/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
@@ -155,9 +155,9 @@ This gives:
As each route group is ported to `HttpApi`:
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
4. verify SDK output is unchanged
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
@@ -267,7 +267,7 @@ Use the same sequence for each route group.
3. Apply the schema migration ordering above so those types are Effect Schema-first.
4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel under an experimental prefix.
6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
8. Add one end-to-end test and one OpenAPI-focused test.
9. Compare ergonomics before migrating the next endpoint.
@@ -286,20 +286,20 @@ Placement rule:
- keep `HttpApi` code under `src/server`, not `src/effect`
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
- place each `HttpApi` slice next to the HTTP boundary it serves
- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
Suggested file layout for a repeatable spike:
- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/instance/httpapi/server.ts`standalone Effect HTTP server that composes all groups
- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/routes/instance/httpapi/server.ts`bridged Effect HTTP layer that composes all groups
- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
Suggested responsibilities:
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
- tests should verify the bridged routes through the normal server surface
## Example migration shape
@@ -319,33 +319,33 @@ Each route-group spike should follow the same shape.
- keep handler bodies thin
- keep transport mapping at the HTTP boundary only
### 3. Standalone server
### 3. Bridged server
- the Effect HTTP server is self-contained in `httpapi/server.ts`
- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
- each route group exposes its own OpenAPI doc endpoint
- the Effect HTTP layer is composed in `httpapi/server.ts`
- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
### 4. Verification
- seed real state through the existing service
- call the experimental endpoints
- call the bridged endpoints with the flag enabled
- assert that the service behavior is unchanged
- assert that the generated OpenAPI contains the migrated paths and schemas
## Boundary composition
The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
### Auth
- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
### Instance and workspace lookup
- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
@@ -360,7 +360,7 @@ The standalone Effect server owns its own middleware stack. It does not share mi
The first slice is successful if:
- the standalone Effect server starts and serves the endpoints independently of the Hono server
- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
- the handlers reuse the existing Effect service
- request decoding and response shapes are schema-defined from canonical Effect schemas
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
@@ -157,7 +157,7 @@ Direct legacy usage means any source file that still calls one of:
- `Instance.reload(...)`
- `Instance.dispose()` / `Instance.disposeAll()`
Current total: `54` files in `packages/opencode/src`.
Current total: `56` files in `packages/opencode/src`.
### Core bridge and plumbing
@@ -177,13 +177,13 @@ Migration rule:
These are the current request-entry seams that still create or consume instance context through the legacy helper.
- `src/server/instance/middleware.ts`
- `src/server/instance/index.ts`
- `src/server/instance/project.ts`
- `src/server/instance/workspace.ts`
- `src/server/instance/file.ts`
- `src/server/instance/experimental.ts`
- `src/server/instance/global.ts`
- `src/server/routes/instance/middleware.ts`
- `src/server/routes/instance/index.ts`
- `src/server/routes/instance/project.ts`
- `src/server/routes/control/workspace.ts`
- `src/server/routes/instance/file.ts`
- `src/server/routes/instance/experimental.ts`
- `src/server/routes/global.ts`
Migration rule:
@@ -239,7 +239,7 @@ Migration rule:
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
- `src/agent/agent.ts`
- `src/config/tui-migrate.ts`
- `src/cli/cmd/tui/config/tui-migrate.ts`
- `src/file/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
@@ -250,7 +250,7 @@ These modules are already the best near-term migration targets because they are
- `src/project/vcs.ts`
- `src/provider/provider.ts`
- `src/pty/index.ts`
- `src/session/index.ts`
- `src/session/session.ts`
- `src/session/instruction.ts`
- `src/session/llm.ts`
- `src/session/system.ts`
+6 -8
View File
@@ -4,11 +4,11 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
## Config / TUI
- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration.
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
- [ ] `cli/cmd/tui/config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
- [x] `env/index.ts` - already uses `InstanceState.make(...)`.
## ConfigPaths
@@ -21,14 +21,12 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
- `readFile(...)`
- `parseText(...)`
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Instance cleanup
- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
## Notes
+34 -44
View File
@@ -19,53 +19,43 @@ See `instance-context.md` for the phased plan to remove the legacy ALS / promise
## Service shape
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
```ts
export namespace Foo {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// For instance-scoped services:
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
// Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
export * as Foo from "."
```
Rules:
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
- `runPromise` goes inside the namespace (not exported unless tests need it)
- Facade functions are plain `async function` — no `fn()` wrappers
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
- No `Layer.fresh` — InstanceState handles per-directory isolation
- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
- Use `Effect.fn("Foo.method")` for Effect methods
- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
## Schema → Zod interop
@@ -266,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
## Destroying the facades
This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
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.
@@ -297,11 +287,11 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
+16 -18
View File
@@ -39,28 +39,26 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu
## Current route files
Current instance route files live under `src/server/instance`, not `server/routes`.
Current instance route files live under `src/server/routes/instance`.
The main migration targets are:
Files that are already mostly on the intended service-yielding shape:
- [ ] `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
- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service`
- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service`
- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service`
- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service`
- [x] `server/routes/instance/pty.ts`handlers yield `Pty.Service`
Additional route files that still participate in the migration:
Files still worth tracking here:
- [ ] `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
- [ ] `server/routes/instance/session.ts`still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage
- [ ] `server/routes/instance/index.ts`mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path`
- [ ] `server/routes/instance/file.ts`most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed
- [ ] `server/routes/instance/experimental.ts`mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads
- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)`
- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style
## 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.
- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files.
- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files.
@@ -40,13 +40,13 @@ Everything still lives in `packages/opencode`.
Important current facts:
- there is no `packages/core` or `packages/cli` workspace yet
- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
- there is no `packages/server` workspace yet on this branch
- the main host server is still Hono-based in `src/server/server.ts`
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
- that experimental slice is mounted under `/experimental/httpapi/question`
- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*`
- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI`
- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes
This means the package split should start from an extraction path, not from greenfield package ownership.
@@ -209,17 +209,19 @@ Current host and route composition:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/index.ts`
- `src/server/routes/instance/index.ts`
- `src/server/middleware.ts`
- `src/server/adapter.bun.ts`
- `src/server/adapter.node.ts`
Current experimental `HttpApi` slice:
Current bridged `HttpApi` slices:
- `src/server/instance/httpapi/question.ts`
- `src/server/instance/httpapi/index.ts`
- `src/server/instance/experimental.ts`
- `test/server/question-httpapi.test.ts`
- `src/server/routes/instance/httpapi/question.ts`
- `src/server/routes/instance/httpapi/permission.ts`
- `src/server/routes/instance/httpapi/provider.ts`
- `src/server/routes/instance/httpapi/config.ts`
- `src/server/routes/instance/httpapi/project.ts`
- `src/server/routes/instance/httpapi/server.ts`
Current OpenAPI flow:
@@ -245,7 +247,7 @@ Keep in `packages/opencode` for now:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/*.ts`
- `src/server/routes/**/*.ts`
- `src/server/middleware.ts`
- `src/server/adapter.*.ts`
- `src/effect/app-runtime.ts`
@@ -305,14 +307,13 @@ Bad early migration targets:
## First vertical slice
The first slice for the package split is the existing experimental `question` group.
The first slice for the package split is still the existing `question` `HttpApi` group.
Why `question` first:
- it already exists as an experimental `HttpApi` slice
- it already follows the desired contract and implementation split in one file
- it is already mounted through the current Hono host
- it already has an end-to-end test
- it is JSON-only
- it has low blast radius
@@ -357,7 +358,7 @@ Done means:
Scope:
- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts`
- place it in `packages/server/src/definition/question.ts`
- aggregate it in `packages/server/src/definition/api.ts`
- generate OpenAPI in `packages/server/src/openapi.ts`
@@ -399,8 +400,9 @@ Scope:
- replace local experimental question route wiring in `packages/opencode`
- keep the same mount path:
- `/experimental/httpapi/question`
- `/experimental/httpapi/question/doc`
- `/question`
- `/question/:requestID/reply`
- `/question/:requestID/reject`
Rules:
@@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing.
Typical validation for the first waves:
- `bun typecheck` in the touched package directory or directories
- the relevant route test, especially `test/server/question-httpapi.test.ts`
- the relevant server / route coverage for the migrated slice
- merged OpenAPI coverage if the PR touches spec generation
Do not run tests from repo root.
+3 -5
View File
@@ -36,7 +36,7 @@ This keeps tool tests aligned with the production service graph and makes follow
## Exported tools
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
@@ -45,7 +45,6 @@ These exported tool definitions already exist in `src/tool` and are on the curre
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `ls.ts`
- [x] `lsp.ts`
- [x] `multiedit.ts`
- [x] `plan.ts`
@@ -60,7 +59,7 @@ These exported tool definitions already exist in `src/tool` and are on the curre
Notes:
- `batch.ts` is no longer a current tool file and should not be tracked here.
- There is no current `ls.ts` tool file on this branch.
- `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.
@@ -73,7 +72,7 @@ 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`
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
- [ ] `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:
@@ -83,7 +82,6 @@ Notable items that are already effectively on the target path and do not need se
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `ls.ts`
- `multiedit.ts`
- `edit.ts`
@@ -158,7 +158,12 @@ export const layer = Layer.effect(
(dir) =>
npm
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(Effect.forkScoped),
{
@@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { InstallationVersion } from "@/installation/version"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
@@ -62,6 +62,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
+20 -27
View File
@@ -25,18 +25,20 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
import { ConfigLayout } from "./layout"
import { ConfigLSP } from "./lsp"
import { ConfigManaged } from "./managed"
import { ConfigMCP } from "./mcp"
import { ConfigModelID } from "./model-id"
import { ConfigPlugin } from "./plugin"
import { ConfigManaged } from "./managed"
import { ConfigCommand } from "./command"
import { ConfigParse } from "./parse"
import { ConfigPermission } from "./permission"
import { ConfigProvider } from "./provider"
import { ConfigSkills } from "./skills"
import { ConfigPaths } from "./paths"
import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp"
import { ConfigPermission } from "./permission"
import { ConfigPlugin } from "./plugin"
import { ConfigProvider } from "./provider"
import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@/npm"
@@ -73,23 +75,9 @@ async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(
return config
}
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
})
.strict()
.meta({
ref: "ServerConfig",
})
export const Layout = z.enum(["auto", "stretch"]).meta({
ref: "LayoutConfig",
})
export type Layout = z.infer<typeof Layout>
export const Server = ConfigServer.Server.zod
export const Layout = ConfigLayout.Layout.zod
export type Layout = ConfigLayout.Layout
export const Info = z
.object({
@@ -171,7 +159,7 @@ export const Info = z
.optional()
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
provider: z
.record(z.string(), ConfigProvider.Info)
.record(z.string(), ConfigProvider.Info.zod)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
@@ -518,7 +506,12 @@ export const layer = Layer.effect(
const dep = yield* npmSvc
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(
Effect.exit,
+10
View File
@@ -0,0 +1,10 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Layout = Schema.Literals(["auto", "stretch"])
.annotate({ identifier: "LayoutConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Layout = Schema.Schema.Type<typeof Layout>
export * as ConfigLayout from "./layout"
+107 -113
View File
@@ -1,120 +1,114 @@
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Model = z
.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
family: Schema.optional(Schema.String),
release_date: Schema.optional(Schema.String),
attachment: Schema.optional(Schema.Boolean),
reasoning: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Boolean),
tool_call: Schema.optional(Schema.Boolean),
interleaved: Schema.optional(
Schema.Union([
Schema.Literal(true),
Schema.Struct({
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
}),
]),
),
cost: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
}),
),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
variants: z
.record(
z.string(),
z
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any()),
)
.optional()
.describe("Variant-specific configuration"),
})
.partial()
),
limit: Schema.optional(
Schema.Struct({
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
}),
),
modalities: Schema.optional(
Schema.Struct({
input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
}),
),
experimental: Schema.optional(Schema.Boolean),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
variants: Schema.optional(
Schema.Record(
Schema.String,
Schema.StructWithRest(
Schema.Struct({
disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }),
}),
[Schema.Record(Schema.String, Schema.Any)],
),
).annotate({ description: "Variant-specific configuration" }),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Info = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
timeout: z
.union([
z
.number()
.int()
.positive()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
z.literal(false).describe("Disable timeout for this provider entirely."),
])
.optional()
.describe(
export class Info extends Schema.Class<Info>("ProviderConfig")({
api: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
id: Schema.optional(Schema.String),
npm: Schema.optional(Schema.String),
whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
options: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
apiKey: Schema.optional(Schema.String),
baseURL: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String).annotate({
description: "GitHub Enterprise URL for copilot authentication",
}),
setCacheKey: Schema.optional(Schema.Boolean).annotate({
description: "Enable promptCacheKey for this provider (default false)",
}),
timeout: Schema.optional(
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
description:
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
}),
).annotate({
description:
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
chunkTimeout: z
.number()
.int()
.positive()
.optional()
.describe(
}),
chunkTimeout: Schema.optional(PositiveInt).annotate({
description:
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
),
})
.catchall(z.any())
.optional(),
models: z.record(z.string(), Model).optional(),
})
.partial()
.strict()
.meta({
ref: "ProviderConfig",
})
export type Info = z.infer<typeof Info>
}),
}),
[Schema.Record(Schema.String, Schema.Any)],
),
),
models: Schema.optional(Schema.Record(Schema.String, Model)),
}) {
static readonly zod = zod(this)
}
export * as ConfigProvider from "./provider"
+20
View File
@@ -0,0 +1,20 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
export class Server extends Schema.Class<Server>("ServerConfig")({
port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
description: "Port to listen on",
}),
hostname: Schema.optional(Schema.String).annotate({ description: "Hostname to listen on" }),
mdns: Schema.optional(Schema.Boolean).annotate({ description: "Enable mDNS service discovery" }),
mdnsDomain: Schema.optional(Schema.String).annotate({
description: "Custom domain name for mDNS service (default: opencode.local)",
}),
cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Additional domains to allow for CORS",
}),
}) {
static readonly zod = zod(this)
}
export * as ConfigServer from "./server"
+7 -1
View File
@@ -3,6 +3,8 @@ import { Log } from "@/util"
type Fields = Record<string, unknown>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
@@ -12,7 +14,11 @@ export interface Handle {
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
Object.fromEntries(
Object.entries(input ?? {})
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => [normalizeKey(key), value]),
)
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
+28 -20
View File
@@ -356,8 +356,9 @@ export const layer = Layer.effect(
)
const scan = Effect.fn("File.scan")(function* () {
if (Instance.directory === path.parse(Instance.directory).root) return
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
const ctx = yield* InstanceState.context
if (ctx.directory === path.parse(ctx.directory).root) return
const isGlobalHome = ctx.directory === Global.Path.home && ctx.project.id === "global"
const next: Entry = { files: [], dirs: [] }
if (isGlobalHome) {
@@ -366,14 +367,14 @@ export const layer = Layer.effect(
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
const top = yield* appFs.readDirectoryEntries(ctx.directory).pipe(Effect.orElseSucceed(() => []))
for (const entry of top) {
if (entry.type !== "directory") continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(Instance.directory, entry.name)
const base = path.join(ctx.directory, entry.name)
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
for (const child of children) {
if (child.type !== "directory") continue
@@ -384,7 +385,7 @@ export const layer = Layer.effect(
next.dirs = Array.from(dirs).toSorted()
} else {
const files = yield* rg.files({ cwd: Instance.directory }).pipe(
const files = yield* rg.files({ cwd: ctx.directory }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
@@ -416,7 +417,7 @@ export const layer = Layer.effect(
})
const gitText = Effect.fnUntraced(function* (args: string[]) {
return (yield* git.run(args, { cwd: Instance.directory })).text()
return (yield* git.run(args, { cwd: (yield* InstanceState.context).directory })).text()
})
const init = Effect.fn("File.init")(function* () {
@@ -424,7 +425,8 @@ export const layer = Layer.effect(
})
const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return []
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
const diffOutput = yield* gitText([
"-c",
@@ -463,7 +465,7 @@ export const layer = Layer.effect(
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
const content = yield* appFs
.readFileString(path.join(Instance.directory, file))
.readFileString(path.join(ctx.directory, file))
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
if (content === undefined) continue
changed.push({
@@ -498,19 +500,22 @@ export const layer = Layer.effect(
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
const full = path.isAbsolute(item.path) ? item.path : path.join(ctx.directory, item.path)
return {
...item,
path: path.relative(Instance.directory, full),
path: path.relative(ctx.directory, full),
}
})
})
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file })
const full = path.join(Instance.directory, file)
const ctx = yield* InstanceState.context
const full = path.join(ctx.directory, file)
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
if (!Instance.containsPath(full, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
if (isImageByExtension(file)) {
const exists = yield* appFs.existsSafe(full)
@@ -553,13 +558,13 @@ export const layer = Layer.effect(
Effect.catch(() => Effect.succeed("")),
)
if (Instance.project.vcs === "git") {
if (ctx.project.vcs === "git") {
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
if (!diff.trim()) {
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
}
if (diff.trim()) {
const original = yield* git.show(Instance.directory, "HEAD", file)
const original = yield* git.show(ctx.directory, "HEAD", file)
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
@@ -573,21 +578,24 @@ export const layer = Layer.effect(
})
const list = Effect.fn("File.list")(function* (dir?: string) {
const ctx = yield* InstanceState.context
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (Instance.project.vcs === "git") {
if (ctx.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(Instance.project.worktree, ".gitignore")
const gitignore = path.join(ctx.worktree, ".gitignore")
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
if (gitignoreText) ig.add(gitignoreText)
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
const ignoreFile = path.join(ctx.worktree, ".ignore")
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
if (ignoreText) ig.add(ignoreText)
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
if (!Instance.containsPath(resolved, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
@@ -595,7 +603,7 @@ export const layer = Layer.effect(
for (const entry of entries) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(Instance.directory, absolute)
const file = path.relative(ctx.directory, absolute)
const type = entry.type === "directory" ? "directory" : "file"
nodes.push({
name: entry.name,
+21 -19
View File
@@ -1,15 +1,17 @@
import { Npm } from "../npm"
import { Instance } from "../project/instance"
import type { InstanceContext } from "../project/instance"
import { Filesystem } from "../util"
import { Process } from "../util"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {}
export interface Info {
name: string
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<string[] | false>
enabled(context: Context): Promise<string[] | false>
}
export const gofmt: Info = {
@@ -65,8 +67,8 @@ export const prettier: Info = {
".graphql",
".gql",
],
async enabled() {
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
async enabled(context) {
const items = await Filesystem.findUp("package.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
@@ -87,9 +89,9 @@ export const oxfmt: Info = {
BUN_BE_BUN: "1",
},
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
async enabled() {
async enabled(context) {
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
const items = await Filesystem.findUp("package.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
@@ -137,10 +139,10 @@ export const biome: Info = {
".graphql",
".gql",
],
async enabled() {
async enabled(context) {
const configs = ["biome.json", "biome.jsonc"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
const found = await Filesystem.findUp(config, context.directory, context.worktree)
if (found.length > 0) {
const bin = await Npm.which("@biomejs/biome")
if (bin) return [bin, "format", "--write", "$FILE"]
@@ -163,8 +165,8 @@ export const zig: Info = {
export const clang: Info = {
name: "clang-format",
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
async enabled(context) {
const items = await Filesystem.findUp(".clang-format", context.directory, context.worktree)
if (items.length > 0) {
const match = which("clang-format")
if (match) return [match, "-i", "$FILE"]
@@ -186,11 +188,11 @@ export const ktlint: Info = {
export const ruff: Info = {
name: "ruff",
extensions: [".py", ".pyi"],
async enabled() {
async enabled(context) {
if (!which("ruff")) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
const found = await Filesystem.findUp(config, context.directory, context.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0])
@@ -202,7 +204,7 @@ export const ruff: Info = {
}
const deps = ["requirements.txt", "pyproject.toml", "Pipfile"]
for (const dep of deps) {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
const found = await Filesystem.findUp(dep, context.directory, context.worktree)
if (found.length > 0) {
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return ["ruff", "format", "$FILE"]
@@ -233,8 +235,8 @@ export const rlang: Info = {
export const uvformat: Info = {
name: "uv",
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
async enabled(context) {
if (await ruff.enabled(context)) return false
const uv = which("uv")
if (uv == null) return false
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
@@ -286,9 +288,9 @@ export const dart: Info = {
export const ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
async enabled() {
async enabled(context) {
if (!which("ocamlformat")) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
const items = await Filesystem.findUp(".ocamlformat", context.directory, context.worktree)
if (items.length > 0) return ["ocamlformat", "-i", "$FILE"]
return false
},
@@ -357,8 +359,8 @@ export const rustfmt: Info = {
export const pint: Info = {
name: "pint",
extensions: [".php"],
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
async enabled(context) {
const items = await Filesystem.findUp("composer.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
require?: Record<string, string>
+3 -3
View File
@@ -37,14 +37,14 @@ export const layer = Layer.effect(
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (_ctx) {
Effect.fn("Format.state")(function* (ctx) {
const commands: Record<string, string[] | false> = {}
const formatters: Record<string, Formatter.Info> = {}
async function getCommand(item: Formatter.Info) {
let cmd = commands[item.name]
if (cmd === false || cmd === undefined) {
cmd = await item.enabled()
cmd = await item.enabled(ctx)
commands[item.name] = cmd
}
return cmd
@@ -153,7 +153,7 @@ export const layer = Layer.effect(
...info,
name,
extensions: info.extensions ?? [],
enabled: builtIn && !info.command ? builtIn.enabled : async () => info.command ?? false,
enabled: builtIn && !info.command ? builtIn.enabled : async (_context) => info.command ?? false,
}
}
}
+19 -20
View File
@@ -11,7 +11,6 @@ import z from "zod"
import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/shared/util/error"
import { withTimeout } from "../util/timeout"
import { Instance } from "../project/instance"
import { Filesystem } from "../util"
const DIAGNOSTICS_DEBOUNCE_MS = 150
@@ -39,7 +38,7 @@ export const Event = {
),
}
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
@@ -145,33 +144,33 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return connection
},
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
const text = await Filesystem.readText(input.path)
const extension = path.extname(input.path)
async open(request: { path: string }) {
request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
const text = await Filesystem.readText(request.path)
const extension = path.extname(request.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
const version = files[input.path]
const version = files[request.path]
if (version !== undefined) {
log.info("workspace/didChangeWatchedFiles", input)
log.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
uri: pathToFileURL(request.path).href,
type: 2, // Changed
},
],
})
const next = version + 1
files[input.path] = next
files[request.path] = next
log.info("textDocument/didChange", {
path: input.path,
path: request.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: pathToFileURL(input.path).href,
uri: pathToFileURL(request.path).href,
version: next,
},
contentChanges: [{ text }],
@@ -179,36 +178,36 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return
}
log.info("workspace/didChangeWatchedFiles", input)
log.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
uri: pathToFileURL(request.path).href,
type: 1, // Created
},
],
})
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
log.info("textDocument/didOpen", request)
diagnostics.delete(request.path)
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(input.path).href,
uri: pathToFileURL(request.path).href,
languageId,
version: 0,
text,
},
})
files[input.path] = 0
files[request.path] = 0
return
},
},
get diagnostics() {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
async waitForDiagnostics(request: { path: string }) {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
)
log.info("waiting for diagnostics", { path: normalizedPath })
let unsub: () => void
+17 -8
View File
@@ -7,12 +7,12 @@ import { pathToFileURL, fileURLToPath } from "url"
import * as LSPServer from "./server"
import z from "zod"
import { Config } from "../config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
const log = Log.create({ service: "lsp" })
@@ -162,7 +162,7 @@ export const layer = Layer.effect(
const config = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* () {
Effect.fn("LSP.state")(function* (ctx) {
const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {}
@@ -187,7 +187,7 @@ export const layer = Layer.effect(
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
root: existing?.root ?? (async (_file, ctx) => ctx.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
@@ -225,7 +225,13 @@ export const layer = Layer.effect(
)
const getClients = Effect.fnUntraced(function* (file: string) {
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
const ctx = yield* InstanceState.context
if (
!AppFileSystem.contains(ctx.directory, file) &&
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
) {
return [] as LSPClient.Info[]
}
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
@@ -233,7 +239,7 @@ export const layer = Layer.effect(
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
.spawn(root)
.spawn(root, ctx)
.then((value) => {
if (!value) s.broken.add(key)
return value
@@ -251,6 +257,7 @@ export const layer = Layer.effect(
serverID: server.id,
server: handle,
root,
directory: ctx.directory,
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
@@ -273,7 +280,7 @@ export const layer = Layer.effect(
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
const root = await server.root(file, ctx)
if (!root) continue
if (s.broken.has(root + server.id)) continue
@@ -326,13 +333,14 @@ export const layer = Layer.effect(
})
const status = Effect.fn("LSP.status")(function* () {
const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state)
const result: Status[] = []
for (const client of s.clients) {
result.push({
id: client.serverID,
name: s.servers[client.serverID].id,
root: path.relative(Instance.directory, client.root),
root: path.relative(ctx.directory, client.root),
status: "connected",
})
}
@@ -340,12 +348,13 @@ export const layer = Layer.effect(
})
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
const root = await server.root(file, ctx)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
+42 -42
View File
@@ -6,7 +6,7 @@ import { Log } from "../util"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util"
import { Instance } from "../project/instance"
import type { InstanceContext } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util"
import { Process } from "../util"
@@ -29,15 +29,15 @@ export interface Handle {
initialization?: Record<string, any>
}
type RootFunction = (file: string) => Promise<string | undefined>
type RootFunction = (file: string, ctx: InstanceContext) => Promise<string | undefined>
const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => {
return async (file) => {
return async (file, ctx) => {
if (excludePatterns) {
const excludedFiles = Filesystem.up({
targets: excludePatterns,
start: path.dirname(file),
stop: Instance.directory,
stop: ctx.directory,
})
const excluded = await excludedFiles.next()
await excludedFiles.return()
@@ -46,11 +46,11 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo
const files = Filesystem.up({
targets: includePatterns,
start: path.dirname(file),
stop: Instance.directory,
stop: ctx.directory,
})
const first = await files.next()
await files.return()
if (!first.value) return Instance.directory
if (!first.value) return ctx.directory
return path.dirname(first.value)
}
}
@@ -60,16 +60,16 @@ export interface Info {
extensions: string[]
global?: boolean
root: RootFunction
spawn(root: string): Promise<Handle | undefined>
spawn(root: string, ctx: InstanceContext): Promise<Handle | undefined>
}
export const Deno: Info = {
id: "deno",
root: async (file) => {
root: async (file, ctx) => {
const files = Filesystem.up({
targets: ["deno.json", "deno.jsonc"],
start: path.dirname(file),
stop: Instance.directory,
stop: ctx.directory,
})
const first = await files.next()
await files.return()
@@ -98,8 +98,8 @@ export const Typescript: Info = {
["deno.json", "deno.jsonc"],
),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(root) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
async spawn(root, ctx) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
const bin = await Npm.which("typescript-language-server")
@@ -154,8 +154,8 @@ export const ESLint: Info = {
id: "eslint",
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
async spawn(root) {
const eslint = Module.resolve("eslint", Instance.directory)
async spawn(root, ctx) {
const eslint = Module.resolve("eslint", ctx.directory)
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
@@ -219,7 +219,7 @@ export const Oxlint: Info = {
"package.json",
]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"],
async spawn(root) {
async spawn(root, ctx) {
const ext = process.platform === "win32" ? ".cmd" : ""
const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext)
@@ -232,7 +232,7 @@ export const Oxlint: Info = {
const candidates = Filesystem.up({
targets: [target],
start: root,
stop: Instance.worktree,
stop: ctx.worktree,
})
const first = await candidates.next()
await candidates.return()
@@ -344,10 +344,10 @@ export const Biome: Info = {
export const Gopls: Info = {
id: "gopls",
root: async (file) => {
const work = await NearestRoot(["go.work"])(file)
root: async (file, ctx) => {
const work = await NearestRoot(["go.work"])(file, ctx)
if (work) return work
return NearestRoot(["go.mod", "go.sum"])(file)
return NearestRoot(["go.mod", "go.sum"])(file, ctx)
},
extensions: [".go"],
async spawn(root) {
@@ -810,8 +810,8 @@ export const SourceKit: Info = {
export const RustAnalyzer: Info = {
id: "rust",
root: async (root) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root)
root: async (file, ctx) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx)
if (crateRoot === undefined) {
return undefined
}
@@ -834,7 +834,7 @@ export const RustAnalyzer: Info = {
currentDir = parentDir
// Stop if we've gone above the app root
if (!currentDir.startsWith(Instance.worktree)) break
if (!currentDir.startsWith(ctx.worktree)) break
}
return crateRoot
@@ -1031,8 +1031,8 @@ export const Astro: Info = {
id: "astro",
extensions: [".astro"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
async spawn(root, ctx) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
if (!tsserver) {
log.info("typescript not found, required for Astro language server")
return
@@ -1067,7 +1067,7 @@ export const Astro: Info = {
export const JDTLS: Info = {
id: "jdtls",
root: async (file) => {
root: async (file, ctx) => {
// Without exclusions, NearestRoot defaults to instance directory so we can't
// distinguish between a) no project found and b) project found at instance dir.
// So we can't choose the root from (potential) monorepo markers first.
@@ -1077,12 +1077,12 @@ export const JDTLS: Info = {
const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers)
const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([
NearestRoot(
["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"],
exclusionsForMonorepos,
)(file),
NearestRoot(gradleMarkers, settingsMarkers)(file),
NearestRoot(settingsMarkers)(file),
NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], exclusionsForMonorepos)(
file,
ctx,
),
NearestRoot(gradleMarkers, settingsMarkers)(file, ctx),
NearestRoot(settingsMarkers)(file, ctx),
])
// If projectRoot is undefined we know we are in a monorepo or no project at all.
@@ -1189,18 +1189,18 @@ export const JDTLS: Info = {
export const KotlinLS: Info = {
id: "kotlin-ls",
extensions: [".kt", ".kts"],
root: async (file) => {
root: async (file, ctx) => {
// 1) Nearest Gradle root (multi-project or included build)
const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file)
const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file, ctx)
if (settingsRoot) return settingsRoot
// 2) Gradle wrapper (strong root signal)
const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file)
const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file, ctx)
if (wrapperRoot) return wrapperRoot
// 3) Single-project or module-level build
const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file)
const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file, ctx)
if (buildRoot) return buildRoot
// 4) Maven fallback
return NearestRoot(["pom.xml"])(file)
return NearestRoot(["pom.xml"])(file, ctx)
},
async spawn(root) {
const distPath = path.join(Global.Path.bin, "kotlin-ls")
@@ -1539,7 +1539,7 @@ export const Ocaml: Info = {
export const BashLS: Info = {
id: "bash",
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async () => Instance.directory,
root: async (_file, ctx) => ctx.directory,
async spawn(root) {
let binary = which("bash-language-server")
const args: string[] = []
@@ -1734,7 +1734,7 @@ export const TexLab: Info = {
export const DockerfileLS: Info = {
id: "dockerfile",
extensions: [".dockerfile", "Dockerfile"],
root: async () => Instance.directory,
root: async (_file, ctx) => ctx.directory,
async spawn(root) {
let binary = which("docker-langserver")
const args: string[] = []
@@ -1799,16 +1799,16 @@ export const Clojure: Info = {
export const Nixd: Info = {
id: "nixd",
extensions: [".nix"],
root: async (file) => {
root: async (file, ctx) => {
// First, look for flake.nix - the most reliable Nix project root indicator
const flakeRoot = await NearestRoot(["flake.nix"])(file)
if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot
const flakeRoot = await NearestRoot(["flake.nix"])(file, ctx)
if (flakeRoot && flakeRoot !== ctx.directory) return flakeRoot
// If no flake.nix, fall back to git repository root
if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree
if (ctx.worktree && ctx.worktree !== ctx.directory) return ctx.worktree
// Finally, use the instance directory as fallback
return Instance.directory
return ctx.directory
},
async spawn(root) {
const nixd = which("nixd")
+11 -5
View File
@@ -25,7 +25,12 @@ export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: { add: string[] },
input?: {
add: {
name: string
version?: string
}[]
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
@@ -137,17 +142,18 @@ export const layer = Layer.effect(
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!canWrite) return
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add: input?.add, dir })
yield* reify({ add, dir })
return
}
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
@@ -163,7 +169,7 @@ export const layer = Layer.effect(
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []),
...(input?.add || []).map((pkg) => pkg.name),
])
const root = lockAny?.packages?.[""] || {}
@@ -176,7 +182,7 @@ export const layer = Layer.effect(
for (const name of declared) {
if (!locked.has(name)) {
yield* reify({ dir, add: input?.add })
yield* reify({ dir, add })
return
}
}
@@ -1,6 +1,5 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { iife } from "@/util/iife"
import { Log } from "../../util"
@@ -335,6 +334,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
if (incoming.model.api.id.includes("gpt")) {
output.maxOutputTokens = undefined
}
// GitHub Copilot's /v1/messages shim rejects the GA `eager_input_streaming`
// field on tool definitions ("Extra inputs are not permitted"). Opt out of
// the @ai-sdk/anthropic default so it stops injecting the field.
if (incoming.model.api.npm === "@ai-sdk/anthropic") {
output.options.toolStreaming = false
}
},
"chat.headers": async (incoming, output) => {
if (!incoming.model.providerID.includes("github-copilot")) return
@@ -10,6 +10,11 @@ export const schema = z.object({
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
policy: z
.object({
state: z.string().optional(),
})
.optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
@@ -122,7 +127,9 @@ export async function get(
})
const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
const remote = new Map(
data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
)
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
+1 -1
View File
@@ -96,7 +96,7 @@ export const Instance = {
if (AppFileSystem.contains(instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false
if (instance.worktree === "/") return false
return AppFileSystem.contains(instance.worktree, filepath)
},
/**
@@ -4,18 +4,44 @@ import { AppRuntime } from "@/effect/app-runtime"
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
// Build the base span attributes for an HTTP handler: method, path, and every
// matched route param. Names follow OTel attribute-naming guidance:
// domain-first (`session.id`, `message.id`, …) so they match the existing
// OTel `session.id` semantic convention and the bare `message.id` we
// already emit from Tool.execute. Non-standard route params fall back to
// `opencode.<name>` since those are internal implementation details
// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
export interface RequestLike {
readonly req: {
readonly method: string
readonly url: string
param(): Record<string, string>
}
}
// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
// other param is namespaced under `opencode.` to avoid colliding with
// standard conventions.
export function paramToAttributeKey(key: string): string {
const m = key.match(/^(.+)ID$/)
if (m) return `${m[1].toLowerCase()}.id`
return `opencode.${key}`
}
export function requestAttributes(c: RequestLike): Record<string, string> {
const attributes: Record<string, string> = {
"http.method": c.req.method,
"http.path": new URL(c.req.url).pathname,
}
for (const [key, value] of Object.entries(c.req.param())) {
attributes[paramToAttributeKey(key)] = value
}
return attributes
}
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
const url = new URL(c.req.url)
return AppRuntime.runPromise(
effect.pipe(
Effect.withSpan(name, {
attributes: {
"http.method": c.req.method,
"http.path": url.pathname,
},
}),
),
)
return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
}
export async function jsonRequest<C extends Context, A, E>(
+14 -2
View File
@@ -74,7 +74,7 @@ const live: Layer.Layer<
.clone()
.tag("providerID", input.model.providerID)
.tag("modelID", input.model.id)
.tag("sessionID", input.sessionID)
.tag("session.id", input.sessionID)
.tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name)
.tag("mode", input.agent.mode)
@@ -317,6 +317,18 @@ const live: Layer.Layer<
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
const telemetryTracer = tracer
? new Proxy(tracer, {
get(target, prop, receiver) {
if (prop !== "startSpan") return Reflect.get(target, prop, receiver)
return (...args: Parameters<typeof target.startSpan>) => {
const span = target.startSpan(...args)
span.setAttribute("session.id", input.sessionID)
return span
}
},
})
: undefined
return streamText({
onError(error) {
@@ -390,7 +402,7 @@ const live: Layer.Layer<
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer,
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
+1 -1
View File
@@ -123,7 +123,7 @@ export const layer: Layer.Layer<
reasoningMap: {},
}
let aborted = false
const slog = log.clone().tag("sessionID", input.sessionID).tag("messageID", input.assistantMessage.id)
const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
const parse = (e: unknown) =>
MessageV2.fromError(e, {
+246 -21
View File
@@ -1,4 +1,4 @@
import { Schema, SchemaAST } from "effect"
import { Effect, Option, Schema, SchemaAST } from "effect"
import z from "zod"
/**
@@ -8,36 +8,216 @@ import z from "zod"
*/
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
/**
* Annotation key for a pre-parse transform that runs on the raw input before
* the derived Zod schema validates it. The walker emits
* `z.preprocess(fn, inner)` when this annotation is present.
*
* Models zod's `z.preprocess(fn, schema)` pattern useful when the schema
* needs to inspect the user's raw input (e.g. to capture insertion order)
* before `Schema.Struct` canonicalises the object.
*
* TODO: This exists to paper over a missing Effect Schema feature. The
* parser canonicalises open struct output (known fields first in
* declaration order, then catchall fields) before any user-defined
* transform sees the value, and there is no pre-parse hook so the
* user's original property insertion order is gone by the time
* `Schema.decodeTo` or `middlewareDecoding` runs.
*
* That canonicalisation is a reasonable default, but `config/permission.ts`
* encodes rule precedence in the user's JSON key order (`evaluate.ts`
* uses `findLast`, so later entries win), which the canonicalisation
* silently destroys.
*
* The cleanest upstream fix would be either:
*
* 1. A `preserveInputOrder` option on `Schema.Struct` /
* `Schema.StructWithRest` that keeps the input's insertion order in
* the parsed object (opt-in; canonical order stays default).
* 2. A generic pre-parse hook (`Schema.preprocess(schema, fn)` or a
* transformation whose decode receives the raw `unknown`).
*
* Either of those would let us delete `ZodPreprocess` and the
* `__originalKeys` hack. Alternatively, the permission model could move
* to specificity-based precedence (exact keys beat wildcards) or an
* explicit ordered array of rules, which removes the ordering
* dependency at the data-model level.
*/
export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess")
// AST nodes are immutable and frequently shared across schemas (e.g. a single
// Schema.Class embedded in multiple parents). Memoizing by node identity
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
// by reference across callers.
const walkCache = new WeakMap<SchemaAST.AST, z.ZodTypeAny>()
// Shared empty ParseOptions for the rare callers that need one — avoids
// allocating a fresh object per parse inside refinements and transforms.
const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
}
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
const cached = walkCache.get(ast)
if (cached) return cached
const result = walkUncached(ast)
walkCache.set(ast, result)
return result
}
function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
if (override) return override
let out = body(ast)
for (const check of ast.checks ?? []) {
out = applyCheck(out, check, ast)
}
// Schema.Class wraps its fields in a Declaration AST plus an encoding that
// constructs the class instance. For the Zod derivation we want the plain
// field shape (the decoded/consumer view), not the class instance — so
// Declarations fall through to body(), not encoded(). User-level
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
// nodes, where we do apply the transform.
//
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
// on the inner Zod rather than a transform wrapper — so optional ASTs whose
// encoding resolves a default from Option.none() route through body()/opt().
const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
const base = hasTransform ? encoded(ast) : body(ast)
const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess]
const out = preprocess ? z.preprocess(preprocess, checked) : checked
const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast)
const next = desc ? out.describe(desc) : out
return ref ? next.meta({ ref }) : next
const described = desc ? out.describe(desc) : out
return ref ? described.meta({ ref }) : described
}
function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check<any>, ast: SchemaAST.AST): z.ZodTypeAny {
if (check._tag === "FilterGroup") {
return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out)
// Walk the encoded side and apply each link's decode to produce the decoded
// shape. A node `Target` produced by `from.decodeTo(Target)` carries
// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls
// nest the encoding via `Link.to` so walking it recursively threads all
// prior transforms — typical encoding.length is 1.
function encoded(ast: SchemaAST.AST): z.ZodTypeAny {
const encoding = ast.encoding!
return encoding.reduce<z.ZodTypeAny>(
(acc, link) => acc.transform((v) => decode(link.transformation, v)),
walk(encoding[0].to),
)
}
// Transformations built via pure `SchemaGetter.transform(fn)` (the common
// decodeTo case) resolve synchronously, so running with no services is safe.
// Effectful / middleware-based transforms will surface as Effect defects.
function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown {
const exit = Effect.runSyncExit(
(transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect<
Option.Option<unknown>
>,
)
if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`)
return Option.getOrElse(exit.value, () => value)
}
// Flatten FilterGroups and any nested variants into a linear list of Filters.
// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are
// translated into native Zod methods so their JSON Schema output includes
// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …).
// Anything else falls back to a single .superRefine layer — runtime-only,
// emits no JSON Schema constraint.
function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
const filters: SchemaAST.Filter<unknown>[] = []
const collect = (c: SchemaAST.Check<unknown>) => {
if (c._tag === "FilterGroup") c.checks.forEach(collect)
else filters.push(c)
}
return out.superRefine((value, ctx) => {
const issue = check.run(value, ast, {} as any)
if (!issue) return
const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed"
ctx.addIssue({ code: "custom", message })
checks.forEach(collect)
const unhandled: SchemaAST.Filter<unknown>[] = []
const translated = filters.reduce<z.ZodTypeAny>((acc, filter) => {
const next = translateFilter(acc, filter)
if (next) return next
unhandled.push(filter)
return acc
}, out)
if (unhandled.length === 0) return translated
return translated.superRefine((value, ctx) => {
for (const filter of unhandled) {
const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
if (!issue) continue
const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
ctx.addIssue({ code: "custom", message })
}
})
}
// Translate a well-known Effect Schema filter into a native Zod method call on
// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every
// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at
// construction time. Returns `undefined` for unrecognised filters so the
// caller can fall back to the generic .superRefine path.
function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter<unknown>): z.ZodTypeAny | undefined {
const meta = (filter.annotations as { meta?: Record<string, unknown> } | undefined)?.meta
if (!meta || typeof meta._tag !== "string") return undefined
switch (meta._tag) {
case "isInt":
return call(out, "int")
case "isFinite":
return call(out, "finite")
case "isGreaterThan":
return call(out, "gt", meta.exclusiveMinimum)
case "isGreaterThanOrEqualTo":
return call(out, "gte", meta.minimum)
case "isLessThan":
return call(out, "lt", meta.exclusiveMaximum)
case "isLessThanOrEqualTo":
return call(out, "lte", meta.maximum)
case "isBetween": {
const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum)
if (!lo) return undefined
return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum)
}
case "isMultipleOf":
return call(out, "multipleOf", meta.divisor)
case "isMinLength":
return call(out, "min", meta.minLength)
case "isMaxLength":
return call(out, "max", meta.maxLength)
case "isLengthBetween": {
const lo = call(out, "min", meta.minimum)
if (!lo) return undefined
return call(lo, "max", meta.maximum)
}
case "isPattern":
return call(out, "regex", meta.regExp)
case "isStartsWith":
return call(out, "startsWith", meta.startsWith)
case "isEndsWith":
return call(out, "endsWith", meta.endsWith)
case "isIncludes":
return call(out, "includes", meta.includes)
case "isUUID":
return call(out, "uuid")
case "isULID":
return call(out, "ulid")
case "isBase64":
return call(out, "base64")
case "isBase64Url":
return call(out, "base64url")
}
return undefined
}
// Invoke a named Zod method on `target` if it exists, otherwise return
// undefined so the caller can fall back. Using this helper instead of a
// typed cast keeps `translateFilter` free of per-case narrowing noise.
function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined {
const fn = (target as unknown as Record<string, ((...a: unknown[]) => z.ZodTypeAny) | undefined>)[method]
return typeof fn === "function" ? fn.apply(target, args) : undefined
}
function issueMessage(issue: any): string | undefined {
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
if (typeof issue?.message === "string") return issue.message
@@ -81,10 +261,43 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
if (ast._tag !== "Union") return fail(ast)
const items = ast.types.filter((item) => item._tag !== "Undefined")
if (items.length === 1) return walk(items[0]).optional()
if (items.length > 1)
return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
return z.undefined().optional()
const inner =
items.length === 1
? walk(items[0])
: items.length > 1
? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
: z.undefined()
// Schema.withDecodingDefault attaches an encoding `Link` whose transformation
// decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
// it to extract the default and emit `.default(...)` instead of `.optional()`.
const fallback = extractDefault(ast)
if (fallback !== undefined) return inner.default(fallback.value)
return inner.optional()
}
type DecodeLink = {
readonly transformation: {
readonly decode: {
readonly run: (
input: Option.Option<unknown>,
options: SchemaAST.ParseOptions,
) => Effect.Effect<Option.Option<unknown>, unknown>
}
}
}
function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).encoding
if (!encoding?.length) return undefined
// Walk the chain of encoding Links in order; the first Getter that produces
// a value from Option.none wins. withDecodingDefault always puts its
// defaulting Link adjacent to the optional Union.
for (const link of encoding) {
const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {}))
if (probe._tag !== "Success") continue
if (Option.isSome(probe.value)) return { value: probe.value.value }
}
return undefined
}
function union(ast: SchemaAST.Union): z.ZodTypeAny {
@@ -107,15 +320,27 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
}
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
// Pure record: { [k: string]: V }
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast)
return z.record(z.string(), walk(sig.type))
}
if (ast.indexSignatures.length > 0) return fail(ast)
// Pure object with known fields and no index signatures.
if (ast.indexSignatures.length === 0) {
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
}
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
// Struct with a catchall (StructWithRest): known fields + index signature.
// Only supports a single string-keyed index signature; multi-signature or
// symbol/number keys fall through to fail.
if (ast.indexSignatures.length !== 1) return fail(ast)
const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast)
return z
.object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
.catchall(walk(sig.type))
}
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
+80 -79
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { SessionEvent } from "./session-event"
import { produce } from "immer"
import { castDraft, produce } from "immer"
export const ID = SessionEvent.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -70,7 +70,9 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
Schema.toTaggedUnion("status"),
)
export type ToolState = Schema.Schema.Type<typeof ToolState>
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
@@ -96,7 +98,9 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
text: Schema.String,
}) {}
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool])
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
Schema.toTaggedUnion("type"),
)
export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
@@ -126,7 +130,7 @@ export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compacti
...Base,
}) {}
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction])
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type"))
export type Entry = Schema.Schema.Type<typeof Entry>
@@ -141,19 +145,29 @@ export function step(old: History, event: SessionEvent.Event): History {
return produce(old, (draft) => {
const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
type DraftContent = NonNullable<typeof pendingAssistant>["content"][number]
type DraftTool = Extract<DraftContent, { type: "tool" }>
switch (event.type) {
case "prompt": {
const latestTool = (callID?: string) =>
pendingAssistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID),
)
const latestText = () => pendingAssistant?.content.findLast((item) => item.type === "text")
const latestReasoning = () => pendingAssistant?.content.findLast((item) => item.type === "reasoning")
SessionEvent.Event.match(event, {
prompt: (event) => {
const entry = User.fromEvent(event)
if (pendingAssistant) {
// @ts-expect-error
draft.pending.push(User.fromEvent(event))
break
draft.pending.push(castDraft(entry))
return
}
// @ts-expect-error
draft.entries.push(User.fromEvent(event))
break
}
case "step.started": {
draft.entries.push(castDraft(entry))
},
synthetic: (event) => {
draft.entries.push(new Synthetic({ ...event, time: { created: event.timestamp } }))
},
"step.started": (event) => {
if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
draft.entries.push({
id: event.id,
@@ -163,27 +177,28 @@ export function step(old: History, event: SessionEvent.Event): History {
},
content: [],
})
break
}
case "text.started": {
if (!pendingAssistant) break
},
"step.ended": (event) => {
if (!pendingAssistant) return
pendingAssistant.time.completed = event.timestamp
pendingAssistant.cost = event.cost
pendingAssistant.tokens = event.tokens
},
"text.started": () => {
if (!pendingAssistant) return
pendingAssistant.content.push({
type: "text",
text: "",
})
break
}
case "text.delta": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "text")
},
"text.delta": (event) => {
if (!pendingAssistant) return
const match = latestText()
if (match) match.text += event.delta
break
}
case "text.ended": {
break
}
case "tool.input.started": {
if (!pendingAssistant) break
},
"text.ended": () => {},
"tool.input.started": (event) => {
if (!pendingAssistant) return
pendingAssistant.content.push({
type: "tool",
callID: event.callID,
@@ -196,21 +211,17 @@ export function step(old: History, event: SessionEvent.Event): History {
input: "",
},
})
break
}
case "tool.input.delta": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
},
"tool.input.delta": (event) => {
if (!pendingAssistant) return
const match = latestTool(event.callID)
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
if (match) match.state.input += event.delta
break
}
case "tool.input.ended": {
break
}
case "tool.called": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
},
"tool.input.ended": () => {},
"tool.called": (event) => {
if (!pendingAssistant) return
const match = latestTool(event.callID)
if (match) {
match.time.ran = event.timestamp
match.state = {
@@ -218,11 +229,10 @@ export function step(old: History, event: SessionEvent.Event): History {
input: event.input,
}
}
break
}
case "tool.success": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
},
"tool.success": (event) => {
if (!pendingAssistant) return
const match = latestTool(event.callID)
if (match && match.state.status === "running") {
match.state = {
status: "completed",
@@ -230,15 +240,13 @@ export function step(old: History, event: SessionEvent.Event): History {
output: event.output ?? "",
title: event.title,
metadata: event.metadata ?? {},
// @ts-expect-error
attachments: event.attachments ?? [],
attachments: [...(event.attachments ?? [])],
}
}
break
}
case "tool.error": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
},
"tool.error": (event) => {
if (!pendingAssistant) return
const match = latestTool(event.callID)
if (match && match.state.status === "running") {
match.state = {
status: "error",
@@ -247,36 +255,29 @@ export function step(old: History, event: SessionEvent.Event): History {
metadata: event.metadata ?? {},
}
}
break
}
case "reasoning.started": {
if (!pendingAssistant) break
},
"reasoning.started": () => {
if (!pendingAssistant) return
pendingAssistant.content.push({
type: "reasoning",
text: "",
})
break
}
case "reasoning.delta": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
},
"reasoning.delta": (event) => {
if (!pendingAssistant) return
const match = latestReasoning()
if (match) match.text += event.delta
break
}
case "reasoning.ended": {
if (!pendingAssistant) break
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
},
"reasoning.ended": (event) => {
if (!pendingAssistant) return
const match = latestReasoning()
if (match) match.text = event.text
break
}
case "step.ended": {
if (!pendingAssistant) break
pendingAssistant.time.completed = event.timestamp
pendingAssistant.cost = event.cost
pendingAssistant.tokens = event.tokens
break
}
}
},
retried: () => {},
compacted: (event) => {
draft.entries.push(new Compaction({ ...event, type: "compaction", time: { created: event.timestamp } }))
},
})
})
}
+1 -1
View File
@@ -441,7 +441,7 @@ export namespace SessionEvent {
{
mode: "oneOf",
},
)
).pipe(Schema.toTaggedUnion("type"))
export type Event = Schema.Schema.Type<typeof Event>
export type Type = Event["type"]
}
@@ -31,6 +31,7 @@ describe("LSPClient interop", () => {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
}),
})
@@ -55,6 +56,7 @@ describe("LSPClient interop", () => {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
}),
})
@@ -79,6 +81,7 @@ describe("LSPClient interop", () => {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
}),
})
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test"
import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace"
function fakeContext(method: string, url: string, params: Record<string, string>) {
return {
req: {
method,
url,
param: () => params,
},
}
}
describe("paramToAttributeKey", () => {
test("converts fooID to foo.id", () => {
expect(paramToAttributeKey("sessionID")).toBe("session.id")
expect(paramToAttributeKey("messageID")).toBe("message.id")
expect(paramToAttributeKey("partID")).toBe("part.id")
expect(paramToAttributeKey("projectID")).toBe("project.id")
expect(paramToAttributeKey("providerID")).toBe("provider.id")
expect(paramToAttributeKey("ptyID")).toBe("pty.id")
expect(paramToAttributeKey("permissionID")).toBe("permission.id")
expect(paramToAttributeKey("requestID")).toBe("request.id")
expect(paramToAttributeKey("workspaceID")).toBe("workspace.id")
})
test("namespaces non-ID params under opencode.", () => {
expect(paramToAttributeKey("name")).toBe("opencode.name")
expect(paramToAttributeKey("slug")).toBe("opencode.slug")
})
})
describe("requestAttributes", () => {
test("includes http method and path", () => {
const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {}))
expect(attrs["http.method"]).toBe("GET")
expect(attrs["http.path"]).toBe("/session")
})
test("strips query string from path", () => {
const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {}))
expect(attrs["http.path"]).toBe("/file/search")
})
test("emits OTel-style <domain>.id for ID-shaped route params", () => {
const attrs = requestAttributes(
fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", {
sessionID: "ses_abc",
messageID: "msg_def",
partID: "prt_ghi",
}),
)
expect(attrs["session.id"]).toBe("ses_abc")
expect(attrs["message.id"]).toBe("msg_def")
expect(attrs["part.id"]).toBe("prt_ghi")
// No camelCase leftovers:
expect(attrs["opencode.sessionID"]).toBeUndefined()
expect(attrs["opencode.messageID"]).toBeUndefined()
expect(attrs["opencode.partID"]).toBeUndefined()
})
test("produces no param attributes when no params are matched", () => {
const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {}))
expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([])
})
test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => {
const attrs = requestAttributes(
fakeContext("POST", "http://localhost/mcp/exa/connect", {
name: "exa",
}),
)
expect(attrs["opencode.name"]).toBe("exa")
expect(attrs["name"]).toBeUndefined()
})
})
@@ -591,7 +591,64 @@ describe("session-entry step", () => {
)
})
test.failing("records synthetic events", () => {
test("routes tool events by callID when tool streams interleave", () => {
FastCheck.assert(
FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
input: a,
provider: { executed: true },
timestamp: time(5),
}),
SessionEvent.Tool.Called.create({
callID: "b",
tool: "grep",
input: b,
provider: { executed: true },
timestamp: time(6),
}),
SessionEvent.Tool.Success.create({
callID: "a",
title: titleA,
output: "done-a",
provider: { executed: true },
timestamp: time(7),
}),
SessionEvent.Tool.Success.create({
callID: "b",
title: titleB,
output: "done-b",
provider: { executed: true },
timestamp: time(8),
}),
],
active(),
)
const first = tool(next, "a")
const second = tool(next, "b")
expect(first?.state.status).toBe("completed")
expect(second?.state.status).toBe("completed")
if (first?.state.status !== "completed" || second?.state.status !== "completed") return
expect(first.state.input).toEqual(a)
expect(second.state.input).toEqual(b)
expect(first.state.title).toBe(titleA)
expect(second.state.title).toBe(titleB)
}),
{ numRuns: 50 },
)
})
test("records synthetic events", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
@@ -604,7 +661,7 @@ describe("session-entry step", () => {
)
})
test.failing("records compaction events", () => {
test("records compaction events", () => {
FastCheck.assert(
FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
const next = SessionEntry.step(
+605 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Effect, Schema, SchemaGetter } from "effect"
import z from "zod"
import { zod, ZodOverride } from "../../src/util/effect-zod"
import { zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod"
function json(schema: z.ZodTypeAny) {
const { $schema: _, ...rest } = z.toJSONSchema(schema)
@@ -263,4 +263,607 @@ describe("util.effect-zod", () => {
expect(result.error!.issues[0].message).toBe("missing 'required' key")
})
})
describe("StructWithRest / catchall", () => {
test("struct with a string-keyed record rest parses known AND extra keys", () => {
const schema = zod(
Schema.StructWithRest(
Schema.Struct({
apiKey: Schema.optional(Schema.String),
baseURL: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
)
// Known fields come through as declared
expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" })
// Extra keys are preserved (catchall)
expect(
schema.parse({
apiKey: "sk-x",
baseURL: "https://api.example.com",
customField: "anything",
nested: { foo: 1 },
}),
).toEqual({
apiKey: "sk-x",
baseURL: "https://api.example.com",
customField: "anything",
nested: { foo: 1 },
})
})
test("catchall value type constrains the extras", () => {
const schema = zod(
Schema.StructWithRest(
Schema.Struct({
count: Schema.Number,
}),
[Schema.Record(Schema.String, Schema.Number)],
),
)
// Known field + numeric extras
expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 })
// Non-numeric extra is rejected
expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false)
})
test("JSON schema output marks additionalProperties appropriately", () => {
const schema = zod(
Schema.StructWithRest(
Schema.Struct({
id: Schema.String,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
)
const shape = json(schema) as { additionalProperties?: unknown }
// Presence of `additionalProperties` (truthy or a schema) signals catchall.
expect(shape.additionalProperties).not.toBe(false)
expect(shape.additionalProperties).toBeDefined()
})
test("plain struct without rest still emits additionalProperties unchanged (regression)", () => {
const schema = zod(Schema.Struct({ id: Schema.String }))
expect(schema.parse({ id: "x" })).toEqual({ id: "x" })
})
})
describe("transforms (Schema.decodeTo)", () => {
test("Number -> pseudo-Duration (seconds) applies the decode function", () => {
// Models the account/account.ts DurationFromSeconds pattern.
const SecondsToMs = Schema.Number.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((n: number) => n * 1000),
encode: SchemaGetter.transform((ms: number) => ms / 1000),
}),
)
const schema = zod(SecondsToMs)
expect(schema.parse(3)).toBe(3000)
expect(schema.parse(0)).toBe(0)
})
test("String -> Number via parseInt decode", () => {
const ParsedInt = Schema.String.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
encode: SchemaGetter.transform((n: number) => String(n)),
}),
)
const schema = zod(ParsedInt)
expect(schema.parse("42")).toBe(42)
expect(schema.parse("0")).toBe(0)
})
test("transform inside a struct field applies per-field", () => {
const Field = Schema.Number.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((n: number) => n + 1),
encode: SchemaGetter.transform((n: number) => n - 1),
}),
)
const schema = zod(
Schema.Struct({
plain: Schema.Number,
bumped: Field,
}),
)
expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 })
})
test("chained decodeTo composes transforms in order", () => {
// String -> Number (parseInt) -> Number (doubled).
// Exercises the encoded() reduce, not just a single link.
const Chained = Schema.String.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
encode: SchemaGetter.transform((n: number) => String(n)),
}),
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transform((n: number) => n * 2),
encode: SchemaGetter.transform((n: number) => n / 2),
}),
)
const schema = zod(Chained)
expect(schema.parse("21")).toBe(42)
expect(schema.parse("0")).toBe(0)
})
test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => {
// Schema.Class uses Declaration + encoding under the hood to construct
// class instances. The walker must NOT apply that transform, or zod
// parsing would return class instances instead of plain objects.
class Method extends Schema.Class<Method>("TxTestMethod")({
type: Schema.String,
value: Schema.Number,
}) {}
const schema = zod(Method)
const parsed = schema.parse({ type: "oauth", value: 1 })
expect(parsed).toEqual({ type: "oauth", value: 1 })
// Guardrail: ensure we didn't get back a Method instance.
expect(parsed).not.toBeInstanceOf(Method)
})
})
describe("optimizations", () => {
test("walk() memoizes by AST identity — same AST node returns same Zod", () => {
const shared = Schema.Struct({ id: Schema.String, name: Schema.String })
const left = zod(shared)
const right = zod(shared)
expect(left).toBe(right)
})
test("nested reuse of the same AST reuses the cached Zod child", () => {
// Two different parents embed the same inner schema. The inner zod
// child should be identical by reference inside both parents.
class Inner extends Schema.Class<Inner>("MemoTestInner")({
value: Schema.String,
}) {}
class OuterA extends Schema.Class<OuterA>("MemoTestOuterA")({
inner: Inner,
}) {}
class OuterB extends Schema.Class<OuterB>("MemoTestOuterB")({
inner: Inner,
}) {}
const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.()
const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.()
expect(shapeA.inner).toBe(shapeB.inner)
})
test("multiple checks run in a single refinement layer (all fire on one value)", () => {
// Three checks attached to the same schema. All three must run and
// report — asserting that no check silently got dropped when we
// flattened into one superRefine.
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
const schema = zod(Schema.Number.check(positive).check(even).check(under100))
const neg = schema.safeParse(-3)
expect(neg.success).toBe(false)
expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
const big = schema.safeParse(101)
expect(big.success).toBe(false)
expect(big.error!.issues.map((i) => i.message)).toContain("too big")
// Passing value satisfies all three
expect(schema.parse(42)).toBe(42)
})
test("FilterGroup flattens into the single refinement layer alongside its siblings", () => {
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
const group = Schema.makeFilterGroup([positive, even])
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
const schema = zod(Schema.Number.check(group).check(under100))
const bad = schema.safeParse(-3)
expect(bad.success).toBe(false)
expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
})
})
describe("well-known refinement translation", () => {
test("Schema.isInt emits type: integer in JSON Schema", () => {
const schema = zod(Schema.Number.check(Schema.isInt()))
const native = json(z.number().int())
expect(json(schema)).toEqual(native)
expect(schema.parse(3)).toBe(3)
expect(schema.safeParse(1.5).success).toBe(false)
})
test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => {
const schema = zod(Schema.Number.check(Schema.isGreaterThan(0)))
expect((json(schema) as any).exclusiveMinimum).toBe(0)
expect(schema.parse(1)).toBe(1)
expect(schema.safeParse(0).success).toBe(false)
expect(schema.safeParse(-1).success).toBe(false)
})
test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => {
const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)))
expect((json(schema) as any).minimum).toBe(0)
expect(schema.parse(0)).toBe(0)
expect(schema.safeParse(-1).success).toBe(false)
})
test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => {
const schema = zod(Schema.Number.check(Schema.isLessThan(10)))
expect((json(schema) as any).exclusiveMaximum).toBe(10)
expect(schema.parse(9)).toBe(9)
expect(schema.safeParse(10).success).toBe(false)
})
test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => {
const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10)))
expect((json(schema) as any).maximum).toBe(10)
expect(schema.parse(10)).toBe(10)
expect(schema.safeParse(11).success).toBe(false)
})
test("Schema.isMultipleOf(5) emits multipleOf: 5", () => {
const schema = zod(Schema.Number.check(Schema.isMultipleOf(5)))
expect((json(schema) as any).multipleOf).toBe(5)
expect(schema.parse(10)).toBe(10)
expect(schema.safeParse(7).success).toBe(false)
})
test("Schema.isFinite validates at runtime", () => {
const schema = zod(Schema.Number.check(Schema.isFinite()))
expect(schema.parse(1)).toBe(1)
expect(schema.safeParse(Infinity).success).toBe(false)
expect(schema.safeParse(NaN).success).toBe(false)
})
test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => {
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
const native = json(z.number().int().positive())
expect(json(schema)).toEqual(native)
expect(schema.parse(3)).toBe(3)
expect(schema.safeParse(0).success).toBe(false)
expect(schema.safeParse(1.5).success).toBe(false)
})
test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => {
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)))
const native = json(z.number().int().min(0))
expect(json(schema)).toEqual(native)
expect(schema.parse(0)).toBe(0)
expect(schema.safeParse(-1).success).toBe(false)
})
test("Schema.isBetween emits both bounds", () => {
const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 })))
const shape = json(schema) as any
expect(shape.minimum).toBe(1)
expect(shape.maximum).toBe(10)
expect(schema.parse(5)).toBe(5)
expect(schema.safeParse(11).success).toBe(false)
expect(schema.safeParse(0).success).toBe(false)
})
test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => {
const schema = zod(
Schema.Number.check(
Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }),
),
)
const shape = json(schema) as any
expect(shape.exclusiveMinimum).toBe(1)
expect(shape.exclusiveMaximum).toBe(10)
expect(schema.parse(5)).toBe(5)
expect(schema.safeParse(1).success).toBe(false)
expect(schema.safeParse(10).success).toBe(false)
})
test("Schema.isInt32 (FilterGroup) produces integer bounds", () => {
const schema = zod(Schema.Number.check(Schema.isInt32()))
const shape = json(schema) as any
expect(shape.type).toBe("integer")
expect(shape.minimum).toBe(-2147483648)
expect(shape.maximum).toBe(2147483647)
expect(schema.parse(42)).toBe(42)
expect(schema.safeParse(1.5).success).toBe(false)
expect(schema.safeParse(2147483648).success).toBe(false)
})
test("Schema.isMinLength on string emits minLength", () => {
const schema = zod(Schema.String.check(Schema.isMinLength(3)))
expect((json(schema) as any).minLength).toBe(3)
expect(schema.parse("abc")).toBe("abc")
expect(schema.safeParse("ab").success).toBe(false)
})
test("Schema.isMaxLength on string emits maxLength", () => {
const schema = zod(Schema.String.check(Schema.isMaxLength(5)))
expect((json(schema) as any).maxLength).toBe(5)
expect(schema.parse("abcde")).toBe("abcde")
expect(schema.safeParse("abcdef").success).toBe(false)
})
test("Schema.isLengthBetween on string emits both bounds", () => {
const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4)))
const shape = json(schema) as any
expect(shape.minLength).toBe(2)
expect(shape.maxLength).toBe(4)
expect(schema.parse("abc")).toBe("abc")
expect(schema.safeParse("a").success).toBe(false)
expect(schema.safeParse("abcde").success).toBe(false)
})
test("Schema.isMinLength on array emits minItems", () => {
const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1)))
expect((json(schema) as any).minItems).toBe(1)
expect(schema.parse(["x"])).toEqual(["x"])
expect(schema.safeParse([]).success).toBe(false)
})
test("Schema.isPattern emits pattern", () => {
const schema = zod(Schema.String.check(Schema.isPattern(/^per/)))
expect((json(schema) as any).pattern).toBe("^per")
expect(schema.parse("per_abc")).toBe("per_abc")
expect(schema.safeParse("abc").success).toBe(false)
})
test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => {
const schema = zod(Schema.String.check(Schema.isStartsWith("per")))
const native = json(z.string().startsWith("per"))
expect(json(schema)).toEqual(native)
expect(schema.parse("per_abc")).toBe("per_abc")
expect(schema.safeParse("abc").success).toBe(false)
})
test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => {
const schema = zod(Schema.String.check(Schema.isEndsWith(".json")))
const native = json(z.string().endsWith(".json"))
expect(json(schema)).toEqual(native)
expect(schema.parse("a.json")).toBe("a.json")
expect(schema.safeParse("a.txt").success).toBe(false)
})
test("Schema.isUUID emits format: uuid", () => {
const schema = zod(Schema.String.check(Schema.isUUID()))
expect((json(schema) as any).format).toBe("uuid")
})
test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => {
// isInt is well-known (translates to .int()); the anonymous filter falls
// back to superRefine.
const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed"))
const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven))
const shape = json(schema) as any
// Well-known translation is preserved — type is integer, not plain number
expect(shape.type).toBe("integer")
// Runtime: both constraints fire
expect(schema.parse(3)).toBe(3)
expect(schema.safeParse(1.5).success).toBe(false)
const seven = schema.safeParse(7)
expect(seven.success).toBe(false)
expect(seven.error!.issues[0].message).toBe("no sevens allowed")
})
test("inside a struct field, well-known refinements propagate through", () => {
// Mirrors config.ts port: z.number().int().positive().optional()
const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
const schema = zod(Schema.Struct({ port: Port }))
const shape = json(schema) as any
expect(shape.properties.port.type).toBe("integer")
expect(shape.properties.port.exclusiveMinimum).toBe(0)
})
})
describe("Schema.optionalWith defaults", () => {
test("parsing undefined returns the default value", () => {
const schema = zod(
Schema.Struct({
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
}),
)
expect(schema.parse({})).toEqual({ mode: "ctrl-x" })
expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" })
})
test("parsing a real value returns that value (default does not fire)", () => {
const schema = zod(
Schema.Struct({
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
}),
)
expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" })
})
test("default on a number field", () => {
const schema = zod(
Schema.Struct({
count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))),
}),
)
expect(schema.parse({})).toEqual({ count: 42 })
expect(schema.parse({ count: 7 })).toEqual({ count: 7 })
})
test("multiple defaulted fields inside a struct", () => {
const schema = zod(
Schema.Struct({
leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))),
inner: Schema.String,
}),
)
expect(schema.parse({ inner: "hi" })).toEqual({
leader: "ctrl-x",
quit: "ctrl-c",
inner: "hi",
})
expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({
leader: "a",
quit: "b",
inner: "c",
})
})
test("JSON Schema output includes the default key", () => {
const schema = zod(
Schema.Struct({
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
}),
)
const shape = json(schema) as any
expect(shape.properties.mode.default).toBe("ctrl-x")
})
test("default referencing a computed value resolves when evaluated", () => {
// Simulates `keybinds.ts` style of per-platform defaults: the default is
// produced by an Effect that computes a value at decode time.
const platform = "darwin"
const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k"
const schema = zod(
Schema.Struct({
command_palette: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.sync(() => fallback))),
}),
)
expect(schema.parse({})).toEqual({ command_palette: "cmd-k" })
const shape = json(schema) as any
expect(shape.properties.command_palette.default).toBe("cmd-k")
})
test("plain Schema.optional (no default) still emits .optional() (regression)", () => {
const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) }))
expect(schema.parse({})).toEqual({})
expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
})
})
describe("ZodPreprocess annotation", () => {
test("preprocess runs on raw input before the inner schema parses", () => {
// Models the permission.ts __originalKeys pattern: capture the original
// insertion order of a user-provided object BEFORE Schema parsing
// canonicalises the keys.
const preprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
}
return val
}
const Inner = Schema.Struct({
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
a: Schema.optional(Schema.String),
b: Schema.optional(Schema.String),
}).annotate({ [ZodPreprocess]: preprocess })
const schema = zod(Inner)
const parsed = schema.parse({ b: "1", a: "2" }) as {
__keys?: string[]
a?: string
b?: string
}
expect(parsed.__keys).toEqual(["b", "a"])
expect(parsed.a).toBe("2")
expect(parsed.b).toBe("1")
})
test("preprocess does not transform already-shaped input", () => {
// When the user passes an object that already has __keys, preprocess
// returns it unchanged because spreading preserves any existing key.
const preprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !("__keys" in val)) {
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
}
return val
}
const Inner = Schema.Struct({
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
a: Schema.optional(Schema.String),
}).annotate({ [ZodPreprocess]: preprocess })
const schema = zod(Inner)
const parsed = schema.parse({ __keys: ["existing"], a: "hi" }) as {
__keys?: string[]
a?: string
}
expect(parsed.__keys).toEqual(["existing"])
})
test("preprocess composes with a union (either object or string)", () => {
// Mirrors permission.ts exactly: input can be either an object (with
// preprocess injecting metadata) or a plain string action.
const Action = Schema.Literals(["ask", "allow", "deny"])
const Obj = Schema.Struct({
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
read: Schema.optional(Action),
write: Schema.optional(Action),
})
const preprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
}
return val
}
const Inner = Schema.Union([Obj, Action]).annotate({ [ZodPreprocess]: preprocess })
const schema = zod(Inner)
// String branch — passes through preprocess unchanged
expect(schema.parse("allow")).toBe("allow")
// Object branch — __keys injected, preserves order
const parsed = schema.parse({ write: "allow", read: "deny" }) as {
__keys?: string[]
read?: string
write?: string
}
expect(parsed.__keys).toEqual(["write", "read"])
expect(parsed.write).toBe("allow")
expect(parsed.read).toBe("deny")
})
test("JSON Schema output comes from the inner schema — preprocess is runtime-only", () => {
const Inner = Schema.Struct({
a: Schema.optional(Schema.String),
b: Schema.optional(Schema.Number),
}).annotate({ [ZodPreprocess]: (v: unknown) => v })
const shape = json(zod(Inner)) as any
expect(shape.type).toBe("object")
expect(shape.properties.a.type).toBe("string")
expect(shape.properties.b.type).toBe("number")
})
test("identifier + description propagate through the preprocess wrapper", () => {
const Inner = Schema.Struct({
x: Schema.optional(Schema.String),
}).annotate({
identifier: "WithPreproc",
description: "A schema with preprocess",
[ZodPreprocess]: (v: unknown) => v,
})
const schema = zod(Inner)
expect(schema.meta()?.ref).toBe("WithPreproc")
expect(schema.meta()?.description).toBe("A schema with preprocess")
})
test("preprocess inside a struct field applies only to that field", () => {
const Inner = Schema.String.annotate({
[ZodPreprocess]: (v: unknown) => (typeof v === "number" ? String(v) : v),
})
const schema = zod(Schema.Struct({ name: Inner, raw: Schema.Number }))
expect(schema.parse({ name: 42, raw: 7 })).toEqual({ name: "42", raw: 7 })
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.4.10",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.4.10",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
+3 -8
View File
@@ -10949,8 +10949,7 @@
"type": "string"
}
}
},
"additionalProperties": false
}
},
"PermissionActionConfig": {
"type": "string",
@@ -11180,13 +11179,11 @@
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"anyOf": [
{
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
{
"description": "Disable timeout for this provider entirely.",
"type": "boolean",
"const": false
}
@@ -11247,8 +11244,7 @@
"enum": ["reasoning_content", "reasoning_details"]
}
},
"required": ["field"],
"additionalProperties": false
"required": ["field"]
}
]
},
@@ -11377,8 +11373,7 @@
}
}
}
},
"additionalProperties": false
}
},
"McpLocalConfig": {
"type": "object",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.10",
"version": "1.4.11",
"name": "@opencode-ai/shared",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.4.10",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.4.10",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.4.10",
"version": "1.4.11",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
-104
View File
@@ -1,104 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 1c78d6f13..21254c8ca 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -4236,6 +4236,12 @@ var openaiLanguageModelResponsesOptionsSchema = (0, import_provider_utils27.lazy
* Additional metadata to store with the generation.
*/
metadata: import_v422.z.any().nullish(),
+ contextManagement: import_v422.z.array(
+ import_v422.z.object({
+ type: import_v422.z.literal("compaction"),
+ compactThreshold: import_v422.z.number()
+ })
+ ).nullish(),
/**
* Whether to use parallel tool calls. Defaults to `true`.
*/
@@ -4790,6 +4796,12 @@ var OpenAIResponsesLanguageModel = class {
metadata: openaiOptions == null ? void 0 : openaiOptions.metadata,
parallel_tool_calls: openaiOptions == null ? void 0 : openaiOptions.parallelToolCalls,
previous_response_id: openaiOptions == null ? void 0 : openaiOptions.previousResponseId,
+ ...(openaiOptions != null && openaiOptions.contextManagement && {
+ context_management: openaiOptions.contextManagement.map((cm) => ({
+ type: cm.type,
+ compact_threshold: cm.compactThreshold
+ }))
+ }),
store,
user: openaiOptions == null ? void 0 : openaiOptions.user,
instructions: openaiOptions == null ? void 0 : openaiOptions.instructions,
@@ -6759,4 +6771,4 @@ var openai = createOpenAI();
createOpenAI,
openai
});
-//# sourceMappingURL=index.js.map
\ No newline at end of file
+//# sourceMappingURL=index.js.map
diff --git a/dist/index.mjs b/dist/index.mjs
index 3dee8855a..3a0081631 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -4313,6 +4313,12 @@ var openaiLanguageModelResponsesOptionsSchema = lazySchema20(
* Additional metadata to store with the generation.
*/
metadata: z22.any().nullish(),
+ contextManagement: z22.array(
+ z22.object({
+ type: z22.literal("compaction"),
+ compactThreshold: z22.number()
+ })
+ ).nullish(),
/**
* Whether to use parallel tool calls. Defaults to `true`.
*/
@@ -4869,6 +4875,12 @@ var OpenAIResponsesLanguageModel = class {
metadata: openaiOptions == null ? void 0 : openaiOptions.metadata,
parallel_tool_calls: openaiOptions == null ? void 0 : openaiOptions.parallelToolCalls,
previous_response_id: openaiOptions == null ? void 0 : openaiOptions.previousResponseId,
+ ...(openaiOptions != null && openaiOptions.contextManagement && {
+ context_management: openaiOptions.contextManagement.map((cm) => ({
+ type: cm.type,
+ compact_threshold: cm.compactThreshold
+ }))
+ }),
store,
user: openaiOptions == null ? void 0 : openaiOptions.user,
instructions: openaiOptions == null ? void 0 : openaiOptions.instructions,
@@ -6849,4 +6861,4 @@ export {
createOpenAI,
openai
};
-//# sourceMappingURL=index.mjs.map
\ No newline at end of file
+//# sourceMappingURL=index.mjs.map
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 7317931f9..55177ff7b 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -1014,6 +1014,10 @@ declare const openaiLanguageModelResponsesOptionsSchema: _ai_sdk_provider_utils.
logprobs?: number | boolean | undefined;
maxToolCalls?: number | null | undefined;
metadata?: any;
+ contextManagement?: {
+ type: "compaction";
+ compactThreshold: number;
+ }[] | null | undefined;
parallelToolCalls?: boolean | null | undefined;
previousResponseId?: string | null | undefined;
promptCacheKey?: string | null | undefined;
diff --git a/dist/index.d.mts b/dist/index.d.mts
index 7317931f9..55177ff7b 100644
--- a/dist/index.d.mts
+++ b/dist/index.d.mts
@@ -1014,6 +1014,10 @@ declare const openaiLanguageModelResponsesOptionsSchema: _ai_sdk_provider_utils.
logprobs?: number | boolean | undefined;
maxToolCalls?: number | null | undefined;
metadata?: any;
+ contextManagement?: {
+ type: "compaction";
+ compactThreshold: number;
+ }[] | null | undefined;
parallelToolCalls?: boolean | null | undefined;
previousResponseId?: string | null | undefined;
promptCacheKey?: string | null | undefined;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.4.10",
"version": "1.4.11",
"publisher": "sst-dev",
"repository": {
"type": "git",