diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td
index 8bc1d8e2b8..65bda9804a 100644
--- a/.github/VOUCHED.td
+++ b/.github/VOUCHED.td
@@ -27,6 +27,7 @@ r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
+rubdos
shantur
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
diff --git a/bun.lock b/bun.lock
index 0ba00b23f8..64b32feac4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
- "version": "1.14.19",
+ "version": "1.14.20",
"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.14.19",
+ "version": "1.14.20",
"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.14.19",
+ "version": "1.14.20",
"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.14.19",
+ "version": "1.14.20",
"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.14.19",
+ "version": "1.14.20",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -269,7 +269,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -298,7 +298,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -314,7 +314,7 @@
},
"packages/opencode": {
"name": "opencode",
- "version": "1.14.19",
+ "version": "1.14.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -367,8 +367,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
- "@opentui/core": "0.1.101",
- "@opentui/solid": "0.1.101",
+ "@opentui/core": "0.1.99",
+ "@opentui/solid": "0.1.99",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -459,23 +459,23 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
- "@opentui/core": "0.1.101",
- "@opentui/solid": "0.1.101",
+ "@opentui/core": "0.1.99",
+ "@opentui/solid": "0.1.99",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
- "@opentui/core": ">=0.1.101",
- "@opentui/solid": ">=0.1.101",
+ "@opentui/core": ">=0.1.99",
+ "@opentui/solid": ">=0.1.99",
},
"optionalPeers": [
"@opentui/core",
@@ -494,7 +494,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -509,7 +509,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
- "version": "1.14.19",
+ "version": "1.14.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -533,7 +533,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -568,7 +568,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -617,7 +617,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -688,7 +688,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
- "@types/bun": "1.3.11",
+ "@types/bun": "1.3.12",
"@types/cross-spawn": "6.0.6",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
@@ -1604,21 +1604,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
- "@opentui/core": ["@opentui/core@0.1.101", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.101", "@opentui/core-darwin-x64": "0.1.101", "@opentui/core-linux-arm64": "0.1.101", "@opentui/core-linux-x64": "0.1.101", "@opentui/core-win32-arm64": "0.1.101", "@opentui/core-win32-x64": "0.1.101", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8jUhNKnwCDO3Y2iiEmagoQLjgX5l1WbddQiwky8B5JU4FW0/WRHairBmU1kRAQBmhdeg57dVinSG4iu2PAtKEA=="],
+ "@opentui/core": ["@opentui/core@0.1.99", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.99", "@opentui/core-darwin-x64": "0.1.99", "@opentui/core-linux-arm64": "0.1.99", "@opentui/core-linux-x64": "0.1.99", "@opentui/core-win32-arm64": "0.1.99", "@opentui/core-win32-x64": "0.1.99", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-I3+AEgGzqNWIpWX9g2WOscSPwtQDNOm4KlBjxBWCZjLxkF07u77heWXF7OiAdhKLtNUW6TFiyt6yznqAZPdG3A=="],
- "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.101", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HtqZh8TIKCH1Nge5J0etBCpzYfPY4fVcq110uJm2As6D/dTTPv8r4J+KkrqoSphkpj/Y2b4t7KpqNHthXA0EVw=="],
+ "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.99", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bzVrqeX2vb5iWrc/ftOUOqeUY8XO+qSgoTwj5TXHuwagavgwD3Hpeyjx8+icnTTeM4pao0som1WR9xfye6/X5Q=="],
- "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.101", "", { "os": "darwin", "cpu": "x64" }, "sha512-o5ClQWnGG1inRE2YZAatPw1jPEAJni00amcoIfKBj8e1WS+fQA+iQTq1xFunNcyNPObLDCVuW1X+NrbK9xmPvQ=="],
+ "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.99", "", { "os": "darwin", "cpu": "x64" }, "sha512-VE4FrXBYpkxnvkqcCV1a8aN9jyyMJMihVW+V2NLCtp+4yQsj0AapG5TiUSN76XnmSZRptxDy5rBmEempeoIZbg=="],
- "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.101", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/weY7DQpaPWGYDPD0CROHowUotqnVlk7Kb6l9+iZCrxm9s7HPRHkcMDVmcWDqHEqa/J879EJcqaUDzDArqC+w=="],
+ "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.99", "", { "os": "linux", "cpu": "arm64" }, "sha512-viXQsbpS7yHjYkl7+am32JdvG96QU9lvHh1UiZtpOxcNUUqiYmA2ZwZFPD2Bi54jNyj5l2hjH6YkD3DzE2FEWA=="],
- "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.101", "", { "os": "linux", "cpu": "x64" }, "sha512-+Bfr8jLbbR1WREUMCCvSZ44G1+WU2lPqJx7x1StTa9iFNEdicxCdd0QQsO6cnKn5yW+2Pr/FdrqHbxSQw3ejbA=="],
+ "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.99", "", { "os": "linux", "cpu": "x64" }, "sha512-WLoEFINOSp0tZSR9y4LUuGc7n4Y7H1wcpjUPzQ9vChkYDXrfZltEanzoDWbDcQ4kZQW5tHVC7LrZHpAsRLwFZg=="],
- "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.101", "", { "os": "win32", "cpu": "arm64" }, "sha512-LTMIHJzJrVqS8mgpp+tuyVHuqYlicQTvFi/sTsJ6Xswf1asatsvZYsbQByhBLpFT80j10G7uvDa361S5gjCUDA=="],
+ "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.99", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWMOLWCEO8HdrctU1dMkgZC8qGkiO4Dwr4/e11tTvVpRmYhDsP/IR89ZjEEtOwnKwFOFuB/MxvflqaEWVQ2g5Q=="],
- "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.101", "", { "os": "win32", "cpu": "x64" }, "sha512-VaMs5bg6y0tYKptaEK8Hy5wTp4m//wJRKUdW8uvrS9cFgxyovZGuw0+TfK3NgbdeX+8jWm8LEAiak4jle5BABg=="],
+ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.99", "", { "os": "win32", "cpu": "x64" }, "sha512-aYRlsL2w8YRL6vPd7/hrqlNVkXU3QowWb01TOvAcHS8UAsXaGFUr47kSDyjxDi1wg1MzmVduCfsC7T3NoThV1w=="],
- "@opentui/solid": ["@opentui/solid@0.1.101", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.101", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-STY2FQYtVS2rhUgpslG6mM0EAkgobBDF91+B+SNmvXIkJwP+ydP6UVgcuIo5McIbb9GIbAODx5X2Q48PSR7hgw=="],
+ "@opentui/solid": ["@opentui/solid@0.1.99", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.99", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-DrqqO4h2V88FmeIP2cErYkMU0ZK5MrUsZw3w6IzZpoXyyiL4/9qpWzUq+CXx+r16VP2iGxDJwGKUmtFAzUch2Q=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2302,7 +2302,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
- "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
+ "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
@@ -2720,7 +2720,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
- "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
+ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
diff --git a/flake.lock b/flake.lock
index 805be8739b..1c8e62bd82 100644
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1773909469,
- "narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
+ "lastModified": 1776683584,
+ "narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "7149c06513f335be57f26fcbbbe34afda923882b",
+ "rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89",
"type": "github"
},
"original": {
diff --git a/nix/hashes.json b/nix/hashes.json
index e68adae5ba..c096046106 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
{
"nodeModules": {
- "x86_64-linux": "sha256-DOGOZdPdkcuyDhVAyWHGsL4rrV28S+YFZj/VORuoQ8Q=",
- "aarch64-linux": "sha256-WRnAaEoKvgFFZ+UkbYtD9gBw0HtV1jdUqv7yUE2uTAQ=",
- "aarch64-darwin": "sha256-LxIj/dsL88M99T3WLaD9FL6Qdu2TV+kr1RMZaZ3i4WM=",
- "x86_64-darwin": "sha256-PgIvplw6yz9KN5nBWox3BXZIXDbkJ3ZuDPKKSVF82MU="
+ "x86_64-linux": "sha256-AgHhYsiygxbsBo3JN4HqHXKAwh8n1qeuSCe2qqxlxW4=",
+ "aarch64-linux": "sha256-h2lpWRQ5EDYnjpqZXtUAp1mxKLQxJ4m8MspgSY8Ev78=",
+ "aarch64-darwin": "sha256-xnd91+WyeAqn06run2ajsekxJvTMiLsnqNPe/rR8VTM=",
+ "x86_64-darwin": "sha256-rXpz45IOjGEk73xhP9VY86eOj2CZBg2l1vzwzTIOOOQ="
}
}
diff --git a/package.json b/package.json
index 06bf9c91ae..f918bcd025 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
- "packageManager": "bun@1.3.11",
+ "packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop-electron dev",
@@ -30,7 +30,7 @@
"@effect/opentelemetry": "4.0.0-beta.48",
"@effect/platform-node": "4.0.0-beta.48",
"@npmcli/arborist": "9.4.0",
- "@types/bun": "1.3.11",
+ "@types/bun": "1.3.12",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
diff --git a/packages/app/package.json b/packages/app/package.json
index 73a648cb6f..f461459fcb 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
- "version": "1.14.19",
+ "version": "1.14.20",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx
index ea5d70065a..8eb12daf52 100644
--- a/packages/app/src/components/dialog-edit-project.tsx
+++ b/packages/app/src/components/dialog-edit-project.tsx
@@ -12,6 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/shared/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
+import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
@@ -26,8 +27,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
const [store, setStore] = createStore({
name: defaultName(),
- color: props.project.icon?.color || "pink",
- iconUrl: props.project.icon?.override || "",
+ color: props.project.icon?.color,
+ iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
@@ -39,7 +40,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
- setStore("iconUrl", e.target?.result as string)
+ setStore("iconOverride", e.target?.result as string)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
@@ -68,7 +69,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
}
function clearIcon() {
- setStore("iconUrl", "")
+ setStore("iconOverride", "")
}
const saveMutation = useMutation(() => ({
@@ -81,17 +82,17 @@ export function DialogEditProject(props: { project: LocalProject }) {
projectID: props.project.id,
directory: props.project.worktree,
name,
- icon: { color: store.color, override: store.iconUrl },
+ icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
- globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
+ globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
- icon: { color: store.color, override: store.iconUrl || undefined },
+ icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
@@ -130,13 +131,13 @@ export function DialogEditProject(props: { project: LocalProject }) {
classList={{
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver,
- "overflow-hidden": !!store.iconUrl,
+ "overflow-hidden": !!store.iconOverride,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
- if (store.iconUrl && store.iconHover) {
+ if (store.iconOverride && store.iconHover) {
clearIcon()
} else {
iconInput?.click()
@@ -144,7 +145,11 @@ export function DialogEditProject(props: { project: LocalProject }) {
}}
>
}
>
-
+ {(src) => (
+
+ )}
@@ -174,8 +181,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
@@ -198,7 +205,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
+
@@ -215,7 +222,10 @@ export function DialogEditProject(props: { project: LocalProject }) {
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
store.color !== color,
}}
- onClick={() => setStore("color", color)}
+ onClick={() => {
+ if (store.color === color && !props.project.icon?.url) return
+ setStore("color", store.color === color ? undefined : color)
+ }}
>
-
+
{(i) => {
const key = ServerConnection.key(i)
@@ -619,7 +619,7 @@ export function DialogSelectServer() {
-
+
= (props) => {
}))
const agentsLoading = () => agentsQuery.isLoading
+ const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
+ const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource(
() => prompt.ready().promise,
@@ -1460,7 +1462,10 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => {
-
+
0}
fallback={
@@ -1557,7 +1565,10 @@ export const PromptInput: Component = (props) => {
-
+
{
/>
+
+
+
+ settings.general.setShowSessionProgressBar(checked)}
+ />
+
+
)
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 4ced2b939d..7c819918c0 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -10,7 +10,7 @@ import type {
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/shared/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
-import { createStore, produce, reconcile } from "solid-js/store"
+import { createStore, produce, reconcile, unwrap } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error"
@@ -95,13 +95,8 @@ function createGlobalSync() {
)
}
- const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
+ const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
projectWritten = true
- if (typeof next === "function") {
- setGlobalStore("project", produce(next))
- cacheProjects()
- return
- }
setGlobalStore("project", next)
cacheProjects()
}
@@ -116,7 +111,7 @@ function createGlobalSync() {
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
- setProjects(input[1] as Project[] | ((draft: Project[]) => void))
+ setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@@ -300,6 +295,19 @@ function createGlobalSync() {
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
+ if (event.type === "session.error") {
+ const error = event.properties.error
+ if (error?.name !== "MessageAbortedError") {
+ console.error("[global-sync] session error", {
+ scope: directory === "global" ? "global" : "workspace",
+ directory: directory === "global" ? undefined : directory,
+ project: directory === "global" ? undefined : getFilename(directory),
+ sessionID: event.properties.sessionID,
+ error,
+ })
+ }
+ }
+
if (directory === "global") {
applyGlobalEvent({
event,
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index 11a0cf83fd..82408fdfe9 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -21,7 +21,7 @@ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
- setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
+ setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
@@ -33,14 +33,18 @@ export function applyGlobalEvent(input: {
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) {
- input.setGlobalProject((draft) => {
- draft[result.index] = { ...draft[result.index], ...properties }
- })
+ input.setGlobalProject(
+ produce((draft) => {
+ draft[result.index] = { ...draft[result.index], ...properties }
+ }),
+ )
return
}
- input.setGlobalProject((draft) => {
- draft.splice(result.index, 0, properties)
- })
+ input.setGlobalProject(
+ produce((draft) => {
+ draft.splice(result.index, 0, properties)
+ }),
+ )
}
function cleanupSessionCaches(
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 74ea285310..90e357bd33 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -516,7 +516,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
for (const project of projects) {
- if (project.icon?.color) continue
+ if (project.icon?.color || project.icon.url) continue
const worktree = project.worktree
const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used)
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx
index 6d4f3d2cda..be2fb49d7e 100644
--- a/packages/app/src/context/settings.tsx
+++ b/packages/app/src/context/settings.tsx
@@ -31,6 +31,7 @@ export interface Settings {
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
+ showSessionProgressBar: boolean
}
updates: {
startup: boolean
@@ -115,6 +116,7 @@ const defaultSettings: Settings = {
showReasoningSummaries: false,
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
+ showSessionProgressBar: true,
},
updates: {
startup: true,
@@ -227,6 +229,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value)
},
+ showSessionProgressBar: withFallback(
+ () => store.general?.showSessionProgressBar,
+ defaultSettings.general.showSessionProgressBar,
+ ),
+ setShowSessionProgressBar(value: boolean) {
+ setStore("general", "showSessionProgressBar", value)
+ },
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index 9e9a88c2d0..efb2919a5b 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -582,6 +582,8 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
"settings.general.row.editToolPartsExpanded.description":
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
+ "settings.general.row.showSessionProgressBar.title": "إظهار شريط تقدم الجلسة",
+ "settings.general.row.showSessionProgressBar.description": "عرض شريط التقدم المتحرك أعلى الجلسة أثناء عمل الوكيل",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index 5fd1aee763..022d012984 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -590,6 +590,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
+ "settings.general.row.showSessionProgressBar.title": "Mostrar barra de progresso da sessão",
+ "settings.general.row.showSessionProgressBar.description":
+ "Exibir a barra de progresso animada no topo da sessão quando o agente estiver trabalhando",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index f872db1f00..15d8376ab6 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/packages/app/src/i18n/bs.ts
@@ -655,6 +655,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
"settings.general.row.editToolPartsExpanded.description":
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
+ "settings.general.row.showSessionProgressBar.title": "Prikaži traku napretka sesije",
+ "settings.general.row.showSessionProgressBar.description":
+ "Prikaži animiranu traku napretka na vrhu sesije kada agent radi",
"settings.general.row.wayland.title": "Koristi nativni Wayland",
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index 82f4fe3f63..03cfe2b786 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -649,6 +649,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
+ "settings.general.row.showSessionProgressBar.title": "Vis sessionens fremdriftslinje",
+ "settings.general.row.showSessionProgressBar.description":
+ "Vis den animerede fremdriftslinje øverst i sessionen, når agenten arbejder",
"settings.general.row.wayland.title": "Brug native Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index d5b95459ac..ccb88e9f41 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -601,6 +601,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
"settings.general.row.editToolPartsExpanded.description":
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
+ "settings.general.row.showSessionProgressBar.title": "Sitzungsfortschrittsleiste anzeigen",
+ "settings.general.row.showSessionProgressBar.description":
+ "Die animierte Fortschrittsleiste oben in der Sitzung anzeigen, wenn der Agent arbeitet",
"settings.general.row.wayland.title": "Natives Wayland verwenden",
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 95266582ec..9b9e4390bf 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -765,6 +765,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
"settings.general.row.editToolPartsExpanded.description":
"Show edit, write, and patch tool parts expanded by default in the timeline",
+ "settings.general.row.showSessionProgressBar.title": "Show session progress bar",
+ "settings.general.row.showSessionProgressBar.description":
+ "Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 12bc45cf38..0b4789c2aa 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -659,6 +659,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
"settings.general.row.editToolPartsExpanded.description":
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
+ "settings.general.row.showSessionProgressBar.title": "Mostrar barra de progreso de la sesión",
+ "settings.general.row.showSessionProgressBar.description":
+ "Mostrar la barra de progreso animada en la parte superior de la sesión cuando el agente esté trabajando",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index 6c98b9ca1e..4d73f626b2 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -598,6 +598,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
"settings.general.row.editToolPartsExpanded.description":
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
+ "settings.general.row.showSessionProgressBar.title": "Afficher la barre de progression de la session",
+ "settings.general.row.showSessionProgressBar.description":
+ "Afficher la barre de progression animée en haut de la session lorsque l'agent travaille",
"settings.general.row.wayland.title": "Utiliser Wayland natif",
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index 7678334127..493b1f17ff 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -587,6 +587,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
"settings.general.row.editToolPartsExpanded.description":
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
+ "settings.general.row.showSessionProgressBar.title": "セッション進行状況バーを表示",
+ "settings.general.row.showSessionProgressBar.description":
+ "エージェントの作業中に、セッション上部にアニメーション付きの進行状況バーを表示します",
"settings.general.row.wayland.title": "ネイティブWaylandを使用",
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index 76bf33df6f..0218cc1a9e 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -583,6 +583,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
"settings.general.row.editToolPartsExpanded.description":
"타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
+ "settings.general.row.showSessionProgressBar.title": "세션 진행 표시줄 표시",
+ "settings.general.row.showSessionProgressBar.description":
+ "에이전트가 작업 중일 때 세션 상단에 애니메이션 진행 표시줄을 표시합니다",
"settings.general.row.wayland.title": "네이티브 Wayland 사용",
"settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index 75e557b16b..43aa844200 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -656,6 +656,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
"settings.general.row.editToolPartsExpanded.description":
"Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
+ "settings.general.row.showSessionProgressBar.title": "Vis fremdriftslinje for sesjonen",
+ "settings.general.row.showSessionProgressBar.description":
+ "Vis den animerte fremdriftslinjen øverst i sesjonen når agenten jobber",
"settings.general.row.wayland.title": "Bruk innebygd Wayland",
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index 0ab4a6906c..6c6d4dddc1 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -588,6 +588,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
"settings.general.row.editToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
+ "settings.general.row.showSessionProgressBar.title": "Pokazuj pasek postępu sesji",
+ "settings.general.row.showSessionProgressBar.description":
+ "Wyświetlaj animowany pasek postępu u góry sesji, gdy agent pracuje",
"settings.general.row.wayland.title": "Użyj natywnego Wayland",
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 135c8e66c4..e0b094877a 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -656,6 +656,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
"settings.general.row.editToolPartsExpanded.description":
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
+ "settings.general.row.showSessionProgressBar.title": "Показывать индикатор прогресса сессии",
+ "settings.general.row.showSessionProgressBar.description":
+ "Показывать анимированный индикатор прогресса вверху сессии, когда агент работает",
"settings.general.row.wayland.title": "Использовать нативный Wayland",
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
"settings.general.row.wayland.tooltip":
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 81674df32d..8a15f29c0b 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -647,6 +647,9 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
"settings.general.row.editToolPartsExpanded.description":
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
+ "settings.general.row.showSessionProgressBar.title": "แสดงแถบความคืบหน้าของเซสชัน",
+ "settings.general.row.showSessionProgressBar.description":
+ "แสดงแถบความคืบหน้าแบบเคลื่อนไหวที่ด้านบนของเซสชันเมื่อเอเจนต์กำลังทำงาน",
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",
diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts
index f3cb3ab464..f20c05000d 100644
--- a/packages/app/src/i18n/tr.ts
+++ b/packages/app/src/i18n/tr.ts
@@ -663,6 +663,10 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.description":
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
+ "settings.general.row.showSessionProgressBar.title": "Oturum ilerleme çubuğunu göster",
+ "settings.general.row.showSessionProgressBar.description":
+ "Ajan çalışırken oturumun üst kısmında animasyonlu ilerleme çubuğunu göster",
+
"settings.general.row.wayland.title": "Yerel Wayland kullan",
"settings.general.row.wayland.description":
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index d95bfd19ba..05310df965 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -646,6 +646,8 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
+ "settings.general.row.showSessionProgressBar.title": "显示会话进度条",
+ "settings.general.row.showSessionProgressBar.description": "当智能体正在工作时,在会话顶部显示动画进度条",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 4a88ca4fc8..43681c7793 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -642,6 +642,8 @@ export const dict = {
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
+ "settings.general.row.showSessionProgressBar.title": "顯示工作階段進度列",
+ "settings.general.row.showSessionProgressBar.description": "當代理程式正在運作時,在工作階段頂部顯示動畫進度列",
"settings.general.row.wayland.title": "使用原生 Wayland",
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index 45db5b5489..5170311a7b 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -19,6 +19,14 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
+ return id === OPENCODE_PROJECT_ID
+ ? "https://opencode.ai/favicon.svg"
+ : icon?.color
+ ? undefined
+ : icon?.override || icon?.url
+}
+
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
const globalSync = useGlobalSync()
const notification = useNotification()
@@ -42,11 +50,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
-
+
ratio(m.req)))
const log = (n: number) => Math.log10(Math.max(n, 1))
const base = 24
- const p = 1.8
+ const p = 2.2
const x = (r: number) => left + base + Math.pow(log(r) / log(rmax), p) * (plot - base)
const start = (x(1) / w) * 100
@@ -152,12 +153,24 @@ function LimitsGraph(props: { href: string }) {
+ {m.baseReq && (
+
+ )}
)}
@@ -247,6 +260,12 @@ export default function Home() {
+
+
{i18n.t("home.banner.badge")}
+
+ {i18n.t("go.banner.text")}
+
+

diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
index c894ace104..abd417e06b 100644
--- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx
@@ -289,8 +289,10 @@ export function LiteSection() {
Kimi K2.6
GLM-5
GLM-5.1
-
Mimo-V2-Pro
-
Mimo-V2-Omni
+
MiMo-V2-Pro
+
MiMo-V2-Omni
+
MiMo-V2.5-Pro
+
MiMo-V2.5
MiniMax M2.5
MiniMax M2.7
Qwen3.5 Plus
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index 81c512b99a..d9dc450012 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -461,7 +461,8 @@ export async function handler(
}
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
- const allProviders = modelInfo.providers
+ let topPriority = Infinity
+ const providers = modelInfo.providers
.filter((provider) => !provider.disabled)
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
@@ -470,9 +471,10 @@ export async function handler(
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
-
- const topPriority = Math.min(...allProviders.map((p) => p.priority))
- const providers = allProviders
+ .map((provider) => {
+ topPriority = Math.min(topPriority, provider.priority)
+ return provider
+ })
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array
(provider.weight).fill(provider))
diff --git a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts
index 53015d51cc..8e3e8cc95e 100644
--- a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts
+++ b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts
@@ -1,28 +1,25 @@
import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
-import { ModelTpmLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
+import { ModelTpmRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { UsageInfo } from "./provider/provider"
export function createModelTpmLimiter(providers: { id: string; model: string; tpmLimit?: number }[]) {
const ids = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`)
if (ids.length === 0) return
- const yyyyMMddHHmm = new Date(Date.now())
- .toISOString()
- .replace(/[^0-9]/g, "")
- .substring(0, 12)
+ const yyyyMMddHHmm = parseInt(
+ new Date(Date.now())
+ .toISOString()
+ .replace(/[^0-9]/g, "")
+ .substring(0, 12),
+ )
return {
check: async () => {
const data = await Database.use((tx) =>
tx
.select()
- .from(ModelTpmLimitTable)
- .where(
- inArray(
- ModelTpmLimitTable.id,
- ids.map((id) => formatId(id, yyyyMMddHHmm)),
- ),
- ),
+ .from(ModelTpmRateLimitTable)
+ .where(and(inArray(ModelTpmRateLimitTable.id, ids), eq(ModelTpmRateLimitTable.interval, yyyyMMddHHmm))),
)
// convert to map of model to count
@@ -41,14 +38,10 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp
if (usage <= 0) return
await Database.use((tx) =>
tx
- .insert(ModelTpmLimitTable)
- .values({ id: formatId(id, yyyyMMddHHmm), count: usage })
- .onDuplicateKeyUpdate({ set: { count: sql`${ModelTpmLimitTable.count} + ${usage}` } }),
+ .insert(ModelTpmRateLimitTable)
+ .values({ id, interval: yyyyMMddHHmm, count: usage })
+ .onDuplicateKeyUpdate({ set: { count: sql`${ModelTpmRateLimitTable.count} + ${usage}` } }),
)
},
}
-
- function formatId(id: string, yyyyMMddHHmm: string) {
- return `${id.substring(0, 200)}/${yyyyMMddHHmm}`
- }
}
diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts
index 0f6f11da78..de49cddc1b 100644
--- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts
+++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts
@@ -148,11 +148,13 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
return {
parse: (chunk: string) => {
const data = chunk.split("\n")[1]
- if (!data.startsWith("data: ")) return
+ // Claude models start with "data: {"
+ // Alibaba models start with "data:{"
+ if (!data.startsWith("data:")) return
let json
try {
- json = JSON.parse(data.slice(6))
+ json = JSON.parse(data.replace(/^data:\s*/, ""))
} catch {
return
}
diff --git a/packages/console/core/migrations/20260421020842_bizarre_living_tribunal/migration.sql b/packages/console/core/migrations/20260421020842_bizarre_living_tribunal/migration.sql
new file mode 100644
index 0000000000..07dc63d984
--- /dev/null
+++ b/packages/console/core/migrations/20260421020842_bizarre_living_tribunal/migration.sql
@@ -0,0 +1,5 @@
+CREATE TABLE `model_tpm_rate_limit` (
+ `id` varchar(255) PRIMARY KEY,
+ `interval` bigint NOT NULL,
+ `count` int NOT NULL
+);
diff --git a/packages/console/core/migrations/20260421020842_bizarre_living_tribunal/snapshot.json b/packages/console/core/migrations/20260421020842_bizarre_living_tribunal/snapshot.json
new file mode 100644
index 0000000000..99e6fa52f7
--- /dev/null
+++ b/packages/console/core/migrations/20260421020842_bizarre_living_tribunal/snapshot.json
@@ -0,0 +1,2657 @@
+{
+ "version": "6",
+ "dialect": "mysql",
+ "id": "29e20639-1d4f-4125-bed8-70b7adaaa387",
+ "prevIds": ["0af8994a-606c-4ac9-a0a7-ebc991faaa38"],
+ "ddl": [
+ {
+ "name": "account",
+ "entityType": "tables"
+ },
+ {
+ "name": "auth",
+ "entityType": "tables"
+ },
+ {
+ "name": "benchmark",
+ "entityType": "tables"
+ },
+ {
+ "name": "billing",
+ "entityType": "tables"
+ },
+ {
+ "name": "coupon",
+ "entityType": "tables"
+ },
+ {
+ "name": "lite",
+ "entityType": "tables"
+ },
+ {
+ "name": "payment",
+ "entityType": "tables"
+ },
+ {
+ "name": "subscription",
+ "entityType": "tables"
+ },
+ {
+ "name": "usage",
+ "entityType": "tables"
+ },
+ {
+ "name": "ip_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "ip",
+ "entityType": "tables"
+ },
+ {
+ "name": "key_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "model_tpm_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "model_tpm_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "key",
+ "entityType": "tables"
+ },
+ {
+ "name": "model",
+ "entityType": "tables"
+ },
+ {
+ "name": "provider",
+ "entityType": "tables"
+ },
+ {
+ "name": "user",
+ "entityType": "tables"
+ },
+ {
+ "name": "workspace",
+ "entityType": "tables"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "enum('email','github','google')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subject",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "agent",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "mediumtext",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "result",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "customer_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(32)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_type",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(4)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_last4",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "balance",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_limit",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_usage_updated",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "boolean",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_trigger",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_amount",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_error",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_reload_error",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_reload_locked_till",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(28)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "enum('20','100','200')",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription_plan",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_subscription_booked",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_subscription_selected",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(28)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "lite_subscription_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "lite",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "coupon"
+ },
+ {
+ "type": "enum('BUILDATHON','GOFREEMONTH')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "coupon"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_redeemed",
+ "entityType": "columns",
+ "table": "coupon"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "rolling_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "weekly_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_rolling_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_weekly_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "customer_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "invoice_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "amount",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_refunded",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "enrichment",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "rolling_usage",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "fixed_usage",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_rolling_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_fixed_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "input_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "output_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reasoning_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_read_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_write_5m_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_write_1h_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cost",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "enrichment",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(45)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "ip",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "varchar(10)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "varchar(45)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "ip",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "usage",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "varchar(40)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "model_tpm_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "model_tpm_limit"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "model_tpm_rate_limit"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "model_tpm_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "model_tpm_rate_limit"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_used",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "credentials",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_seen",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "color",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "enum('admin','member')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "role",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_limit",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_usage_updated",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "account",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "auth",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "benchmark",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "billing",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["email", "type"],
+ "name": "PRIMARY",
+ "table": "coupon",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "lite",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "payment",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "subscription",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "usage",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["ip", "interval"],
+ "name": "PRIMARY",
+ "table": "ip_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["ip"],
+ "name": "PRIMARY",
+ "table": "ip",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["key", "interval"],
+ "name": "PRIMARY",
+ "table": "key_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "model_tpm_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "model_tpm_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "key",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "model",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "provider",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "user",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "workspace",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "provider",
+ "isExpression": false
+ },
+ {
+ "value": "subject",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "provider",
+ "entityType": "indexes",
+ "table": "auth"
+ },
+ {
+ "columns": [
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "account_id",
+ "entityType": "indexes",
+ "table": "auth"
+ },
+ {
+ "columns": [
+ {
+ "value": "time_created",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "time_created",
+ "entityType": "indexes",
+ "table": "benchmark"
+ },
+ {
+ "columns": [
+ {
+ "value": "customer_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_customer_id",
+ "entityType": "indexes",
+ "table": "billing"
+ },
+ {
+ "columns": [
+ {
+ "value": "subscription_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_subscription_id",
+ "entityType": "indexes",
+ "table": "billing"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "user_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_user_id",
+ "entityType": "indexes",
+ "table": "lite"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "user_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_user_id",
+ "entityType": "indexes",
+ "table": "subscription"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "time_created",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "usage_time_created",
+ "entityType": "indexes",
+ "table": "usage"
+ },
+ {
+ "columns": [
+ {
+ "value": "key",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_key",
+ "entityType": "indexes",
+ "table": "key"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "model",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "model_workspace_model",
+ "entityType": "indexes",
+ "table": "model"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "provider",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_provider",
+ "entityType": "indexes",
+ "table": "provider"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "user_account_id",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "email",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "user_email",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_account_id",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "email",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_email",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "slug",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "slug",
+ "entityType": "indexes",
+ "table": "workspace"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/console/core/migrations/20260421023950_nebulous_weapon_omega/migration.sql b/packages/console/core/migrations/20260421023950_nebulous_weapon_omega/migration.sql
new file mode 100644
index 0000000000..d7da039b86
--- /dev/null
+++ b/packages/console/core/migrations/20260421023950_nebulous_weapon_omega/migration.sql
@@ -0,0 +1,3 @@
+DROP TABLE `model_tpm_limit`;--> statement-breakpoint
+ALTER TABLE `model_tpm_rate_limit` DROP PRIMARY KEY;--> statement-breakpoint
+ALTER TABLE `model_tpm_rate_limit` ADD PRIMARY KEY (`id`,`interval`);
\ No newline at end of file
diff --git a/packages/console/core/migrations/20260421023950_nebulous_weapon_omega/snapshot.json b/packages/console/core/migrations/20260421023950_nebulous_weapon_omega/snapshot.json
new file mode 100644
index 0000000000..351c19d000
--- /dev/null
+++ b/packages/console/core/migrations/20260421023950_nebulous_weapon_omega/snapshot.json
@@ -0,0 +1,2619 @@
+{
+ "version": "6",
+ "dialect": "mysql",
+ "id": "9e2d81ba-88b4-4704-a8b6-4a27dd2bd8d9",
+ "prevIds": ["29e20639-1d4f-4125-bed8-70b7adaaa387"],
+ "ddl": [
+ {
+ "name": "account",
+ "entityType": "tables"
+ },
+ {
+ "name": "auth",
+ "entityType": "tables"
+ },
+ {
+ "name": "benchmark",
+ "entityType": "tables"
+ },
+ {
+ "name": "billing",
+ "entityType": "tables"
+ },
+ {
+ "name": "coupon",
+ "entityType": "tables"
+ },
+ {
+ "name": "lite",
+ "entityType": "tables"
+ },
+ {
+ "name": "payment",
+ "entityType": "tables"
+ },
+ {
+ "name": "subscription",
+ "entityType": "tables"
+ },
+ {
+ "name": "usage",
+ "entityType": "tables"
+ },
+ {
+ "name": "ip_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "ip",
+ "entityType": "tables"
+ },
+ {
+ "name": "key_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "model_tpm_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "key",
+ "entityType": "tables"
+ },
+ {
+ "name": "model",
+ "entityType": "tables"
+ },
+ {
+ "name": "provider",
+ "entityType": "tables"
+ },
+ {
+ "name": "user",
+ "entityType": "tables"
+ },
+ {
+ "name": "workspace",
+ "entityType": "tables"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "enum('email','github','google')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subject",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "agent",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "mediumtext",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "result",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "customer_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(32)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_type",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(4)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_last4",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "balance",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_limit",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_usage_updated",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "boolean",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_trigger",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_amount",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_error",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_reload_error",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_reload_locked_till",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(28)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "enum('20','100','200')",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription_plan",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_subscription_booked",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_subscription_selected",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(28)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "lite_subscription_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "lite",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "coupon"
+ },
+ {
+ "type": "enum('BUILDATHON','GOFREEMONTH')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "coupon"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_redeemed",
+ "entityType": "columns",
+ "table": "coupon"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "rolling_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "weekly_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_rolling_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_weekly_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "customer_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "invoice_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "amount",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_refunded",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "enrichment",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "rolling_usage",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "fixed_usage",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_rolling_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_fixed_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "input_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "output_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reasoning_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_read_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_write_5m_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_write_1h_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cost",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "enrichment",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(45)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "ip",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "varchar(10)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "varchar(45)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "ip",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "usage",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "varchar(40)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "model_tpm_rate_limit"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "model_tpm_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "model_tpm_rate_limit"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_used",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "credentials",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_seen",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "color",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "enum('admin','member')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "role",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_limit",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_usage_updated",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "account",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "auth",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "benchmark",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "billing",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["email", "type"],
+ "name": "PRIMARY",
+ "table": "coupon",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "lite",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "payment",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "subscription",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "usage",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["ip", "interval"],
+ "name": "PRIMARY",
+ "table": "ip_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["ip"],
+ "name": "PRIMARY",
+ "table": "ip",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["key", "interval"],
+ "name": "PRIMARY",
+ "table": "key_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id", "interval"],
+ "name": "PRIMARY",
+ "table": "model_tpm_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "key",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "model",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "provider",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "user",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "workspace",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "provider",
+ "isExpression": false
+ },
+ {
+ "value": "subject",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "provider",
+ "entityType": "indexes",
+ "table": "auth"
+ },
+ {
+ "columns": [
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "account_id",
+ "entityType": "indexes",
+ "table": "auth"
+ },
+ {
+ "columns": [
+ {
+ "value": "time_created",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "time_created",
+ "entityType": "indexes",
+ "table": "benchmark"
+ },
+ {
+ "columns": [
+ {
+ "value": "customer_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_customer_id",
+ "entityType": "indexes",
+ "table": "billing"
+ },
+ {
+ "columns": [
+ {
+ "value": "subscription_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_subscription_id",
+ "entityType": "indexes",
+ "table": "billing"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "user_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_user_id",
+ "entityType": "indexes",
+ "table": "lite"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "user_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_user_id",
+ "entityType": "indexes",
+ "table": "subscription"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "time_created",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "usage_time_created",
+ "entityType": "indexes",
+ "table": "usage"
+ },
+ {
+ "columns": [
+ {
+ "value": "key",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_key",
+ "entityType": "indexes",
+ "table": "key"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "model",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "model_workspace_model",
+ "entityType": "indexes",
+ "table": "model"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "provider",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_provider",
+ "entityType": "indexes",
+ "table": "provider"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "user_account_id",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "email",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "user_email",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_account_id",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "email",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_email",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "slug",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "slug",
+ "entityType": "indexes",
+ "table": "workspace"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 3605cfb0ee..090e5c59d9 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.14.19",
+ "version": "1.14.20",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/console/core/src/schema/ip.sql.ts b/packages/console/core/src/schema/ip.sql.ts
index 5814054460..94087abe52 100644
--- a/packages/console/core/src/schema/ip.sql.ts
+++ b/packages/console/core/src/schema/ip.sql.ts
@@ -1,4 +1,4 @@
-import { mysqlTable, int, primaryKey, varchar } from "drizzle-orm/mysql-core"
+import { mysqlTable, int, primaryKey, varchar, bigint } from "drizzle-orm/mysql-core"
import { timestamps } from "../drizzle/types"
export const IpTable = mysqlTable(
@@ -31,11 +31,12 @@ export const KeyRateLimitTable = mysqlTable(
(table) => [primaryKey({ columns: [table.key, table.interval] })],
)
-export const ModelTpmLimitTable = mysqlTable(
- "model_tpm_limit",
+export const ModelTpmRateLimitTable = mysqlTable(
+ "model_tpm_rate_limit",
{
id: varchar("id", { length: 255 }).notNull(),
+ interval: bigint("interval", { mode: "number" }).notNull(),
count: int("count").notNull(),
},
- (table) => [primaryKey({ columns: [table.id] })],
+ (table) => [primaryKey({ columns: [table.id, table.interval] })],
)
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index da73bc61fc..a2e0c5f03a 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.14.19",
+ "version": "1.14.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index b66296670f..7aa0cb7574 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.14.19",
+ "version": "1.14.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile
index 485375dd9f..d6f4729bf5 100644
--- a/packages/containers/bun-node/Dockerfile
+++ b/packages/containers/bun-node/Dockerfile
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0
-ARG BUN_VERSION=1.3.11
+ARG BUN_VERSION=1.3.13
ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts
index d0e6c42b6c..f28c7b6c18 100644
--- a/packages/desktop-electron/electron.vite.config.ts
+++ b/packages/desktop-electron/electron.vite.config.ts
@@ -53,6 +53,10 @@ export default defineConfig({
build: {
rollupOptions: {
input: { index: "src/preload/index.ts" },
+ output: {
+ format: "cjs",
+ entryFileNames: "[name].js",
+ },
},
},
},
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index 7105cb50ef..155e43f5e9 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
- "version": "1.14.19",
+ "version": "1.14.20",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
index 6c4e6d5ca1..ae9f581186 100644
--- a/packages/desktop-electron/src/main/index.ts
+++ b/packages/desktop-electron/src/main/index.ts
@@ -42,7 +42,13 @@ import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
-import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
+import {
+ createLoadingWindow,
+ createMainWindow,
+ registerRendererProtocol,
+ setBackgroundColor,
+ setDockIcon,
+} from "./windows"
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
@@ -106,6 +112,7 @@ function setupApp() {
void app.whenReady().then(async () => {
app.setAsDefaultProtocolClient("opencode")
+ registerRendererProtocol()
setDockIcon()
setupAutoUpdater()
await initialize()
@@ -188,15 +195,10 @@ async function initialize() {
logger.log("loading task finished")
})()
- const globals = {
- updaterEnabled: UPDATER_ENABLED,
- deepLinks: pendingDeepLinks,
- }
-
if (needsMigration) {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
- overlay = createLoadingWindow(globals)
+ overlay = createLoadingWindow()
await delay(1_000)
}
}
@@ -208,7 +210,7 @@ async function initialize() {
await loadingComplete.promise
}
- mainWindow = createMainWindow(globals)
+ mainWindow = createMainWindow()
wireMenu()
overlay?.close()
@@ -245,6 +247,8 @@ registerIpcHandlers({
initEmitter.off("step", listener)
}
},
+ getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
+ consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
getWslConfig: () => Promise.resolve(getWslConfig()),
diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts
index 52d87ed7ee..8dbca8eea1 100644
--- a/packages/desktop-electron/src/main/ipc.ts
+++ b/packages/desktop-electron/src/main/ipc.ts
@@ -2,7 +2,14 @@ import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
-import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types"
+import type {
+ InitStep,
+ ServerReadyData,
+ SqliteMigrationProgress,
+ TitlebarTheme,
+ WindowConfig,
+ WslConfig,
+} from "../preload/types"
import { getStore } from "./store"
import { setTitlebar } from "./windows"
@@ -14,6 +21,8 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise
+ getWindowConfig: () => Promise | WindowConfig
+ consumeInitialDeepLinks: () => Promise | string[]
getDefaultServerUrl: () => Promise | string | null
setDefaultServerUrl: (url: string | null) => Promise | void
getWslConfig: () => Promise
@@ -37,6 +46,8 @@ export function registerIpcHandlers(deps: Deps) {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)
})
+ ipcMain.handle("get-window-config", () => deps.getWindowConfig())
+ ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts
index fcf209fb67..0d9a697fa9 100644
--- a/packages/desktop-electron/src/main/menu.ts
+++ b/packages/desktop-electron/src/main/menu.ts
@@ -47,7 +47,7 @@ export function createMenu(deps: Deps) {
{
label: "New Window",
accelerator: "Cmd+Shift+N",
- click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }),
+ click: () => createMainWindow(),
},
{ type: "separator" },
{ role: "close" },
diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts
index 55dfdf6e9b..83d50f7cb6 100644
--- a/packages/desktop-electron/src/main/server.ts
+++ b/packages/desktop-electron/src/main/server.ts
@@ -39,6 +39,7 @@ export async function spawnLocalServer(hostname: string, port: number, password:
hostname,
username: "opencode",
password,
+ cors: ["oc://renderer"],
})
const wait = (async () => {
diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts
index 95f80c1240..337e1ca0bc 100644
--- a/packages/desktop-electron/src/main/windows.ts
+++ b/packages/desktop-electron/src/main/windows.ts
@@ -1,15 +1,24 @@
import windowState from "electron-window-state"
-import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
-import { dirname, join } from "node:path"
-import { fileURLToPath } from "node:url"
+import { app, BrowserWindow, net, nativeImage, nativeTheme, protocol } from "electron"
+import { dirname, isAbsolute, join, relative, resolve } from "node:path"
+import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
-type Globals = {
- updaterEnabled: boolean
- deepLinks?: string[]
-}
-
const root = dirname(fileURLToPath(import.meta.url))
+const rendererRoot = join(root, "../renderer")
+const rendererProtocol = "oc"
+const rendererHost = "renderer"
+
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: rendererProtocol,
+ privileges: {
+ secure: true,
+ standard: true,
+ supportFetchAPI: true,
+ },
+ },
+])
let backgroundColor: string | undefined
@@ -54,7 +63,7 @@ export function setDockIcon() {
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
-export function createMainWindow(globals: Globals) {
+export function createMainWindow() {
const state = windowState({
defaultWidth: 1280,
defaultHeight: 800,
@@ -84,15 +93,29 @@ export function createMainWindow(globals: Globals) {
}
: {}),
webPreferences: {
- preload: join(root, "../preload/index.mjs"),
- sandbox: false,
+ preload: join(root, "../preload/index.js"),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
},
})
+ win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
+ const { requestHeaders } = details
+ upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
+ callback({ requestHeaders })
+ })
+
+ win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
+ const { responseHeaders = {} } = details
+ upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"])
+ upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"])
+ callback({ responseHeaders })
+ })
+
state.manage(win)
loadWindow(win, "index.html")
wireZoom(win)
- injectGlobals(win, globals)
win.once("ready-to-show", () => {
win.show()
@@ -101,7 +124,7 @@ export function createMainWindow(globals: Globals) {
return win
}
-export function createLoadingWindow(globals: Globals) {
+export function createLoadingWindow() {
const mode = tone()
const win = new BrowserWindow({
width: 640,
@@ -120,17 +143,37 @@ export function createLoadingWindow(globals: Globals) {
}
: {}),
webPreferences: {
- preload: join(root, "../preload/index.mjs"),
- sandbox: false,
+ preload: join(root, "../preload/index.js"),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
},
})
loadWindow(win, "loading.html")
- injectGlobals(win, globals)
return win
}
+export function registerRendererProtocol() {
+ if (protocol.isProtocolHandled(rendererProtocol)) return
+
+ protocol.handle(rendererProtocol, (request) => {
+ const url = new URL(request.url)
+ if (url.host !== rendererHost) {
+ return new Response("Not found", { status: 404 })
+ }
+
+ const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
+ const rel = relative(rendererRoot, file)
+ if (rel.startsWith("..") || isAbsolute(rel)) {
+ return new Response("Not found", { status: 404 })
+ }
+
+ return net.fetch(pathToFileURL(file).toString())
+ })
+}
+
function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
@@ -139,25 +182,25 @@ function loadWindow(win: BrowserWindow, html: string) {
return
}
- void win.loadFile(join(root, `../renderer/${html}`))
+ void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
-
-function injectGlobals(win: BrowserWindow, globals: Globals) {
- win.webContents.on("dom-ready", () => {
- const deepLinks = globals.deepLinks ?? []
- const data = {
- updaterEnabled: globals.updaterEnabled,
- deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
- }
- void win.webContents.executeJavaScript(
- `window.__OPENCODE__ = Object.assign(window.__OPENCODE__ ?? {}, ${JSON.stringify(data)})`,
- )
- })
-}
-
function wireZoom(win: BrowserWindow) {
win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", () => {
win.webContents.setZoomFactor(1)
})
}
+
+function upsertKeyValue(obj: Record, keyToChange: string, value: any) {
+ const keyToChangeLower = keyToChange.toLowerCase()
+ for (const key of Object.keys(obj)) {
+ if (key.toLowerCase() === keyToChangeLower) {
+ // Reassign old key
+ obj[key] = value
+ // Done
+ return
+ }
+ }
+ // Insert at end instead
+ obj[keyToChange] = value
+}
diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts
index 296fcb2f1c..6261419ca5 100644
--- a/packages/desktop-electron/src/preload/index.ts
+++ b/packages/desktop-electron/src/preload/index.ts
@@ -11,6 +11,8 @@ const api: ElectronAPI = {
ipcRenderer.removeListener("init-step", handler)
})
},
+ getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
+ consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts
index f8e6d52c7d..6e22954d18 100644
--- a/packages/desktop-electron/src/preload/types.ts
+++ b/packages/desktop-electron/src/preload/types.ts
@@ -15,10 +15,16 @@ export type TitlebarTheme = {
mode: "light" | "dark"
}
+export type WindowConfig = {
+ updaterEnabled: boolean
+}
+
export type ElectronAPI = {
killSidecar: () => Promise
installCli: () => Promise
awaitInitialization: (onStep: (step: InitStep) => void) => Promise
+ getWindowConfig: () => Promise
+ consumeInitialDeepLinks: () => Promise
getDefaultServerUrl: () => Promise
setDefaultServerUrl: (url: string | null) => Promise
getWslConfig: () => Promise
diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts
index d1590ff048..6dff3baf1c 100644
--- a/packages/desktop-electron/src/renderer/env.d.ts
+++ b/packages/desktop-electron/src/renderer/env.d.ts
@@ -4,8 +4,6 @@ declare global {
interface Window {
api: ElectronAPI
__OPENCODE__?: {
- updaterEnabled?: boolean
- wsl?: boolean
deepLinks?: string[]
}
}
diff --git a/packages/desktop-electron/src/renderer/html.test.ts b/packages/desktop-electron/src/renderer/html.test.ts
index bd8281c2fb..1fc5c87178 100644
--- a/packages/desktop-electron/src/renderer/html.test.ts
+++ b/packages/desktop-electron/src/renderer/html.test.ts
@@ -9,9 +9,9 @@ const root = resolve(dir, "../..")
const html = async (name: string) => Bun.file(join(dir, name)).text()
/**
- * Electron loads renderer HTML via `win.loadFile()` which uses the `file://`
- * protocol. Absolute paths like `src="/foo.js"` resolve to the filesystem root
- * (e.g. `file:///C:/foo.js` on Windows) instead of relative to the app bundle.
+ * Packaged Electron windows load renderer HTML via the privileged `oc://`
+ * protocol. Root-relative asset paths like `src="/foo.js"` would resolve from
+ * the protocol origin root instead of relative to the current HTML entrypoint.
*
* All local resource references must use relative paths (`./`).
*/
diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx
index 44f2e6360c..56fe9fa513 100644
--- a/packages/desktop-electron/src/renderer/index.tsx
+++ b/packages/desktop-electron/src/renderer/index.tsx
@@ -20,7 +20,6 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
-import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { useTheme } from "@opencode-ai/ui/theme"
@@ -43,8 +42,7 @@ const emitDeepLinks = (urls: string[]) => {
}
const listenForDeepLinks = () => {
- const startUrls = window.__OPENCODE__?.deepLinks ?? []
- if (startUrls.length) emitDeepLinks(startUrls)
+ void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls))
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
}
@@ -57,13 +55,21 @@ const createPlatform = (): Platform => {
return undefined
})()
+ const isWslEnabled = async () => {
+ if (os !== "windows") return false
+ return window.api
+ .getWslConfig()
+ .then((config) => config.enabled)
+ .catch(() => false)
+ }
+
const wslHome = async () => {
- if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined
+ if (!(await isWslEnabled())) return undefined
return window.api.wslPath("~", "windows").catch(() => undefined)
}
const handleWslPicker = async (result: T | null): Promise => {
- if (!result || !window.__OPENCODE__?.wsl) return result
+ if (!result || !(await isWslEnabled())) return result
if (Array.isArray(result)) {
return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
}
@@ -137,7 +143,7 @@ const createPlatform = (): Platform => {
if (os === "windows") {
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
const resolvedPath = await (async () => {
- if (window.__OPENCODE__?.wsl) {
+ if (await isWslEnabled()) {
const converted = await window.api.wslPath(path, "windows").catch(() => null)
if (converted) return converted
}
@@ -159,12 +165,14 @@ const createPlatform = (): Platform => {
storage,
checkUpdate: async () => {
- if (!UPDATER_ENABLED()) return { updateAvailable: false }
+ const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
+ if (!config.updaterEnabled) return { updateAvailable: false }
return window.api.checkUpdate()
},
update: async () => {
- if (!UPDATER_ENABLED()) return
+ const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
+ if (!config.updaterEnabled) return
await window.api.installUpdate()
},
@@ -194,11 +202,7 @@ const createPlatform = (): Platform => {
return fetch(input, init)
},
- getWslEnabled: async () => {
- const next = await window.api.getWslConfig().catch(() => null)
- if (next) return next.enabled
- return window.__OPENCODE__!.wsl ?? false
- },
+ getWslEnabled: () => isWslEnabled(),
setWslEnabled: async (enabled) => {
await window.api.setWslConfig({ enabled })
@@ -249,6 +253,7 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
+ const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })))
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
@@ -325,7 +330,15 @@ render(() => {
return (
-
+
{(_) => {
return (
window.__OPENCODE__?.updaterEnabled ?? false
-
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
await initI18n()
try {
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 7b60658f43..a3b6eac6b0 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
- "version": "1.14.19",
+ "version": "1.14.20",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/desktop/src-tauri/release/appstream.metainfo.xml b/packages/desktop/src-tauri/release/appstream.metainfo.xml
index 16aa2bfcb2..d7dd49081b 100644
--- a/packages/desktop/src-tauri/release/appstream.metainfo.xml
+++ b/packages/desktop/src-tauri/release/appstream.metainfo.xml
@@ -28,11 +28,14 @@
- https://opencode.ai/docs/_astro/screenshot.Bs5D4atL_ZvsvFu.webp
+ https://raw.githubusercontent.com/anomalyco/opencode/b75d4d1c5ec449585d515c756fc81f080a157a9a/packages/web/src/assets/lander/screenshot.png
+
+ https://github.com/anomalyco/opencode/releases/tag/v1.4.0
+
https://github.com/anomalyco/opencode/releases/tag/v1.0.223
diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json
index 265044625b..c4f125a273 100644
--- a/packages/desktop/src-tauri/tauri.conf.json
+++ b/packages/desktop/src-tauri/tauri.conf.json
@@ -32,6 +32,7 @@
"icons/dev/icon.ico"
],
"active": true,
+ "category": "DeveloperTool",
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
"externalBin": ["sidecars/opencode-cli"],
"linux": {
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index 4783381f1f..0168636506 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.14.19",
+ "version": "1.14.20",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index cf48deae11..cee69b11af 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
-version = "1.14.19"
+version = "1.14.20"
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.14.19/opencode-darwin-arm64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.20/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-darwin-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.20/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.20/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.14.19/opencode-linux-x64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.20/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.14.19/opencode-windows-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.20/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/function/package.json b/packages/function/package.json
index f01d607e3e..4e920b6889 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.14.19",
+ "version": "1.14.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 5d8fd4b540..dcd0a9ad6c 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.14.19",
+ "version": "1.14.20",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -123,8 +123,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
- "@opentui/core": "0.1.101",
- "@opentui/solid": "0.1.101",
+ "@opentui/core": "0.1.99",
+ "@opentui/solid": "0.1.99",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts
index c0f302f21a..448760ae1a 100755
--- a/packages/opencode/script/schema.ts
+++ b/packages/opencode/script/schema.ts
@@ -55,7 +55,7 @@ const configFile = process.argv[2]
const tuiFile = process.argv[3]
console.log(configFile)
-await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
+await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2))
if (tuiFile) {
console.log(tuiFile)
diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index 93ef81a325..d882857ba1 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -224,7 +224,7 @@ When to use each:
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
-Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
+Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those — and for pure-object schemas where handlers populate plain objects rather than class instances — add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior without the `instanceof` requirement:
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
@@ -373,9 +373,9 @@ The first slice is successful if:
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
-- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
+- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. `Schema.Class`'s Declaration AST enforces `input instanceof self || input.[ClassTypeId]` during encode (see effect-smol `Schema.ts:10479-10484`). Plain objects from zod parse fail with `Expected Foo, got {...}`. This surfaced on `GET /config` where the service returns zod-parsed plain objects and `Config.InfoSchema` referenced `ConfigProvider.Info` (class). The fix was to convert pure-object classes to `Schema.Struct(...).annotate({ identifier: "..." })` — same named SDK `$ref`, no instance requirement. Verified byte-identical `types.gen.ts` vs `dev`.
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
-- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
+- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema **and** when the handler/service returns real instances. For schemas that need a named `$ref` but are populated from plain objects, use `Schema.Struct(...).annotate({ identifier: "..." })` instead. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
### Integration
@@ -404,8 +404,7 @@ Current instance route inventory:
- `provider` - `bridged`
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
- `config` - `bridged` (partial)
- bridged endpoint: `GET /config/providers`
- later endpoint: `GET /config`
+ bridged endpoints: `GET /config`, `GET /config/providers`
defer `PATCH /config` for now
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
@@ -431,9 +430,8 @@ Current instance route inventory:
Recommended near-term sequence:
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
-2. `config` full read endpoint (`GET /config`)
-3. `file` JSON read endpoints
-4. `mcp` JSON read endpoints
+2. `file` JSON read endpoints
+3. `mcp` JSON read endpoints
## Checklist
@@ -449,8 +447,8 @@ Recommended near-term sequence:
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
+- [x] port `GET /config` full read endpoint
- [ ] port `workspace` read endpoints
-- [ ] port `GET /config` full read endpoint
- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md
index 7d0d7eb13c..6d63715030 100644
--- a/packages/opencode/specs/effect/instance-context.md
+++ b/packages/opencode/specs/effect/instance-context.md
@@ -224,7 +224,6 @@ These tools mostly use direct getters for path resolution and repo-relative disp
- `src/tool/bash.ts`
- `src/tool/edit.ts`
- `src/tool/lsp.ts`
-- `src/tool/multiedit.ts`
- `src/tool/plan.ts`
- `src/tool/read.ts`
- `src/tool/write.ts`
diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md
index 72ee10350d..9ff6859cee 100644
--- a/packages/opencode/specs/effect/schema.md
+++ b/packages/opencode/specs/effect/schema.md
@@ -97,7 +97,7 @@ creating a parallel schema source of truth.
## Escape hatches
-The walker in `@/util/effect-zod` exposes three explicit escape hatches for
+The walker in `@/util/effect-zod` exposes two explicit escape hatches for
cases the pure-Schema path cannot express. Each one stays in the codebase
only as long as its upstream or local dependency requires it — inline
comments document when each can be deleted.
@@ -109,19 +109,7 @@ Replaces the entire derivation with a hand-crafted zod schema. Used when:
- the target carries external `$ref` metadata (e.g.
`config/model-id.ts` points at `https://models.dev/...`)
- the target is a zod-only schema that cannot yet be expressed as Schema
- (e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`)
-
-### `ZodPreprocess` annotation
-
-Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by
-`config/permission.ts` to inject `__originalKeys` before parsing, because
-`Schema.StructWithRest` canonicalises output (known fields first, catchall
-after) and destroys the user's original property order — which permission
-rule precedence depends on.
-
-Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder
-(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and
-the `__originalKeys` hack can both be deleted.
+ (e.g. `ConfigAgent.Info`, `Log.Level`)
### Local `DeepMutable` in `config/config.ts`
@@ -171,21 +159,95 @@ Schema at source.
These are the highest-priority next targets. Each is a small, self-contained
schema module with a clear domain.
-- [ ] `src/control-plane/schema.ts`
-- [ ] `src/permission/schema.ts`
-- [ ] `src/project/schema.ts`
-- [ ] `src/provider/schema.ts`
-- [ ] `src/pty/schema.ts`
-- [ ] `src/question/schema.ts`
-- [ ] `src/session/schema.ts`
-- [ ] `src/sync/schema.ts`
-- [ ] `src/tool/schema.ts`
+- [x] `src/control-plane/schema.ts`
+- [x] `src/permission/schema.ts`
+- [x] `src/project/schema.ts`
+- [x] `src/provider/schema.ts`
+- [x] `src/pty/schema.ts`
+- [x] `src/question/schema.ts`
+- [x] `src/session/schema.ts`
+- [x] `src/sync/schema.ts`
+- [x] `src/tool/schema.ts`
### Session domain
Major cluster. Message + event types flow through the SSE API and every SDK
output, so byte-identical SDK surface is critical.
+Suggested order for this cluster, starting from the leaves that `session.ts`
+and the SSE/event surface depend on:
+
+1. `src/session/schema.ts` ✅ already migrated
+2. `src/provider/schema.ts` if `message-v2.ts` still relies on zod-first IDs
+3. `src/lsp/*` schema leaves needed by `LSP.Range`
+4. `src/snapshot/*` leaves used by `Snapshot.FileDiff`
+5. `src/session/message-v2.ts`
+6. `src/session/message.ts`
+7. `src/session/prompt.ts`
+8. `src/session/revert.ts`
+9. `src/session/summary.ts`
+10. `src/session/status.ts`
+11. `src/session/todo.ts`
+12. `src/session/session.ts`
+13. `src/session/compaction.ts`
+
+Dependency sketch:
+
+```text
+session.ts
+|- project/schema.ts
+|- control-plane/schema.ts
+|- permission/schema.ts
+|- snapshot/*
+|- message-v2.ts
+| |- provider/schema.ts
+| |- lsp/*
+| |- snapshot/*
+| |- sync/index.ts
+| `- bus/bus-event.ts
+|- sync/index.ts
+|- bus/bus-event.ts
+`- util/update-schema.ts
+```
+
+Working rule for this cluster:
+
+- migrate reusable leaf schemas and nested payload objects first
+- migrate aggregate DTOs like `Session.Info` after their nested pieces exist as
+ named Schema values
+- leave zod-only event/update helpers in place temporarily when converting
+ them would force unrelated churn across sync/bus boundaries
+
+`message-v2.ts` first-pass outline:
+
+1. Schema-backed imports already available
+ - `SessionID`, `MessageID`, `PartID`
+ - `ProviderID`, `ModelID`
+2. Local leaf objects to extract and migrate first
+ - output format payloads
+ - common part bases like `PartBase`
+ - timestamp/range helper objects like `time.start/end`
+ - file/source helper objects
+ - token/cost/model helper objects
+3. Part variants built from those leaves
+ - `SnapshotPart`, `PatchPart`, `TextPart`, `ReasoningPart`
+ - `FilePart`, `AgentPart`, `CompactionPart`, `SubtaskPart`
+ - retry/step/tool related parts
+4. Higher-level unions and DTOs
+ - `FilePartSource`
+ - part unions
+ - message unions and assistant/user payloads
+5. Errors and event payloads last
+ - `NamedError.create(...)` shapes can stay temporarily if converting them to
+ `Schema.TaggedErrorClass` would force unrelated churn
+ - `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can keep using
+ derived `.zod` until the sync/bus layers are migrated
+
+Possible later tightening after the Schema-first migration is stable:
+
+- promote repeated opaque strings and timestamp numbers into branded/newtype
+ leaf schemas where that adds domain value without changing the wire format
+
- [ ] `src/session/compaction.ts`
- [ ] `src/session/message-v2.ts`
- [ ] `src/session/message.ts`
@@ -216,7 +278,6 @@ emitted JSON Schema must stay byte-identical.
- [ ] `src/tool/grep.ts`
- [ ] `src/tool/invalid.ts`
- [ ] `src/tool/lsp.ts`
-- [ ] `src/tool/multiedit.ts`
- [ ] `src/tool/plan.ts`
- [ ] `src/tool/question.ts`
- [ ] `src/tool/read.ts`
diff --git a/packages/opencode/specs/effect/tools.md b/packages/opencode/specs/effect/tools.md
index 7b47831709..3cc277357b 100644
--- a/packages/opencode/specs/effect/tools.md
+++ b/packages/opencode/specs/effect/tools.md
@@ -46,7 +46,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `lsp.ts`
-- [x] `multiedit.ts`
- [x] `plan.ts`
- [x] `question.ts`
- [x] `read.ts`
@@ -82,7 +81,6 @@ Notable items that are already effectively on the target path and do not need se
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
-- `multiedit.ts`
- `edit.ts`
## Filesystem notes
diff --git a/packages/opencode/src/agent/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt
index c5831bb30e..c7cb838bba 100644
--- a/packages/opencode/src/agent/prompt/compaction.txt
+++ b/packages/opencode/src/agent/prompt/compaction.txt
@@ -1,16 +1,9 @@
-You are a helpful AI assistant tasked with summarizing conversations.
+You are an anchored context summarization assistant for coding sessions.
-When asked to summarize, provide a detailed but concise summary of the older conversation history.
-The most recent turns may be preserved verbatim outside your summary, so focus on information that would still be needed to continue the work with that recent context available.
-Focus on information that would be helpful for continuing the conversation, including:
-- What was done
-- What is currently being worked on
-- Which files are being modified
-- What needs to be done next
-- Key user requests, constraints, or preferences that should persist
-- Important technical decisions and why they were made
+Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
-Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
+If the prompt includes a block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
-Do not respond to any questions in the conversation, only output the summary.
-Respond in the same language the user used in the conversation.
+Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
+
+Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.
diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts
index 185cab9c75..47db6358b6 100644
--- a/packages/opencode/src/cli/cmd/debug/lsp.ts
+++ b/packages/opencode/src/cli/cmd/debug/lsp.ts
@@ -23,8 +23,7 @@ const DiagnosticsCommand = cmd({
const out = await AppRuntime.runPromise(
LSP.Service.use((lsp) =>
Effect.gen(function* () {
- yield* lsp.touchFile(args.file, true)
- yield* Effect.sleep(1000)
+ yield* lsp.touchFile(args.file, "full")
return yield* lsp.diagnostics()
}),
),
diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts
index ed1ca2124d..fe8e233dd1 100644
--- a/packages/opencode/src/cli/cmd/github.ts
+++ b/packages/opencode/src/cli/cmd/github.ts
@@ -985,7 +985,8 @@ export const GithubRunCommand = cmd({
const err = result.info.error
console.error("Agent error:", err)
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
- throw new Error(`${err.name}: ${err.data?.message || ""}`)
+ const message = "message" in err.data ? err.data.message : ""
+ throw new Error(`${err.name}: ${message}`)
}
const text = extractResponseText(result.parts)
@@ -1014,7 +1015,8 @@ export const GithubRunCommand = cmd({
const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
- throw new Error(`${err.name}: ${err.data?.message || ""}`)
+ const message = "message" in err.data ? err.data.message : ""
+ throw new Error(`${err.name}: ${message}`)
}
const summaryText = extractResponseText(summary.parts)
diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts
index 8da254f159..309ec6d950 100644
--- a/packages/opencode/src/cli/cmd/import.ts
+++ b/packages/opencode/src/cli/cmd/import.ts
@@ -168,7 +168,7 @@ export const ImportCommand = cmd({
)
for (const msg of exportData.messages) {
- const msgInfo = MessageV2.Info.parse(msg.info)
+ const msgInfo = MessageV2.Info.zod.parse(msg.info)
const { id, sessionID: _, ...msgData } = msgInfo
Database.use((db) =>
db
@@ -184,7 +184,7 @@ export const ImportCommand = cmd({
)
for (const part of msg.parts) {
- const partInfo = MessageV2.Part.parse(part)
+ const partInfo = MessageV2.Part.zod.parse(part)
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
Database.use((db) =>
db
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 5da2740cce..2b31d078cb 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -1,6 +1,7 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
+import * as Terminal from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
@@ -119,6 +120,12 @@ export function tui(input: {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
+ const mode = await Terminal.getTerminalBackgroundColor()
+
+ // Re-clear after getTerminalBackgroundColor() because setRawMode(false)
+ // restores the original console mode, including processed input on Windows.
+ win32DisableProcessedInput()
+
const onExit = async () => {
unguard?.()
resolve()
@@ -129,7 +136,6 @@ export function tui(input: {
}
const renderer = await createCliRenderer(rendererConfig(input.config))
- const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
await render(() => {
return (
diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts
index 9a93f3f57a..cb6b95a56c 100644
--- a/packages/opencode/src/cli/cmd/tui/attach.ts
+++ b/packages/opencode/src/cli/cmd/tui/attach.ts
@@ -3,6 +3,8 @@ import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
+import { errorMessage } from "@/util/error"
+import { validateSession } from "./validate-session"
export const AttachCommand = cmd({
command: "attach ",
@@ -65,6 +67,20 @@ export const AttachCommand = cmd({
return { Authorization: auth }
})()
const config = await TuiConfig.get()
+
+ try {
+ await validateSession({
+ url: args.url,
+ sessionID: args.session,
+ directory,
+ headers,
+ })
+ } catch (error) {
+ UI.error(errorMessage(error))
+ process.exitCode = 1
+ return
+ }
+
await tui({
url: args.url,
config,
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 06be5dfbef..2f5da1d231 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -68,6 +68,7 @@ import { Flag } from "@/flag/flag"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import * as Clipboard from "../../util/clipboard"
+import { errorMessage } from "@/util/error"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import * as Editor from "../../util/editor"
@@ -180,31 +181,43 @@ export function Session() {
const toast = useToast()
const sdk = useSDK()
- createEffect(async () => {
- const previousWorkspace = project.workspace.current()
- const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
- if (!result.data) {
+ createEffect(() => {
+ const sessionID = route.sessionID
+ void (async () => {
+ const previousWorkspace = project.workspace.current()
+ const result = await sdk.client.session.get({ sessionID }, { throwOnError: true })
+ if (!result.data) {
+ toast.show({
+ message: `Session not found: ${sessionID}`,
+ variant: "error",
+ duration: 5000,
+ })
+ navigate({ type: "home" })
+ return
+ }
+
+ if (result.data.workspaceID !== previousWorkspace) {
+ project.workspace.set(result.data.workspaceID)
+
+ // Sync all the data for this workspace. Note that this
+ // workspace may not exist anymore which is why this is not
+ // fatal. If it doesn't we still want to show the session
+ // (which will be non-interactive)
+ try {
+ await sync.bootstrap({ fatal: false })
+ } catch {}
+ }
+ await sync.session.sync(sessionID)
+ if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
+ })().catch((error) => {
+ if (route.sessionID !== sessionID) return
toast.show({
- message: `Session not found: ${route.sessionID}`,
+ message: errorMessage(error),
variant: "error",
+ duration: 5000,
})
navigate({ type: "home" })
- return
- }
-
- if (result.data.workspaceID !== previousWorkspace) {
- project.workspace.set(result.data.workspaceID)
-
- // Sync all the data for this workspace. Note that this
- // workspace may not exist anymore which is why this is not
- // fatal. If it doesn't we still want to show the session
- // (which will be non-interactive)
- try {
- await sync.bootstrap({ fatal: false })
- } catch (e) {}
- }
- await sync.session.sync(route.sessionID)
- if (scroll) scroll.scrollBy(100_000)
+ })
})
let lastSwitch: string | undefined = undefined
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
index 54cc86a40d..e48f348b98 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -9,6 +9,7 @@ import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
+import { useProject } from "../../context/project"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util"
@@ -131,6 +132,7 @@ function TextBody(props: { title: string; description?: string; icon?: string })
export function PermissionPrompt(props: { request: PermissionRequest }) {
const sdk = useSDK()
+ const project = useProject()
const sync = useSync()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
@@ -187,6 +189,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
void sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
+ workspace: project.workspace.current(),
})
}}
/>
@@ -198,6 +201,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
reply: "reject",
requestID: props.request.id,
message: message || undefined,
+ workspace: project.workspace.current(),
})
}}
onCancel={() => {
@@ -450,12 +454,14 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
void sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
+ workspace: project.workspace.current(),
})
return
}
void sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
+ workspace: project.workspace.current(),
})
}}
/>
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index e3e9eb8117..a2a53ecafa 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -16,6 +16,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
+import { validateSession } from "./validate-session"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -202,6 +203,19 @@ export const TuiThreadCommand = cmd({
events: createEventSource(client),
}
+ try {
+ await validateSession({
+ url: transport.url,
+ sessionID: args.session,
+ directory: cwd,
+ fetch: transport.fetch,
+ })
+ } catch (error) {
+ UI.error(errorMessage(error))
+ process.exitCode = 1
+ return
+ }
+
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()
diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts
index c026b7381c..a61390f2cf 100644
--- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts
@@ -17,6 +17,12 @@ function parse(color: string): RGBA | null {
return null
}
+function mode(background: RGBA | null): "dark" | "light" {
+ if (!background) return "dark"
+ const luminance = (0.299 * background.r + 0.587 * background.g + 0.114 * background.b) / 255
+ return luminance > 0.5 ? "light" : "dark"
+}
+
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
@@ -94,3 +100,36 @@ export async function colors(): Promise<{
}, 1000)
})
}
+
+// Keep startup mode detection separate from `colors()`: the TUI boot path only
+// needs OSC 11 and should resolve on the first background response instead of
+// waiting on the full palette query used by system theme generation.
+export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
+ if (!process.stdin.isTTY) return "dark"
+
+ return new Promise((resolve) => {
+ let timeout: NodeJS.Timeout
+
+ const cleanup = () => {
+ process.stdin.setRawMode(false)
+ process.stdin.removeListener("data", handler)
+ clearTimeout(timeout)
+ }
+
+ const handler = (data: Buffer) => {
+ const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
+ if (!match) return
+ cleanup()
+ resolve(mode(parse(match[1])))
+ }
+
+ process.stdin.setRawMode(true)
+ process.stdin.on("data", handler)
+ process.stdout.write("\x1b]11;?\x07")
+
+ timeout = setTimeout(() => {
+ cleanup()
+ resolve("dark")
+ }, 1000)
+ })
+}
diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts
new file mode 100644
index 0000000000..e2a21d51e1
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts
@@ -0,0 +1,24 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { SessionID } from "@/session/schema"
+
+export async function validateSession(input: {
+ url: string
+ sessionID?: string
+ directory?: string
+ fetch?: typeof fetch
+ headers?: RequestInit["headers"]
+}) {
+ if (!input.sessionID) return
+
+ const result = SessionID.zod.safeParse(input.sessionID)
+ if (!result.success) {
+ throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`)
+ }
+
+ await createOpencodeClient({
+ baseUrl: input.url,
+ directory: input.directory,
+ fetch: input.fetch,
+ headers: input.headers,
+ }).session.get({ sessionID: result.data }, { throwOnError: true })
+}
diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts
index 7c6f08874b..a3e3f3013d 100644
--- a/packages/opencode/src/cli/upgrade.ts
+++ b/packages/opencode/src/cli/upgrade.ts
@@ -7,6 +7,7 @@ import { InstallationVersion } from "@/installation/version"
export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
+ if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
if (!latest) return
@@ -17,7 +18,6 @@ export async function upgrade() {
}
if (InstallationVersion === latest) return
- if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const kind = Installation.getReleaseType(InstallationVersion, latest)
diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts
index 1469522d98..85021407c7 100644
--- a/packages/opencode/src/config/agent.ts
+++ b/packages/opencode/src/config/agent.ts
@@ -3,7 +3,7 @@ export * as ConfigAgent from "./agent"
import { Schema } from "effect"
import z from "zod"
import { Bus } from "@/bus"
-import { zod, ZodOverride } from "@/util/effect-zod"
+import { zod } from "@/util/effect-zod"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
@@ -22,12 +22,6 @@ const Color = Schema.Union([
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
-// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)`
-// shape lives outside the Effect Schema type system), so the walker reaches it
-// via ZodOverride rather than a pure Schema reference. This preserves the
-// `$ref: PermissionConfig` emitted in openapi.json.
-const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
-
const AgentSchema = Schema.StructWithRest(
Schema.Struct({
model: Schema.optional(ConfigModelID),
@@ -54,7 +48,7 @@ const AgentSchema = Schema.StructWithRest(
description: "Maximum number of agentic iterations before forcing text-only response",
}),
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
- permission: Schema.optional(PermissionRef),
+ permission: Schema.optional(ConfigPermission.Info),
}),
[Schema.Record(Schema.String, Schema.Any)],
)
@@ -93,7 +87,7 @@ const normalize = (agent: z.infer) => {
const permission: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ if (tool === "write" || tool === "edit" || tool === "patch") {
permission.edit = action
continue
}
@@ -101,7 +95,8 @@ const normalize = (agent: z.infer) => {
}
globalThis.Object.assign(permission, agent.permission)
- return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
+ const steps = agent.steps ?? agent.maxSteps
+ return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }
}
export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 6b042ce1ea..1c99a45b17 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -25,6 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { zod, ZodOverride } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
@@ -85,13 +86,20 @@ export type Layout = ConfigLayout.Layout
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
// exact zod directly, preserving component $refs.
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
-const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
-const InfoSchema = Schema.Struct({
+// The Effect Schema is the canonical source of truth. The `.zod` compatibility
+// surface is derived so existing Hono validators keep working without a parallel
+// Zod definition.
+//
+// The walker emits `z.object({...})` which is non-strict by default. Config
+// historically uses `.strict()` (additionalProperties: false in openapi.json),
+// so layer that on after derivation. Re-apply the Config ref afterward
+// since `.strict()` strips the walker's meta annotation.
+export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
@@ -192,7 +200,7 @@ const InfoSchema = Schema.Struct({
description: "Additional instruction files or patterns to include",
}),
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
- permission: Schema.optional(PermissionRef),
+ permission: Schema.optional(ConfigPermission.Info),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
enterprise: Schema.optional(
Schema.Struct({
@@ -238,6 +246,14 @@ const InfoSchema = Schema.Struct({
}),
),
})
+ .annotate({ identifier: "Config" })
+ .pipe(
+ withStatics((s) => ({
+ zod: (zod(s) as unknown as z.ZodObject).strict().meta({ ref: "Config" }) as unknown as z.ZodType<
+ DeepMutable>
+ >,
+ })),
+ )
// Schema.Struct produces readonly types by default, but the service code
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
@@ -259,15 +275,7 @@ type DeepMutable = T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable }
: T
-// The walker emits `z.object({...})` which is non-strict by default. Config
-// historically uses `.strict()` (additionalProperties: false in openapi.json),
-// so layer that on after derivation. Re-apply the Config ref afterward
-// since `.strict()` strips the walker's meta annotation.
-export const Info = (zod(InfoSchema) as unknown as z.ZodObject)
- .strict()
- .meta({ ref: "Config" }) as unknown as z.ZodType>>
-
-export type Info = z.output & {
+export type Info = DeepMutable> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
plugin_origins?: ConfigPlugin.Origin[]
@@ -364,7 +372,7 @@ export const layer = Layer.effect(
),
)
const parsed = ConfigParse.jsonc(expanded, source)
- const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
+ const data = ConfigParse.schema(Info.zod, normalizeLoadedConfig(parsed, source), source)
if (!("path" in options)) return data
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
@@ -663,7 +671,7 @@ export const layer = Layer.effect(
const perms: Record = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ if (tool === "write" || tool === "edit" || tool === "patch") {
perms.edit = action
continue
}
@@ -756,13 +764,13 @@ export const layer = Layer.effect(
let next: Info
if (!file.endsWith(".jsonc")) {
- const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file)
+ const existing = ConfigParse.schema(Info.zod, ConfigParse.jsonc(before, file), file)
const merged = mergeDeep(writable(existing), writable(config))
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, writable(config))
- next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file)
+ next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts
index 8b77bc4c28..0887fa984a 100644
--- a/packages/opencode/src/config/mcp.ts
+++ b/packages/opencode/src/config/mcp.ts
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
-export class Local extends Schema.Class("McpLocalConfig")({
+export const Local = Schema.Struct({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
description: "Command and arguments to run the MCP server",
@@ -16,11 +16,12 @@ export class Local extends Schema.Class("McpLocalConfig")({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "McpLocalConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Local = Schema.Schema.Type
-export class OAuth extends Schema.Class("McpOAuthConfig")({
+export const OAuth = Schema.Struct({
clientId: Schema.optional(Schema.String).annotate({
description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
}),
@@ -31,11 +32,12 @@ export class OAuth extends Schema.Class("McpOAuthConfig")({
redirectUri: Schema.optional(Schema.String).annotate({
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
}),
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "McpOAuthConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type OAuth = Schema.Schema.Type
-export class Remote extends Schema.Class("McpRemoteConfig")({
+export const Remote = Schema.Struct({
type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
enabled: Schema.optional(Schema.Boolean).annotate({
@@ -50,9 +52,10 @@ export class Remote extends Schema.Class("McpRemoteConfig")({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "McpRemoteConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Remote = Schema.Schema.Type
export const Info = Schema.Union([Local, Remote])
.annotate({ discriminator: "type" })
diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts
index d4883ed8c1..fdd5746837 100644
--- a/packages/opencode/src/config/permission.ts
+++ b/packages/opencode/src/config/permission.ts
@@ -1,6 +1,6 @@
export * as ConfigPermission from "./permission"
-import { Schema } from "effect"
-import { zod, ZodPreprocess } from "@/util/effect-zod"
+import { Schema, SchemaGetter } from "effect"
+import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Action = Schema.Literals(["ask", "allow", "deny"])
@@ -18,21 +18,19 @@ export const Rule = Schema.Union([Action, Object])
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type
-// Captures the user's original property insertion order before Schema.Struct
-// canonicalises the object. See the `ZodPreprocess` comment in
-// `util/effect-zod.ts` for the full rationale — in short: rule precedence is
-// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win)
-// and `Schema.StructWithRest` would otherwise drop that order.
-const permissionPreprocess = (val: unknown) => {
- if (typeof val === "object" && val !== null && !Array.isArray(val)) {
- return { __originalKeys: globalThis.Object.keys(val), ...val }
- }
- return val
-}
-
-const ObjectShape = Schema.StructWithRest(
+// Known permission keys get explicit types — most are full Rule (either a
+// single Action or a per-pattern object), but a handful of tools take no
+// sub-target patterns and are Action-only. Unknown keys fall through the
+// Record rest signature as Rule.
+//
+// StructWithRest canonicalises key order on decode (known first, then rest),
+// which used to require the `__originalKeys` preprocess hack because
+// `Permission.fromConfig` depended on the user's insertion order. That
+// dependency is gone — `fromConfig` now sorts top-level keys so wildcard
+// permissions come before specifics, making the final precedence
+// order-independent.
+const InputObject = Schema.StructWithRest(
Schema.Struct({
- __originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
read: Schema.optional(Rule),
edit: Schema.optional(Rule),
glob: Schema.optional(Rule),
@@ -53,24 +51,29 @@ const ObjectShape = Schema.StructWithRest(
[Schema.Record(Schema.String, Rule)],
)
-const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({
- [ZodPreprocess]: permissionPreprocess,
-})
+// Input the user writes in config: either a single Action (shorthand for "*")
+// or an object of per-target rules.
+const InputSchema = Schema.Union([Action, InputObject])
-// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the
-// user's original insertion order. A plain string input (the Action branch of
-// the union) becomes `{ "*": action }`.
-const transform = (x: unknown): Record => {
- if (typeof x === "string") return { "*": x as Action }
- const obj = x as { __originalKeys?: string[] } & Record
- const { __originalKeys, ...rest } = obj
- if (!__originalKeys) return rest as Record
- const result: Record = {}
- for (const key of __originalKeys) {
- if (key in rest) result[key] = rest[key] as Rule
- }
- return result
-}
+// Normalise the Action shorthand into `{ "*": action }`. Object inputs pass
+// through untouched.
+const normalizeInput = (input: Schema.Schema.Type): Schema.Schema.Type =>
+ typeof input === "string" ? { "*": input } : input
-export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" })
-export type Info = Record
+export const Info = InputSchema.pipe(
+ Schema.decodeTo(InputObject, {
+ decode: SchemaGetter.transform(normalizeInput),
+ // Not perfectly invertible (we lose whether the user originally typed an
+ // Action shorthand), but the object form is always a valid representation
+ // of the same rules.
+ encode: SchemaGetter.passthrough({ strict: false }),
+ }),
+)
+ .annotate({ identifier: "PermissionConfig" })
+ .pipe(
+ // Walker already emits the decodeTo transform into the derived zod (see
+ // `encoded()` in effect-zod.ts), so just expose that directly.
+ withStatics((s) => ({ zod: zod(s) })),
+ )
+type _Info = Schema.Schema.Type
+export type Info = { -readonly [K in keyof _Info]: _Info[K] }
diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts
index 212e716251..bd6ae35996 100644
--- a/packages/opencode/src/config/provider.ts
+++ b/packages/opencode/src/config/provider.ts
@@ -70,7 +70,7 @@ export const Model = Schema.Struct({
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
-export class Info extends Schema.Class("ProviderConfig")({
+export const Info = Schema.Struct({
api: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
@@ -107,8 +107,9 @@ export class Info extends Schema.Class("ProviderConfig")({
),
),
models: Schema.optional(Schema.Record(Schema.String, Model)),
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "ProviderConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type
export * as ConfigProvider from "./provider"
diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts
index 969a79964b..3ce4fe6262 100644
--- a/packages/opencode/src/config/server.ts
+++ b/packages/opencode/src/config/server.ts
@@ -1,7 +1,8 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
-export class Server extends Schema.Class("ServerConfig")({
+export const Server = Schema.Struct({
port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
description: "Port to listen on",
}),
@@ -13,8 +14,9 @@ export class Server extends Schema.Class("ServerConfig")({
cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Additional domains to allow for CORS",
}),
-}) {
- static readonly zod = zod(this)
-}
+})
+ .annotate({ identifier: "ServerConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Server = Schema.Schema.Type
export * as ConfigServer from "./server"
diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts
index 4c7ced010d..5a0850a249 100644
--- a/packages/opencode/src/control-plane/schema.ts
+++ b/packages/opencode/src/control-plane/schema.ts
@@ -1,8 +1,7 @@
import { Schema } from "effect"
-import z from "zod"
import { Identifier } from "@/id/id"
-import { ZodOverride } from "@/util/effect-zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
@@ -14,6 +13,6 @@ export type WorkspaceID = typeof workspaceIdSchema.Type
export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
- zod: Identifier.schema("workspace").pipe(z.custom()),
+ zod: zod(schema),
})),
)
diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts
index 85934ce9c9..53a2c10119 100644
--- a/packages/opencode/src/format/index.ts
+++ b/packages/opencode/src/format/index.ts
@@ -25,7 +25,7 @@ export type Status = z.infer
export interface Interface {
readonly init: () => Effect.Effect
readonly status: () => Effect.Effect
- readonly file: (filepath: string) => Effect.Effect
+ readonly file: (filepath: string) => Effect.Effect
}
export class Service extends Context.Service()("@opencode/Format") {}
@@ -70,16 +70,19 @@ export const layer = Layer.effect(
}
}),
)
- return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! }))
+ return checks
+ .filter((x): x is { item: Formatter.Info; cmd: string[] } => x.cmd !== false)
+ .map((x) => ({ item: x.item, cmd: x.cmd }))
}
function formatFile(filepath: string) {
return Effect.gen(function* () {
log.info("formatting", { file: filepath })
- const ext = path.extname(filepath)
+ const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath)))
- for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) {
- if (cmd === false) continue
+ if (!formatters.length) return false
+
+ for (const { item, cmd } of formatters) {
log.info("running", { command: cmd })
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
const dir = yield* InstanceState.directory
@@ -113,6 +116,8 @@ export const layer = Layer.effect(
})
}
}
+
+ return true
})
}
@@ -188,7 +193,7 @@ export const layer = Layer.effect(
const file = Effect.fn("Format.file")(function* (filepath: string) {
const { formatFile } = yield* InstanceState.get(state)
- yield* formatFile(filepath)
+ return yield* formatFile(filepath)
})
return Service.of({ init, status, file })
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index b20e8ae7f0..f6d5110a6c 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -14,6 +14,16 @@ import { withTimeout } from "../util/timeout"
import { Filesystem } from "../util"
const DIAGNOSTICS_DEBOUNCE_MS = 150
+const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000
+const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000
+const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000
+
+const INITIALIZE_TIMEOUT_MS = 45_000
+
+// LSP spec constants
+const FILE_CHANGE_CREATED = 1
+const FILE_CHANGE_CHANGED = 2
+const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
const log = Log.create({ service: "lsp.client" })
@@ -38,48 +48,194 @@ export const Event = {
),
}
+type DocumentDiagnosticReport = {
+ items?: Diagnostic[]
+ relatedDocuments?: Record
+}
+
+type WorkspaceDiagnosticReport = {
+ items?: {
+ uri?: string
+ items?: Diagnostic[]
+ }[]
+}
+
+type DiagnosticRequestResult = {
+ handled: boolean
+ matched: boolean
+ byFile: Map
+}
+
+type CapabilityRegistration = {
+ id: string
+ method: string
+ registerOptions?: {
+ identifier?: string
+ workspaceDiagnostics?: boolean
+ }
+}
+
+type ServerCapabilities = {
+ textDocumentSync?:
+ | number
+ | {
+ change?: number
+ }
+ diagnosticProvider?: unknown
+ [key: string]: unknown
+}
+
+function getFilePath(uri: string) {
+ if (!uri.startsWith("file://")) return
+ return Filesystem.normalizePath(fileURLToPath(uri))
+}
+
+function getSyncKind(capabilities?: ServerCapabilities) {
+ if (!capabilities) return
+ const sync = capabilities.textDocumentSync
+ if (typeof sync === "number") return sync
+ return sync?.change
+}
+
+function endPosition(text: string) {
+ const lines = text.split(/\r\n|\r|\n/)
+ return {
+ line: lines.length - 1,
+ character: lines.at(-1)?.length ?? 0,
+ }
+}
+
+function dedupeDiagnostics(items: Diagnostic[]) {
+ const seen = new Set()
+ return items.filter((item) => {
+ const key = JSON.stringify({
+ code: item.code,
+ severity: item.severity,
+ message: item.message,
+ source: item.source,
+ range: item.range,
+ })
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+function configurationValue(settings: unknown, section?: string) {
+ if (!section) return settings ?? null
+ const result = section.split(".").reduce((acc, key) => {
+ if (!acc || typeof acc !== "object" || !(key in acc)) return undefined
+ return (acc as Record)[key]
+ }, settings)
+ return result ?? null
+}
+
+// TypeScript's built-in LSP pushes diagnostics aggressively on first open.
+// We seed the push cache on the very first publish so waitForFreshPush can
+// resolve immediately instead of waiting for a second debounced push.
+function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
+ return serverID === "typescript"
+}
+
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")
+ const logger = log.clone().tag("serverID", input.serverID)
+ logger.info("starting client")
const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout as any),
new StreamMessageWriter(input.server.process.stdin as any),
)
+ // Server stderr can contain both real errors and routine informational logs,
+ // which is normal stderr practice for some tools. Keep the raw stream at
+ // debug so users can opt in with --print-logs --log-level DEBUG without
+ // polluting normal logs.
+ input.server.process.stderr?.on("data", (data: Buffer) => {
+ const text = data.toString().trim()
+ if (text) logger.debug("server stderr", { text: text.slice(0, 1000) })
+ })
+
+ // --- Connection state ---
+
+ const pushDiagnostics = new Map()
+ const pullDiagnostics = new Map()
+ const published = new Map()
+ const diagnosticRegistrations = new Map()
+ const registrationListeners = new Set<() => void>()
+ const mergedDiagnostics = (filePath: string) =>
+ dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
+ const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
+ pushDiagnostics.set(filePath, next)
+ Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
+ }
+ const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
+ pullDiagnostics.set(filePath, next)
+ }
+ const emitRegistrationChange = () => {
+ for (const listener of [...registrationListeners]) listener()
+ }
+
+ // --- LSP connection handlers ---
- const diagnostics = new Map()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
- const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
- l.info("textDocument/publishDiagnostics", {
+ const filePath = getFilePath(params.uri)
+ if (!filePath) return
+ logger.info("textDocument/publishDiagnostics", {
path: filePath,
count: params.diagnostics.length,
+ version: params.version,
})
- const exists = diagnostics.has(filePath)
- diagnostics.set(filePath, params.diagnostics)
- if (!exists && input.serverID === "typescript") return
- Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
+ published.set(filePath, {
+ at: Date.now(),
+ version: typeof params.version === "number" ? params.version : undefined,
+ })
+ if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) {
+ pushDiagnostics.set(filePath, params.diagnostics)
+ return
+ }
+ updatePushDiagnostics(filePath, params.diagnostics)
})
connection.onRequest("window/workDoneProgress/create", (params) => {
- l.info("window/workDoneProgress/create", params)
+ logger.info("window/workDoneProgress/create", params)
return null
})
- connection.onRequest("workspace/configuration", async () => {
- // Return server initialization options
- return [input.server.initialization ?? {}]
+ connection.onRequest("workspace/configuration", async (params) => {
+ const items = (params as { items?: { section?: string }[] }).items ?? []
+ return items.map((item) => configurationValue(input.server.initialization, item.section))
+ })
+ connection.onRequest("client/registerCapability", async (params) => {
+ const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? []
+ let changed = false
+ for (const registration of registrations) {
+ if (registration.method !== "textDocument/diagnostic") continue
+ diagnosticRegistrations.set(registration.id, registration)
+ changed = true
+ }
+ if (changed) emitRegistrationChange()
+ })
+ connection.onRequest("client/unregisterCapability", async (params) => {
+ const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? []
+ let changed = false
+ for (const registration of registrations) {
+ if (registration.method !== "textDocument/diagnostic") continue
+ diagnosticRegistrations.delete(registration.id)
+ changed = true
+ }
+ if (changed) emitRegistrationChange()
})
- connection.onRequest("client/registerCapability", async () => {})
- connection.onRequest("client/unregisterCapability", async () => {})
connection.onRequest("workspace/workspaceFolders", async () => [
{
name: "workspace",
uri: pathToFileURL(input.root).href,
},
])
+ connection.onRequest("workspace/diagnostic/refresh", async () => null)
connection.listen()
- l.info("sending initialize")
- await withTimeout(
- connection.sendRequest("initialize", {
+ // --- Initialize handshake ---
+
+ logger.info("sending initialize")
+ const initialized = await withTimeout(
+ connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
rootUri: pathToFileURL(input.root).href,
processId: input.server.process.pid,
workspaceFolders: [
@@ -100,21 +256,28 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
didChangeWatchedFiles: {
dynamicRegistration: true,
},
+ diagnostics: {
+ refreshSupport: false,
+ },
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
+ diagnostic: {
+ dynamicRegistration: true,
+ relatedDocumentSupport: true,
+ },
publishDiagnostics: {
- versionSupport: true,
+ versionSupport: false,
},
},
},
}),
- 45_000,
+ INITIALIZE_TIMEOUT_MS,
).catch((err) => {
- l.error("initialize error", { error: err })
+ logger.error("initialize error", { error: err })
throw new InitializeError(
{ serverID: input.serverID },
{
@@ -123,6 +286,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
)
})
+ const syncKind = getSyncKind(initialized.capabilities)
+ const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider)
+
await connection.sendNotification("initialized", {})
if (input.server.initialization) {
@@ -131,9 +297,280 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
})
}
- const files: {
- [path: string]: number
- } = {}
+ const files: Record = {}
+
+ // --- Diagnostic helpers ---
+
+ const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
+ const handled = results.some((result) => result.handled)
+ const matched = results.some((result) => result.matched)
+ if (!handled) return { handled: false, matched: false }
+
+ const merged = new Map()
+ for (const result of results) {
+ for (const [target, items] of result.byFile.entries()) {
+ const existing = merged.get(target) ?? []
+ merged.set(target, existing.concat(items))
+ }
+ }
+
+ if (matched && !merged.has(filePath)) merged.set(filePath, [])
+ for (const [target, items] of merged.entries()) {
+ updatePullDiagnostics(target, dedupeDiagnostics(items))
+ }
+
+ return { handled, matched }
+ }
+
+ async function requestDiagnosticReport(filePath: string, identifier?: string): Promise {
+ const report = await withTimeout(
+ connection.sendRequest("textDocument/diagnostic", {
+ ...(identifier ? { identifier } : {}),
+ textDocument: {
+ uri: pathToFileURL(filePath).href,
+ },
+ }),
+ DIAGNOSTICS_REQUEST_TIMEOUT_MS,
+ ).catch(() => null)
+ if (!report) return { handled: false, matched: false, byFile: new Map() }
+
+ const byFile = new Map()
+ const push = (target: string, items: Diagnostic[]) => {
+ const existing = byFile.get(target) ?? []
+ byFile.set(target, existing.concat(items))
+ }
+
+ let handled = false
+ let matched = false
+ if (Array.isArray(report.items)) {
+ push(filePath, report.items)
+ handled = true
+ matched = true
+ }
+ for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
+ const relatedPath = getFilePath(uri)
+ if (!relatedPath || !Array.isArray(related.items)) continue
+ push(relatedPath, related.items)
+ handled = true
+ matched = matched || relatedPath === filePath
+ }
+
+ return { handled, matched, byFile }
+ }
+
+ async function requestWorkspaceDiagnosticReport(
+ filePath: string,
+ identifier?: string,
+ ): Promise {
+ const report = await withTimeout(
+ connection.sendRequest("workspace/diagnostic", {
+ ...(identifier ? { identifier } : {}),
+ previousResultIds: [],
+ }),
+ DIAGNOSTICS_REQUEST_TIMEOUT_MS,
+ ).catch(() => null)
+ if (!report) return { handled: false, matched: false, byFile: new Map() }
+
+ const byFile = new Map()
+ let matched = false
+ for (const item of report.items ?? []) {
+ const relatedPath = item.uri ? getFilePath(item.uri) : undefined
+ if (!relatedPath || !Array.isArray(item.items)) continue
+ const existing = byFile.get(relatedPath) ?? []
+ byFile.set(relatedPath, existing.concat(item.items))
+ matched = matched || relatedPath === filePath
+ }
+
+ return { handled: true, matched, byFile }
+ }
+
+ function documentPullState() {
+ const documentRegistrations = [...diagnosticRegistrations.values()].filter(
+ (registration) => registration.registerOptions?.workspaceDiagnostics !== true,
+ )
+ return {
+ documentIdentifiers: [
+ ...new Set(documentRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])),
+ ],
+ supported: hasStaticPullDiagnostics || documentRegistrations.length > 0,
+ }
+ }
+
+ function workspacePullState() {
+ const workspaceRegistrations = [...diagnosticRegistrations.values()].filter(
+ (registration) => registration.registerOptions?.workspaceDiagnostics === true,
+ )
+ return {
+ workspaceIdentifiers: [
+ ...new Set(workspaceRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])),
+ ],
+ supported: workspaceRegistrations.length > 0,
+ }
+ }
+
+ const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) =>
+ results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0)
+
+ async function requestDiagnostics(
+ filePath: string,
+ requests: Promise[],
+ done: (results: DiagnosticRequestResult[]) => boolean,
+ ) {
+ if (!requests.length) return { handled: false, matched: false }
+
+ const results: DiagnosticRequestResult[] = []
+ return new Promise<{ handled: boolean; matched: boolean }>((resolve) => {
+ let pending = requests.length
+ let resolved = false
+ const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
+ if (resolved) return
+ if (!force && !done(results)) return
+ resolved = true
+ resolve(merged)
+ }
+
+ for (const request of requests) {
+ request.then((result) => {
+ results.push(result)
+ pending -= 1
+ const merged = mergeResults(filePath, results)
+ finish(merged)
+ if (pending === 0) finish(merged, true)
+ })
+ }
+ })
+ }
+
+ // LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one
+ // batch already produced diagnostics for the current file. Let slower pulls keep
+ // merging in the background; do not sequence identifier-by-identifier, and do
+ // not add a post-match settle/debounce delay. See PR #23771.
+ async function requestDocumentDiagnostics(filePath: string) {
+ const state = documentPullState()
+ if (!state.supported) return { handled: false, matched: false }
+ return requestDiagnostics(
+ filePath,
+ [
+ requestDiagnosticReport(filePath),
+ ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),
+ ],
+ (results) => hasCurrentFileDiagnostics(filePath, results),
+ )
+ }
+
+ async function requestFullDiagnostics(filePath: string) {
+ const documentState = documentPullState()
+ const workspaceState = workspacePullState()
+ if (!documentState.supported && !workspaceState.supported) return { handled: false, matched: false }
+ return mergeResults(
+ filePath,
+ await Promise.all([
+ ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []),
+ ...documentState.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),
+ ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []),
+ ...workspaceState.workspaceIdentifiers.map((identifier) =>
+ requestWorkspaceDiagnosticReport(filePath, identifier),
+ ),
+ ]),
+ )
+ }
+
+ function waitForRegistrationChange(timeout: number) {
+ if (timeout <= 0) return Promise.resolve(false)
+ return new Promise((resolve) => {
+ let finished = false
+ let timer: ReturnType | undefined
+ const finish = (result: boolean) => {
+ if (finished) return
+ finished = true
+ if (timer) clearTimeout(timer)
+ registrationListeners.delete(listener)
+ resolve(result)
+ }
+ const listener = () => finish(true)
+ registrationListeners.add(listener)
+ timer = setTimeout(() => finish(false), timeout)
+ })
+ }
+
+ function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) {
+ if (request.timeout <= 0) return Promise.resolve(false)
+ return new Promise((resolve) => {
+ let finished = false
+ let debounceTimer: ReturnType | undefined
+ let timeoutTimer: ReturnType | undefined
+ let unsub: (() => void) | undefined
+ const finish = (result: boolean) => {
+ if (finished) return
+ finished = true
+ if (debounceTimer) clearTimeout(debounceTimer)
+ if (timeoutTimer) clearTimeout(timeoutTimer)
+ unsub?.()
+ resolve(result)
+ }
+ const schedule = () => {
+ const hit = published.get(request.path)
+ if (!hit) return
+ if (typeof hit.version === "number" && hit.version !== request.version) return
+ if (hit.at < request.after && hit.version !== request.version) return
+ if (debounceTimer) clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)))
+ }
+
+ timeoutTimer = setTimeout(() => finish(false), request.timeout)
+ unsub = Bus.subscribe(Event.Diagnostics, (event) => {
+ if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
+ schedule()
+ })
+ schedule()
+ })
+ }
+
+ async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) {
+ const startedAt = request.after ?? Date.now()
+ const pushWait = waitForFreshPush({
+ path: request.path,
+ version: request.version,
+ after: startedAt,
+ timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,
+ })
+
+ while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {
+ const result = await requestDocumentDiagnostics(request.path)
+ if (result.matched) return
+ const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
+ if (remaining <= 0) return
+ const next = await Promise.race([
+ pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
+ waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),
+ ])
+ if (next !== "registration") return
+ }
+ }
+
+ async function waitForFullDiagnostics(request: { path: string; version: number; after?: number }) {
+ const startedAt = request.after ?? Date.now()
+ const pushWait = waitForFreshPush({
+ path: request.path,
+ version: request.version,
+ after: startedAt,
+ timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS,
+ })
+
+ while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) {
+ const result = await requestFullDiagnostics(request.path)
+ if (result.handled || result.matched) return
+ const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
+ if (remaining <= 0) return
+ const next = await Promise.race([
+ pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
+ waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),
+ ])
+ if (next !== "registration") return
+ }
+ }
+
+ // --- Public API ---
const result = {
root: input.root,
@@ -145,26 +582,32 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
},
notify: {
async open(request: { path: string }) {
- request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
+ request.path = Filesystem.normalizePath(
+ 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[request.path]
- if (version !== undefined) {
- log.info("workspace/didChangeWatchedFiles", request)
+ const document = files[request.path]
+ if (document !== undefined) {
+ // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only
+ // re-emit diagnostics when the content actually changes, so clearing
+ // here would lose errors for no-op touchFile calls. Let the server's
+ // next push/pull overwrite naturally.
+ logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(request.path).href,
- type: 2, // Changed
+ type: FILE_CHANGE_CHANGED,
},
],
})
- const next = version + 1
- files[request.path] = next
- log.info("textDocument/didChange", {
+ const next = document.version + 1
+ files[request.path] = { version: next, text }
+ logger.info("textDocument/didChange", {
path: request.path,
version: next,
})
@@ -173,23 +616,35 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
uri: pathToFileURL(request.path).href,
version: next,
},
- contentChanges: [{ text }],
+ contentChanges:
+ syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
+ ? [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: endPosition(document.text),
+ },
+ text,
+ },
+ ]
+ : [{ text }],
})
- return
+ return next
}
- log.info("workspace/didChangeWatchedFiles", request)
+ logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(request.path).href,
- type: 1, // Created
+ type: FILE_CHANGE_CREATED,
},
],
})
- log.info("textDocument/didOpen", request)
- diagnostics.delete(request.path)
+ logger.info("textDocument/didOpen", request)
+ pushDiagnostics.delete(request.path)
+ pullDiagnostics.delete(request.path)
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(request.path).href,
@@ -198,52 +653,42 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
text,
},
})
- files[request.path] = 0
- return
+ files[request.path] = { version: 0, text }
+ return 0
},
},
get diagnostics() {
- return diagnostics
+ const result = new Map()
+ for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {
+ result.set(key, mergedDiagnostics(key))
+ }
+ return result
},
- async waitForDiagnostics(request: { path: string }) {
+ async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full"; after?: number }) {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
)
- log.info("waiting for diagnostics", { path: normalizedPath })
- let unsub: () => void
- let debounceTimer: ReturnType | undefined
- return await withTimeout(
- new Promise((resolve) => {
- unsub = Bus.subscribe(Event.Diagnostics, (event) => {
- if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
- // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
- if (debounceTimer) clearTimeout(debounceTimer)
- debounceTimer = setTimeout(() => {
- log.info("got diagnostics", { path: normalizedPath })
- unsub?.()
- resolve()
- }, DIAGNOSTICS_DEBOUNCE_MS)
- }
- })
- }),
- 3000,
- )
- .catch(() => {})
- .finally(() => {
- if (debounceTimer) clearTimeout(debounceTimer)
- unsub?.()
- })
+ logger.info("waiting for diagnostics", {
+ path: normalizedPath,
+ mode: request.mode ?? "full",
+ version: request.version,
+ })
+ if (request.mode === "document") {
+ await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
+ return
+ }
+ await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
},
async shutdown() {
- l.info("shutting down")
+ logger.info("shutting down")
connection.end()
connection.dispose()
await Process.stop(input.server.process)
- l.info("shutdown")
+ logger.info("shutdown")
},
}
- l.info("initialized")
+ logger.info("initialized")
return result
}
diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts
index aa519f9f7e..4c46cd9aa7 100644
--- a/packages/opencode/src/lsp/lsp.ts
+++ b/packages/opencode/src/lsp/lsp.ts
@@ -10,9 +10,11 @@ import { Config } from "../config"
import { Flag } from "@/flag/flag"
import { Process } from "../util"
import { spawn as lspspawn } from "./launch"
-import { Effect, Layer, Context } from "effect"
+import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { withStatics } from "@/util/schema"
+import { zod, ZodOverride } from "@/util/effect-zod"
const log = Log.create({ service: "lsp" })
@@ -20,60 +22,53 @@ export const Event = {
Updated: BusEvent.define("lsp.updated", z.object({})),
}
-export const Range = z
- .object({
- start: z.object({
- line: z.number(),
- character: z.number(),
- }),
- end: z.object({
- line: z.number(),
- character: z.number(),
- }),
- })
- .meta({
- ref: "Range",
- })
-export type Range = z.infer
+const Position = Schema.Struct({
+ line: Schema.Number,
+ character: Schema.Number,
+})
-export const Symbol = z
- .object({
- name: z.string(),
- kind: z.number(),
- location: z.object({
- uri: z.string(),
- range: Range,
- }),
- })
- .meta({
- ref: "Symbol",
- })
-export type Symbol = z.infer
+export const Range = Schema.Struct({
+ start: Position,
+ end: Position,
+})
+ .annotate({ identifier: "Range" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Range = typeof Range.Type
-export const DocumentSymbol = z
- .object({
- name: z.string(),
- detail: z.string().optional(),
- kind: z.number(),
+export const Symbol = Schema.Struct({
+ name: Schema.String,
+ kind: Schema.Number,
+ location: Schema.Struct({
+ uri: Schema.String,
range: Range,
- selectionRange: Range,
- })
- .meta({
- ref: "DocumentSymbol",
- })
-export type DocumentSymbol = z.infer
+ }),
+})
+ .annotate({ identifier: "Symbol" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Symbol = typeof Symbol.Type
-export const Status = z
- .object({
- id: z.string(),
- name: z.string(),
- root: z.string(),
- status: z.union([z.literal("connected"), z.literal("error")]),
- })
- .meta({
- ref: "LSPStatus",
- })
-export type Status = z.infer
+export const DocumentSymbol = Schema.Struct({
+ name: Schema.String,
+ detail: Schema.optional(Schema.String),
+ kind: Schema.Number,
+ range: Range,
+ selectionRange: Range,
+})
+ .annotate({ identifier: "DocumentSymbol" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type DocumentSymbol = typeof DocumentSymbol.Type
+
+export const Status = Schema.Struct({
+ id: Schema.String,
+ name: Schema.String,
+ root: Schema.String,
+ status: Schema.Literals(["connected", "error"]).annotate({
+ [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
+ }),
+})
+ .annotate({ identifier: "LSPStatus" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Status = typeof Status.Type
enum SymbolKind {
File = 1,
@@ -141,7 +136,7 @@ export interface Interface {
readonly init: () => Effect.Effect
readonly status: () => Effect.Effect
readonly hasClients: (file: string) => Effect.Effect
- readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect
+ readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect
readonly diagnostics: () => Effect.Effect>
readonly hover: (input: LocInput) => Effect.Effect
readonly definition: (input: LocInput) => Effect.Effect
@@ -363,15 +358,21 @@ export const layer = Layer.effect(
})
})
- const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
+ const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {
log.info("touching file", { file: input })
const clients = yield* getClients(input)
yield* Effect.promise(() =>
Promise.all(
clients.map(async (client) => {
- const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
- await client.notify.open({ path: input })
- return wait
+ const after = Date.now()
+ const version = await client.notify.open({ path: input })
+ if (!diagnostics) return
+ return client.waitForDiagnostics({
+ path: input,
+ version,
+ mode: diagnostics,
+ after,
+ })
}),
).catch((err) => {
log.error("failed to touch file", { err, file: input })
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index 9182368063..a0cb8fe388 100644
--- a/packages/opencode/src/lsp/server.ts
+++ b/packages/opencode/src/lsp/server.ts
@@ -490,7 +490,7 @@ export const Pyright: Info = {
const args = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- const resolved = await Npm.which("pyright")
+ const resolved = await Npm.which("pyright", "pyright-langserver")
if (!resolved) return
binary = resolved
}
@@ -705,32 +705,32 @@ export const CSharp: Info = {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
- let bin = which("csharp-ls")
+ let bin = which("roslyn-language-server")
if (!bin) {
if (!which("dotnet")) {
- log.error(".NET SDK is required to install csharp-ls")
+ log.error(".NET SDK is required to install roslyn-language-server")
return
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- log.info("installing csharp-ls via dotnet tool")
- const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], {
+ log.info("installing roslyn-language-server via dotnet tool")
+ const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
- log.error("Failed to install csharp-ls")
+ log.error("Failed to install roslyn-language-server")
return
}
- bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : ""))
- log.info(`installed csharp-ls`, { bin })
+ bin = path.join(Global.Path.bin, "roslyn-language-server" + (process.platform === "win32" ? ".exe" : ""))
+ log.info(`installed roslyn-language-server`, { bin })
}
return {
- process: spawn(bin, {
+ process: spawn(bin, ["--stdio", "--autoLoadProjects"], {
cwd: root,
}),
}
diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts
index 477e99e06a..fc8497d20b 100644
--- a/packages/opencode/src/npm/index.ts
+++ b/packages/opencode/src/npm/index.ts
@@ -34,7 +34,7 @@ export interface Interface {
},
) => Effect.Effect
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect
- readonly which: (pkg: string) => Effect.Effect>
+ readonly which: (pkg: string, bin?: string) => Effect.Effect>
}
export class Service extends Context.Service()("@opencode/Npm") {}
@@ -207,7 +207,7 @@ export const layer = Layer.effect(
return
}, Effect.scoped)
- const which = Effect.fn("Npm.which")(function* (pkg: string) {
+ const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
@@ -215,6 +215,9 @@ export const layer = Layer.effect(
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return Option.none()
+ // Caller picked a specific bin (e.g. pyright exposes both `pyright` and
+ // `pyright-langserver`); trust the hint if the package provides it.
+ if (bin) return files.includes(bin) ? Option.some(bin) : Option.none()
if (files.length === 1) return Option.some(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
@@ -223,11 +226,11 @@ export const layer = Layer.effect(
const parsed = pkgJson.value as { bin?: string | Record }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
- const bin = parsed.bin
- if (typeof bin === "string") return Option.some(unscoped)
- const keys = Object.keys(bin)
+ const parsedBin = parsed.bin
+ if (typeof parsedBin === "string") return Option.some(unscoped)
+ const keys = Object.keys(parsedBin)
if (keys.length === 1) return Option.some(keys[0])
- return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
+ return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts
index 19e1d7555b..3662f9e908 100644
--- a/packages/opencode/src/patch/index.ts
+++ b/packages/opencode/src/patch/index.ts
@@ -3,6 +3,7 @@ import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util"
+import * as Bom from "../util/bom"
const log = Log.create({ service: "patch" })
@@ -305,18 +306,19 @@ export function maybeParseApplyPatch(
interface ApplyPatchFileUpdate {
unified_diff: string
content: string
+ bom: boolean
}
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
- let originalContent: string
+ let originalContent: ReturnType
try {
- originalContent = readFileSync(filePath, "utf-8")
+ originalContent = Bom.split(readFileSync(filePath, "utf-8"))
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
- let originalLines = originalContent.split("\n")
+ let originalLines = originalContent.text.split("\n")
// Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
@@ -331,14 +333,16 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile
newLines.push("")
}
- const newContent = newLines.join("\n")
+ const next = Bom.split(newLines.join("\n"))
+ const newContent = next.text
// Generate unified diff
- const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
+ const unifiedDiff = generateUnifiedDiff(originalContent.text, newContent)
return {
unified_diff: unifiedDiff,
content: newContent,
+ bom: originalContent.bom || next.bom,
}
}
@@ -553,13 +557,13 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise {
await fs.mkdir(moveDir, { recursive: true })
}
- await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
+ await fs.writeFile(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
await fs.unlink(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else {
// Regular update
- await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
+ await fs.writeFile(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
}
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index b9a221155c..6943b3d93b 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -290,8 +290,18 @@ function expand(pattern: string): string {
}
export function fromConfig(permission: ConfigPermission.Info) {
+ // Sort top-level keys so wildcard permissions (`*`, `mcp_*`) come before
+ // specific ones. Combined with `findLast` in evaluate(), this gives the
+ // intuitive semantic "specific tool rules override the `*` fallback"
+ // regardless of the user's JSON key order. Sub-pattern order inside a
+ // single permission key is preserved — only top-level keys are sorted.
+ const entries = Object.entries(permission).sort(([a], [b]) => {
+ const aWild = a.includes("*")
+ const bWild = b.includes("*")
+ return aWild === bWild ? 0 : aWild ? -1 : 1
+ })
const ruleset: Ruleset = []
- for (const [key, value] of Object.entries(permission)) {
+ for (const [key, value] of entries) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
continue
@@ -307,7 +317,7 @@ export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
-const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
+const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export function disabled(tools: string[], ruleset: Ruleset): Set {
const result = new Set()
diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts
index 6ac9389a58..4eddc6a47a 100644
--- a/packages/opencode/src/permission/schema.ts
+++ b/packages/opencode/src/permission/schema.ts
@@ -1,8 +1,7 @@
import { Schema } from "effect"
-import z from "zod"
import { Identifier } from "@/id/id"
-import { ZodOverride } from "@/util/effect-zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema"
export class PermissionID extends Newtype()(
@@ -13,5 +12,5 @@ export class PermissionID extends Newtype()(
return this.make(Identifier.ascending("permission", id))
}
- static readonly zod = Identifier.schema("permission") as unknown as z.ZodType
+ static readonly zod = zod(this)
}
diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts
index c61cb78509..84d314f476 100644
--- a/packages/opencode/src/plugin/codex.ts
+++ b/packages/opencode/src/plugin/codex.ts
@@ -374,6 +374,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise {
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.4-mini",
+ "gpt-5.5",
])
for (const [modelId, model] of Object.entries(provider.models)) {
if (modelId.includes("codex")) continue
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 6a2132274a..d628f87f97 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -207,13 +207,13 @@ export const layer: Layer.Layer<
vcs: fakeVcs,
}
}
- const worktree = (() => {
- const common = resolveGitPath(sandbox, commonDir.text.trim())
- return common === sandbox ? sandbox : pathSvc.dirname(common)
- })()
+ const common = resolveGitPath(sandbox, commonDir.text.trim())
+ const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
+ const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
+ const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
if (id == null) {
- id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
+ id = yield* readCachedProjectId(common)
}
if (!id) {
@@ -226,7 +226,7 @@ export const layer: Layer.Layer<
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
- yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
+ yield* fs.writeFileString(pathSvc.join(common, "opencode"), id).pipe(Effect.ignore)
}
}
diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts
index d10c82e2c3..7708b8de1e 100644
--- a/packages/opencode/src/project/schema.ts
+++ b/packages/opencode/src/project/schema.ts
@@ -1,6 +1,6 @@
import { Schema } from "effect"
-import z from "zod"
+import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID"))
@@ -10,6 +10,6 @@ export type ProjectID = typeof projectIdSchema.Type
export const ProjectID = projectIdSchema.pipe(
withStatics((schema: typeof projectIdSchema) => ({
global: schema.make("global"),
- zod: z.string().pipe(z.custom()),
+ zod: zod(schema),
})),
)
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 6e116fe41e..d643f25373 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -19,6 +19,7 @@ import { zod } from "@/util/effect-zod"
import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
+import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { EffectBridge } from "@/effect"
import { InstanceState } from "@/effect"
@@ -1506,7 +1507,10 @@ const layer: Layer.Layer<
installedPath = model.api.npm
}
- const mod = await import(installedPath)
+ // `installedPath` is a local entry path or an existing `file://` URL. Normalize
+ // only path inputs so Node on Windows accepts the dynamic import.
+ const importSpec = installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href
+ const mod = await import(importSpec)
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn({
diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts
index 702616018a..ea3cac3424 100644
--- a/packages/opencode/src/provider/schema.ts
+++ b/packages/opencode/src/provider/schema.ts
@@ -1,6 +1,6 @@
import { Schema } from "effect"
-import z from "zod"
+import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID"))
@@ -9,7 +9,7 @@ export type ProviderID = typeof providerIdSchema.Type
export const ProviderID = providerIdSchema.pipe(
withStatics((schema: typeof providerIdSchema) => ({
- zod: z.string().pipe(z.custom()),
+ zod: zod(schema),
// Well-known providers
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
@@ -30,7 +30,7 @@ const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID"))
export type ModelID = typeof modelIdSchema.Type
export const ModelID = modelIdSchema.pipe(
- withStatics((_schema: typeof modelIdSchema) => ({
- zod: z.string().pipe(z.custom()),
+ withStatics((schema: typeof modelIdSchema) => ({
+ zod: zod(schema),
})),
)
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 4ed43ce994..1d84c7c931 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -408,9 +408,8 @@ export function variants(model: Provider.Model): Record ({
ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
- zod: Identifier.schema("pty").pipe(z.custom()),
+ zod: zod(schema),
})),
)
diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts
index 41186161d0..f7a0e096a3 100644
--- a/packages/opencode/src/question/schema.ts
+++ b/packages/opencode/src/question/schema.ts
@@ -1,8 +1,7 @@
import { Schema } from "effect"
-import z from "zod"
import { Identifier } from "@/id/id"
-import { ZodOverride } from "@/util/effect-zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema"
export class QuestionID extends Newtype()(
@@ -13,5 +12,5 @@ export class QuestionID extends Newtype()(
return this.make(Identifier.ascending("question", id))
}
- static readonly zod = Identifier.schema("question") as unknown as z.ZodType
+ static readonly zod = zod(this)
}
diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts
index 8208cf9669..54f9972e02 100644
--- a/packages/opencode/src/server/routes/global.ts
+++ b/packages/opencode/src/server/routes/global.ts
@@ -147,7 +147,7 @@ export const GlobalRoutes = lazy(() =>
description: "Get global config info",
content: {
"application/json": {
- schema: resolver(Config.Info),
+ schema: resolver(Config.Info.zod),
},
},
},
@@ -168,14 +168,14 @@ export const GlobalRoutes = lazy(() =>
description: "Successfully updated global config",
content: {
"application/json": {
- schema: resolver(Config.Info),
+ schema: resolver(Config.Info.zod),
},
},
},
...errors(400),
},
}),
- validator("json", Config.Info),
+ validator("json", Config.Info.zod),
async (c) => {
const config = c.req.valid("json")
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts
index 7f368cd31c..88e5feef9d 100644
--- a/packages/opencode/src/server/routes/instance/config.ts
+++ b/packages/opencode/src/server/routes/instance/config.ts
@@ -20,7 +20,7 @@ export const ConfigRoutes = lazy(() =>
description: "Get config info",
content: {
"application/json": {
- schema: resolver(Config.Info),
+ schema: resolver(Config.Info.zod),
},
},
},
@@ -43,14 +43,14 @@ export const ConfigRoutes = lazy(() =>
description: "Successfully updated config",
content: {
"application/json": {
- schema: resolver(Config.Info),
+ schema: resolver(Config.Info.zod),
},
},
},
...errors(400),
},
}),
- validator("json", Config.Info),
+ validator("json", Config.Info.zod),
async (c) =>
jsonRequest("ConfigRoutes.update", c, function* () {
const config = c.req.valid("json")
diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts
index bbef679a85..f92fe6e7e5 100644
--- a/packages/opencode/src/server/routes/instance/file.ts
+++ b/packages/opencode/src/server/routes/instance/file.ts
@@ -90,7 +90,7 @@ export const FileRoutes = lazy(() =>
description: "Symbols",
content: {
"application/json": {
- schema: resolver(LSP.Symbol.array()),
+ schema: resolver(LSP.Symbol.zod.array()),
},
},
},
diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts
index 14aa94f9fc..2dfdec172a 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/config.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts
@@ -9,6 +9,15 @@ export const ConfigApi = HttpApi.make("config")
.add(
HttpApiGroup.make("config")
.add(
+ HttpApiEndpoint.get("get", root, {
+ success: Config.Info,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "config.get",
+ summary: "Get configuration",
+ description: "Retrieve the current OpenCode configuration settings and preferences.",
+ }),
+ ),
HttpApiEndpoint.get("providers", `${root}/providers`, {
success: Provider.ConfigProvidersResult,
}).annotateMerge(
@@ -36,16 +45,23 @@ export const ConfigApi = HttpApi.make("config")
export const configHandlers = Layer.unwrap(
Effect.gen(function* () {
- const svc = yield* Provider.Service
+ const providerSvc = yield* Provider.Service
+ const configSvc = yield* Config.Service
+
+ const get = Effect.fn("ConfigHttpApi.get")(function* () {
+ return yield* configSvc.get()
+ })
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
- const providers = yield* svc.list()
+ const providers = yield* providerSvc.list()
return {
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
- return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers))
+ return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
+ handlers.handle("get", get).handle("providers", providers),
+ )
}),
).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))
diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts
index 5cc51d27ab..e8a038fabc 100644
--- a/packages/opencode/src/server/routes/instance/index.ts
+++ b/packages/opencode/src/server/routes/instance/index.ts
@@ -40,6 +40,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
app.get("/permission", (c) => handler(c.req.raw, context))
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
+ app.get("/config", (c) => handler(c.req.raw, context))
app.get("/config/providers", (c) => handler(c.req.raw, context))
app.get("/provider", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))
@@ -259,7 +260,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
description: "LSP server status",
content: {
"application/json": {
- schema: resolver(LSP.Status.array()),
+ schema: resolver(LSP.Status.zod.array()),
},
},
},
diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts
index bf713935b0..8d03024260 100644
--- a/packages/opencode/src/server/routes/instance/session.ts
+++ b/packages/opencode/src/server/routes/instance/session.ts
@@ -471,7 +471,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully retrieved diff",
content: {
"application/json": {
- schema: resolver(Snapshot.FileDiff.array()),
+ schema: resolver(Snapshot.FileDiff.zod.array()),
},
},
},
@@ -611,7 +611,7 @@ export const SessionRoutes = lazy(() =>
description: "List of messages",
content: {
"application/json": {
- schema: resolver(MessageV2.WithParts.array()),
+ schema: resolver(MessageV2.WithParts.zod.array()),
},
},
},
@@ -701,8 +701,8 @@ export const SessionRoutes = lazy(() =>
"application/json": {
schema: resolver(
z.object({
- info: MessageV2.Info,
- parts: MessageV2.Part.array(),
+ info: MessageV2.Info.zod,
+ parts: MessageV2.Part.zod.array(),
}),
),
},
@@ -813,7 +813,7 @@ export const SessionRoutes = lazy(() =>
description: "Successfully updated part",
content: {
"application/json": {
- schema: resolver(MessageV2.Part),
+ schema: resolver(MessageV2.Part.zod),
},
},
},
@@ -828,7 +828,7 @@ export const SessionRoutes = lazy(() =>
partID: PartID.zod,
}),
),
- validator("json", MessageV2.Part),
+ validator("json", MessageV2.Part.zod),
async (c) => {
const params = c.req.valid("param")
const body = c.req.valid("json")
@@ -856,8 +856,8 @@ export const SessionRoutes = lazy(() =>
"application/json": {
schema: resolver(
z.object({
- info: MessageV2.Assistant,
- parts: MessageV2.Part.array(),
+ info: MessageV2.Assistant.zod,
+ parts: MessageV2.Part.zod.array(),
}),
),
},
@@ -882,7 +882,9 @@ export const SessionRoutes = lazy(() =>
const msg = await runRequest(
"SessionRoutes.prompt",
c,
- SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
+ SessionPrompt.Service.use((svc) =>
+ svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput),
+ ),
)
void stream.write(JSON.stringify(msg))
})
@@ -915,7 +917,9 @@ export const SessionRoutes = lazy(() =>
void runRequest(
"SessionRoutes.prompt_async",
c,
- SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
+ SessionPrompt.Service.use((svc) =>
+ svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput),
+ ),
).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
void Bus.publish(Session.Event.Error, {
@@ -940,8 +944,8 @@ export const SessionRoutes = lazy(() =>
"application/json": {
schema: resolver(
z.object({
- info: MessageV2.Assistant,
- parts: MessageV2.Part.array(),
+ info: MessageV2.Assistant.zod,
+ parts: MessageV2.Part.zod.array(),
}),
),
},
@@ -976,7 +980,7 @@ export const SessionRoutes = lazy(() =>
description: "Created message",
content: {
"application/json": {
- schema: resolver(MessageV2.WithParts),
+ schema: resolver(MessageV2.WithParts.zod),
},
},
},
diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts
index 037543064e..defdb870d7 100644
--- a/packages/opencode/src/session/compaction.ts
+++ b/packages/opencode/src/session/compaction.ts
@@ -32,16 +32,105 @@ export const Event = {
export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
+const TOOL_OUTPUT_MAX_CHARS = 2_000
const PRUNE_PROTECTED_TOOLS = ["skill"]
const DEFAULT_TAIL_TURNS = 2
const MIN_PRESERVE_RECENT_TOKENS = 2_000
const MAX_PRESERVE_RECENT_TOKENS = 8_000
+const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged:
+---
+## Goal
+- [single-sentence task summary]
+
+## Constraints & Preferences
+- [user constraints, preferences, specs, or "(none)"]
+
+## Progress
+### Done
+- [completed work or "(none)"]
+
+### In Progress
+- [current work or "(none)"]
+
+### Blocked
+- [blockers or "(none)"]
+
+## Key Decisions
+- [decision and why, or "(none)"]
+
+## Next Steps
+- [ordered next actions or "(none)"]
+
+## Critical Context
+- [important technical facts, errors, open questions, or "(none)"]
+
+## Relevant Files
+- [file or directory path: why it matters, or "(none)"]
+---
+
+Rules:
+- Keep every section, even when empty.
+- Use terse bullets, not prose paragraphs.
+- Preserve exact file paths, commands, error strings, and identifiers when known.
+- Do not mention the summary process or that context was compacted.`
type Turn = {
start: number
end: number
id: MessageID
}
+type Tail = {
+ start: number
+ id: MessageID
+}
+
+type CompletedCompaction = {
+ userIndex: number
+ assistantIndex: number
+ summary: string | undefined
+}
+
+function summaryText(message: MessageV2.WithParts) {
+ const text = message.parts
+ .filter((part): part is MessageV2.TextPart => part.type === "text")
+ .map((part) => part.text.trim())
+ .filter(Boolean)
+ .join("\n\n")
+ .trim()
+ return text || undefined
+}
+
+function completedCompactions(messages: MessageV2.WithParts[]) {
+ const users = new Map()
+ for (let i = 0; i < messages.length; i++) {
+ const msg = messages[i]
+ if (msg.info.role !== "user") continue
+ if (!msg.parts.some((part) => part.type === "compaction")) continue
+ users.set(msg.info.id, i)
+ }
+
+ return messages.flatMap((msg, assistantIndex): CompletedCompaction[] => {
+ if (msg.info.role !== "assistant") return []
+ if (!msg.info.summary || !msg.info.finish || msg.info.error) return []
+ const userIndex = users.get(msg.info.parentID)
+ if (userIndex === undefined) return []
+ return [{ userIndex, assistantIndex, summary: summaryText(msg) }]
+ })
+}
+
+function buildPrompt(input: { previousSummary?: string; context: string[] }) {
+ const anchor = input.previousSummary
+ ? [
+ "Update the anchored summary below using the conversation history above.",
+ "Preserve still-true details, remove stale details, and merge in the new facts.",
+ "",
+ input.previousSummary,
+ "",
+ ].join("\n")
+ : "Create a new anchored summary from the conversation history above."
+ return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
+}
+
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
return (
input.cfg.compaction?.preserve_recent_tokens ??
@@ -67,6 +156,31 @@ function turns(messages: MessageV2.WithParts[]) {
return result
}
+function splitTurn(input: {
+ messages: MessageV2.WithParts[]
+ turn: Turn
+ model: Provider.Model
+ budget: number
+ estimate: (input: { messages: MessageV2.WithParts[]; model: Provider.Model }) => Effect.Effect
+}) {
+ return Effect.gen(function* () {
+ if (input.budget <= 0) return undefined
+ if (input.turn.end - input.turn.start <= 1) return undefined
+ for (let start = input.turn.start + 1; start < input.turn.end; start++) {
+ const size = yield* input.estimate({
+ messages: input.messages.slice(start, input.turn.end),
+ model: input.model,
+ })
+ if (size > input.budget) continue
+ return {
+ start,
+ id: input.messages[start]!.info.id,
+ } satisfies Tail
+ }
+ return undefined
+ })
+}
+
export interface Interface {
readonly isOverflow: (input: {
tokens: MessageV2.Assistant["tokens"]
@@ -147,18 +261,28 @@ export const layer: Layer.Layer<
}),
{ concurrency: 1 },
)
- if (sizes.at(-1)! > budget) {
- log.info("tail fallback", { budget, size: sizes.at(-1) })
- return { head: input.messages, tail_start_id: undefined }
- }
let total = 0
- let keep: Turn | undefined
+ let keep: Tail | undefined
for (let i = recent.length - 1; i >= 0; i--) {
+ const turn = recent[i]!
const size = sizes[i]
- if (total + size > budget) break
- total += size
- keep = recent[i]
+ if (total + size <= budget) {
+ total += size
+ keep = { start: turn.start, id: turn.id }
+ continue
+ }
+ const remaining = budget - total
+ const split = yield* splitTurn({
+ messages: input.messages,
+ turn,
+ model: input.model,
+ budget: remaining,
+ estimate,
+ })
+ if (split) keep = split
+ else if (!keep) log.info("tail fallback", { budget, size, total })
+ break
}
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
@@ -192,17 +316,15 @@ export const layer: Layer.Layer<
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
- if (part.type === "tool")
- if (part.state.status === "completed") {
- if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
- if (part.state.time.compacted) break loop
- const estimate = Token.estimate(part.state.output)
- total += estimate
- if (total > PRUNE_PROTECT) {
- pruned += estimate
- toPrune.push(part)
- }
- }
+ if (part.type !== "tool") continue
+ if (part.state.status !== "completed") continue
+ if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
+ if (part.state.time.compacted) break loop
+ const estimate = Token.estimate(part.state.output)
+ total += estimate
+ if (total <= PRUNE_PROTECT) continue
+ pruned += estimate
+ toPrune.push(part)
}
}
@@ -263,8 +385,11 @@ export const layer: Layer.Layer<
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
const cfg = yield* config.get()
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
+ const prior = completedCompactions(history)
+ const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex]))
+ const previousSummary = prior.at(-1)?.summary
const selected = yield* select({
- messages: history,
+ messages: history.filter((_, index) => !hidden.has(index)),
cfg,
model,
})
@@ -274,34 +399,13 @@ export const layer: Layer.Layer<
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
- const defaultPrompt = `When constructing the summary, try to stick to this template:
----
-## Goal
-
-[What goal(s) is the user trying to accomplish?]
-
-## Instructions
-
-- [What important instructions did the user give you that are relevant]
-- [If there is a plan or spec, include information about it so next agent can continue using it]
-
-## Discoveries
-
-[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work]
-
-## Accomplished
-
-[What work has been completed, what work is still in progress, and what work is left?]
-
-## Relevant files / directories
-
-[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
----`
-
- const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
+ const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
- const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
+ const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
+ stripMedia: true,
+ toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
+ })
const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
@@ -345,7 +449,7 @@ export const layer: Layer.Layer<
...modelMessages,
{
role: "user",
- content: [{ type: "text", text: prompt }],
+ content: [{ type: "text", text: nextPrompt }],
},
],
model,
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 20528763b8..980dd4da84 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -15,7 +15,10 @@ import { isMedia } from "@/util/media"
import type { SystemError } from "bun"
import type { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema"
-import { Effect } from "effect"
+import { Effect, Schema, Types } from "effect"
+import { zod, ZodOverride } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
+import { namedSchemaError } from "@/util/named-schema-error"
import { EffectLogger } from "@/effect"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
@@ -28,432 +31,550 @@ interface FetchDecompressionError extends Error {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
export { isMedia }
-export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
-export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
-export const StructuredOutputError = NamedError.create(
- "StructuredOutputError",
- z.object({
- message: z.string(),
- retries: z.number(),
- }),
-)
-export const AuthError = NamedError.create(
- "ProviderAuthError",
- z.object({
- providerID: z.string(),
- message: z.string(),
- }),
-)
-export const APIError = NamedError.create(
- "APIError",
- z.object({
- message: z.string(),
- statusCode: z.number().optional(),
- isRetryable: z.boolean(),
- responseHeaders: z.record(z.string(), z.string()).optional(),
- responseBody: z.string().optional(),
- metadata: z.record(z.string(), z.string()).optional(),
- }),
-)
+export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
+export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
+export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
+ message: Schema.String,
+ retries: Schema.Number,
+})
+export const AuthError = namedSchemaError("ProviderAuthError", {
+ providerID: Schema.String,
+ message: Schema.String,
+})
+export const APIError = namedSchemaError("APIError", {
+ message: Schema.String,
+ statusCode: Schema.optional(Schema.Number),
+ isRetryable: Schema.Boolean,
+ responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+ responseBody: Schema.optional(Schema.String),
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+})
export type APIError = z.infer
-export const ContextOverflowError = NamedError.create(
- "ContextOverflowError",
- z.object({ message: z.string(), responseBody: z.string().optional() }),
-)
-
-export const OutputFormatText = z
- .object({
- type: z.literal("text"),
- })
- .meta({
- ref: "OutputFormatText",
- })
-
-export const OutputFormatJsonSchema = z
- .object({
- type: z.literal("json_schema"),
- schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }),
- retryCount: z.number().int().min(0).default(2),
- })
- .meta({
- ref: "OutputFormatJsonSchema",
- })
-
-export const Format = z.discriminatedUnion("type", [OutputFormatText, OutputFormatJsonSchema]).meta({
- ref: "OutputFormat",
-})
-export type OutputFormat = z.infer
-
-const PartBase = z.object({
- id: PartID.zod,
- sessionID: SessionID.zod,
- messageID: MessageID.zod,
+export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
+ message: Schema.String,
+ responseBody: Schema.optional(Schema.String),
})
-export const SnapshotPart = PartBase.extend({
- type: z.literal("snapshot"),
- snapshot: z.string(),
-}).meta({
- ref: "SnapshotPart",
-})
-export type SnapshotPart = z.infer
+export class OutputFormatText extends Schema.Class("OutputFormatText")({
+ type: Schema.Literal("text"),
+}) {
+ static readonly zod = zod(this)
+}
-export const PatchPart = PartBase.extend({
- type: z.literal("patch"),
- hash: z.string(),
- files: z.string().array(),
-}).meta({
- ref: "PatchPart",
-})
-export type PatchPart = z.infer
+export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({
+ type: Schema.Literal("json_schema"),
+ schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
+ retryCount: Schema.Number.check(Schema.isInt())
+ .check(Schema.isGreaterThanOrEqualTo(0))
+ .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
+}) {
+ static readonly zod = zod(this)
+}
-export const TextPart = PartBase.extend({
- type: z.literal("text"),
- text: z.string(),
- synthetic: z.boolean().optional(),
- ignored: z.boolean().optional(),
- time: z
- .object({
- start: z.number(),
- end: z.number().optional(),
- })
- .optional(),
- metadata: z.record(z.string(), z.any()).optional(),
-}).meta({
- ref: "TextPart",
+const _Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({
+ discriminator: "type",
+ identifier: "OutputFormat",
})
-export type TextPart = z.infer
+export const Format = Object.assign(_Format, { zod: zod(_Format) })
+export type OutputFormat = Schema.Schema.Type
-export const ReasoningPart = PartBase.extend({
- type: z.literal("reasoning"),
- text: z.string(),
- metadata: z.record(z.string(), z.any()).optional(),
- time: z.object({
- start: z.number(),
- end: z.number().optional(),
- }),
-}).meta({
- ref: "ReasoningPart",
+const partBase = {
+ id: PartID,
+ sessionID: SessionID,
+ messageID: MessageID,
+}
+
+export const SnapshotPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("snapshot"),
+ snapshot: Schema.String,
})
-export type ReasoningPart = z.infer
+ .annotate({ identifier: "SnapshotPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type SnapshotPart = Types.DeepMutable>
-const FilePartSourceBase = z.object({
- text: z
- .object({
- value: z.string(),
- start: z.number().int(),
- end: z.number().int(),
- })
- .meta({
- ref: "FilePartSourceText",
+export const PatchPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("patch"),
+ hash: Schema.String,
+ files: Schema.Array(Schema.String),
+})
+ .annotate({ identifier: "PatchPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type PatchPart = Types.DeepMutable>
+
+export const TextPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("text"),
+ text: Schema.String,
+ synthetic: Schema.optional(Schema.Boolean),
+ ignored: Schema.optional(Schema.Boolean),
+ time: Schema.optional(
+ Schema.Struct({
+ start: Schema.Number,
+ end: Schema.optional(Schema.Number),
}),
+ ),
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
})
+ .annotate({ identifier: "TextPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type TextPart = Types.DeepMutable>
-export const FileSource = FilePartSourceBase.extend({
- type: z.literal("file"),
- path: z.string(),
-}).meta({
- ref: "FileSource",
+export const ReasoningPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("reasoning"),
+ text: Schema.String,
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+ time: Schema.Struct({
+ start: Schema.Number,
+ end: Schema.optional(Schema.Number),
+ }),
})
+ .annotate({ identifier: "ReasoningPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ReasoningPart = Types.DeepMutable>
-export const SymbolSource = FilePartSourceBase.extend({
- type: z.literal("symbol"),
- path: z.string(),
+const filePartSourceBase = {
+ text: Schema.Struct({
+ value: Schema.String,
+ start: Schema.Number.check(Schema.isInt()),
+ end: Schema.Number.check(Schema.isInt()),
+ }).annotate({ identifier: "FilePartSourceText" }),
+}
+
+export const FileSource = Schema.Struct({
+ ...filePartSourceBase,
+ type: Schema.Literal("file"),
+ path: Schema.String,
+})
+ .annotate({ identifier: "FileSource" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+
+export const SymbolSource = Schema.Struct({
+ ...filePartSourceBase,
+ type: Schema.Literal("symbol"),
+ path: Schema.String,
range: LSP.Range,
- name: z.string(),
- kind: z.number().int(),
-}).meta({
- ref: "SymbolSource",
+ name: Schema.String,
+ kind: Schema.Number.check(Schema.isInt()),
})
+ .annotate({ identifier: "SymbolSource" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
-export const ResourceSource = FilePartSourceBase.extend({
- type: z.literal("resource"),
- clientName: z.string(),
- uri: z.string(),
-}).meta({
- ref: "ResourceSource",
+export const ResourceSource = Schema.Struct({
+ ...filePartSourceBase,
+ type: Schema.Literal("resource"),
+ clientName: Schema.String,
+ uri: Schema.String,
})
+ .annotate({ identifier: "ResourceSource" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
-export const FilePartSource = z.discriminatedUnion("type", [FileSource, SymbolSource, ResourceSource]).meta({
- ref: "FilePartSource",
+const _FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({
+ discriminator: "type",
+ identifier: "FilePartSource",
})
+export const FilePartSource = Object.assign(_FilePartSource, { zod: zod(_FilePartSource) })
-export const FilePart = PartBase.extend({
- type: z.literal("file"),
- mime: z.string(),
- filename: z.string().optional(),
- url: z.string(),
- source: FilePartSource.optional(),
-}).meta({
- ref: "FilePart",
+export const FilePart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("file"),
+ mime: Schema.String,
+ filename: Schema.optional(Schema.String),
+ url: Schema.String,
+ source: Schema.optional(_FilePartSource),
})
-export type FilePart = z.infer
+ .annotate({ identifier: "FilePart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type FilePart = Types.DeepMutable>
-export const AgentPart = PartBase.extend({
- type: z.literal("agent"),
- name: z.string(),
- source: z
- .object({
- value: z.string(),
- start: z.number().int(),
- end: z.number().int(),
- })
- .optional(),
-}).meta({
- ref: "AgentPart",
+export const AgentPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("agent"),
+ name: Schema.String,
+ source: Schema.optional(
+ Schema.Struct({
+ value: Schema.String,
+ start: Schema.Number.check(Schema.isInt()),
+ end: Schema.Number.check(Schema.isInt()),
+ }),
+ ),
})
-export type AgentPart = z.infer
+ .annotate({ identifier: "AgentPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type AgentPart = Types.DeepMutable>
-export const CompactionPart = PartBase.extend({
- type: z.literal("compaction"),
- auto: z.boolean(),
- overflow: z.boolean().optional(),
- tail_start_id: MessageID.zod.optional(),
-}).meta({
- ref: "CompactionPart",
+export const CompactionPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("compaction"),
+ auto: Schema.Boolean,
+ overflow: Schema.optional(Schema.Boolean),
+ tail_start_id: Schema.optional(MessageID),
})
-export type CompactionPart = z.infer
+ .annotate({ identifier: "CompactionPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type CompactionPart = Types.DeepMutable>
-export const SubtaskPart = PartBase.extend({
- type: z.literal("subtask"),
- prompt: z.string(),
- description: z.string(),
- agent: z.string(),
- model: z
- .object({
- providerID: ProviderID.zod,
- modelID: ModelID.zod,
- })
- .optional(),
- command: z.string().optional(),
-}).meta({
- ref: "SubtaskPart",
+export const SubtaskPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("subtask"),
+ prompt: Schema.String,
+ description: Schema.String,
+ agent: Schema.String,
+ model: Schema.optional(
+ Schema.Struct({
+ providerID: ProviderID,
+ modelID: ModelID,
+ }),
+ ),
+ command: Schema.optional(Schema.String),
})
-export type SubtaskPart = z.infer
+ .annotate({ identifier: "SubtaskPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type SubtaskPart = Types.DeepMutable>
-export const RetryPart = PartBase.extend({
- type: z.literal("retry"),
- attempt: z.number(),
- error: APIError.Schema,
- time: z.object({
- created: z.number(),
+export const RetryPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("retry"),
+ attempt: Schema.Number,
+ // APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate.
+ error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }),
+ time: Schema.Struct({
+ created: Schema.Number,
}),
-}).meta({
- ref: "RetryPart",
})
-export type RetryPart = z.infer
+ .annotate({ identifier: "RetryPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type RetryPart = Omit>, "error"> & {
+ error: APIError
+}
-export const StepStartPart = PartBase.extend({
- type: z.literal("step-start"),
- snapshot: z.string().optional(),
-}).meta({
- ref: "StepStartPart",
+export const StepStartPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("step-start"),
+ snapshot: Schema.optional(Schema.String),
})
-export type StepStartPart = z.infer
+ .annotate({ identifier: "StepStartPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type StepStartPart = Types.DeepMutable>
-export const StepFinishPart = PartBase.extend({
- type: z.literal("step-finish"),
- reason: z.string(),
- snapshot: z.string().optional(),
- cost: z.number(),
- tokens: z.object({
- total: z.number().optional(),
- input: z.number(),
- output: z.number(),
- reasoning: z.number(),
- cache: z.object({
- read: z.number(),
- write: z.number(),
+export const StepFinishPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("step-finish"),
+ reason: Schema.String,
+ snapshot: Schema.optional(Schema.String),
+ cost: Schema.Number,
+ tokens: Schema.Struct({
+ total: Schema.optional(Schema.Number),
+ input: Schema.Number,
+ output: Schema.Number,
+ reasoning: Schema.Number,
+ cache: Schema.Struct({
+ read: Schema.Number,
+ write: Schema.Number,
}),
}),
-}).meta({
- ref: "StepFinishPart",
})
-export type StepFinishPart = z.infer
+ .annotate({ identifier: "StepFinishPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type StepFinishPart = Types.DeepMutable>
-export const ToolStatePending = z
- .object({
- status: z.literal("pending"),
- input: z.record(z.string(), z.any()),
- raw: z.string(),
- })
- .meta({
- ref: "ToolStatePending",
- })
-
-export type ToolStatePending = z.infer
-
-export const ToolStateRunning = z
- .object({
- status: z.literal("running"),
- input: z.record(z.string(), z.any()),
- title: z.string().optional(),
- metadata: z.record(z.string(), z.any()).optional(),
- time: z.object({
- start: z.number(),
- }),
- })
- .meta({
- ref: "ToolStateRunning",
- })
-export type ToolStateRunning = z.infer
-
-export const ToolStateCompleted = z
- .object({
- status: z.literal("completed"),
- input: z.record(z.string(), z.any()),
- output: z.string(),
- title: z.string(),
- metadata: z.record(z.string(), z.any()),
- time: z.object({
- start: z.number(),
- end: z.number(),
- compacted: z.number().optional(),
- }),
- attachments: FilePart.array().optional(),
- })
- .meta({
- ref: "ToolStateCompleted",
- })
-export type ToolStateCompleted = z.infer
-
-export const ToolStateError = z
- .object({
- status: z.literal("error"),
- input: z.record(z.string(), z.any()),
- error: z.string(),
- metadata: z.record(z.string(), z.any()).optional(),
- time: z.object({
- start: z.number(),
- end: z.number(),
- }),
- })
- .meta({
- ref: "ToolStateError",
- })
-export type ToolStateError = z.infer
-
-export const ToolState = z
- .discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
- .meta({
- ref: "ToolState",
- })
-
-export const ToolPart = PartBase.extend({
- type: z.literal("tool"),
- callID: z.string(),
- tool: z.string(),
- state: ToolState,
- metadata: z.record(z.string(), z.any()).optional(),
-}).meta({
- ref: "ToolPart",
+export const ToolStatePending = Schema.Struct({
+ status: Schema.Literal("pending"),
+ input: Schema.Record(Schema.String, Schema.Any),
+ raw: Schema.String,
})
-export type ToolPart = z.infer
+ .annotate({ identifier: "ToolStatePending" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolStatePending = Types.DeepMutable>
-const Base = z.object({
- id: MessageID.zod,
- sessionID: SessionID.zod,
-})
-
-export const User = Base.extend({
- role: z.literal("user"),
- time: z.object({
- created: z.number(),
+export const ToolStateRunning = Schema.Struct({
+ status: Schema.Literal("running"),
+ input: Schema.Record(Schema.String, Schema.Any),
+ title: Schema.optional(Schema.String),
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+ time: Schema.Struct({
+ start: Schema.Number,
}),
- format: Format.optional(),
- summary: z
- .object({
- title: z.string().optional(),
- body: z.string().optional(),
- diffs: Snapshot.FileDiff.array(),
- })
- .optional(),
- agent: z.string(),
- model: z.object({
- providerID: ProviderID.zod,
- modelID: ModelID.zod,
- variant: z.string().optional(),
- }),
- system: z.string().optional(),
- tools: z.record(z.string(), z.boolean()).optional(),
-}).meta({
- ref: "UserMessage",
})
-export type User = z.infer
+ .annotate({ identifier: "ToolStateRunning" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolStateRunning = Types.DeepMutable>
-export const Part = z
- .discriminatedUnion("type", [
- TextPart,
- SubtaskPart,
- ReasoningPart,
- FilePart,
- ToolPart,
- StepStartPart,
- StepFinishPart,
- SnapshotPart,
- PatchPart,
- AgentPart,
- RetryPart,
- CompactionPart,
- ])
- .meta({
- ref: "Part",
- })
-export type Part = z.infer
-
-export const Assistant = Base.extend({
- role: z.literal("assistant"),
- time: z.object({
- created: z.number(),
- completed: z.number().optional(),
+export const ToolStateCompleted = Schema.Struct({
+ status: Schema.Literal("completed"),
+ input: Schema.Record(Schema.String, Schema.Any),
+ output: Schema.String,
+ title: Schema.String,
+ metadata: Schema.Record(Schema.String, Schema.Any),
+ time: Schema.Struct({
+ start: Schema.Number,
+ end: Schema.Number,
+ compacted: Schema.optional(Schema.Number),
}),
- error: z
- .discriminatedUnion("name", [
- AuthError.Schema,
- NamedError.Unknown.Schema,
- OutputLengthError.Schema,
- AbortedError.Schema,
- StructuredOutputError.Schema,
- ContextOverflowError.Schema,
- APIError.Schema,
- ])
- .optional(),
- parentID: MessageID.zod,
- modelID: ModelID.zod,
- providerID: ProviderID.zod,
+ attachments: Schema.optional(Schema.Array(FilePart)),
+})
+ .annotate({ identifier: "ToolStateCompleted" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolStateCompleted = Types.DeepMutable>
+
+function truncateToolOutput(text: string, maxChars?: number) {
+ if (!maxChars || text.length <= maxChars) return text
+ const omitted = text.length - maxChars
+ return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]`
+}
+
+export const ToolStateError = Schema.Struct({
+ status: Schema.Literal("error"),
+ input: Schema.Record(Schema.String, Schema.Any),
+ error: Schema.String,
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+ time: Schema.Struct({
+ start: Schema.Number,
+ end: Schema.Number,
+ }),
+})
+ .annotate({ identifier: "ToolStateError" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolStateError = Types.DeepMutable>
+
+const _ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).annotate({
+ discriminator: "status",
+ identifier: "ToolState",
+})
+// Cast the derived zod so downstream z.infer sees the same mutable shape that
+// our exported TS types expose (the pre-migration Zod inferences were mutable).
+export const ToolState = Object.assign(_ToolState, {
+ zod: zod(_ToolState) as unknown as z.ZodType<
+ ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
+ >,
+})
+export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
+
+export const ToolPart = Schema.Struct({
+ ...partBase,
+ type: Schema.Literal("tool"),
+ callID: Schema.String,
+ tool: Schema.String,
+ state: _ToolState,
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+})
+ .annotate({ identifier: "ToolPart" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type ToolPart = Omit>, "state"> & {
+ state: ToolState
+}
+
+const messageBase = {
+ id: MessageID,
+ sessionID: SessionID,
+}
+
+export const User = Schema.Struct({
+ ...messageBase,
+ role: Schema.Literal("user"),
+ time: Schema.Struct({
+ created: Schema.Number,
+ }),
+ format: Schema.optional(_Format),
+ summary: Schema.optional(
+ Schema.Struct({
+ title: Schema.optional(Schema.String),
+ body: Schema.optional(Schema.String),
+ diffs: Schema.Array(Snapshot.FileDiff),
+ }),
+ ),
+ agent: Schema.String,
+ model: Schema.Struct({
+ providerID: ProviderID,
+ modelID: ModelID,
+ variant: Schema.optional(Schema.String),
+ }),
+ system: Schema.optional(Schema.String),
+ tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
+})
+ .annotate({ identifier: "UserMessage" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type User = Types.DeepMutable>
+
+const _Part = Schema.Union([
+ TextPart,
+ SubtaskPart,
+ ReasoningPart,
+ FilePart,
+ ToolPart,
+ StepStartPart,
+ StepFinishPart,
+ SnapshotPart,
+ PatchPart,
+ AgentPart,
+ RetryPart,
+ CompactionPart,
+]).annotate({ discriminator: "type", identifier: "Part" })
+export const Part = Object.assign(_Part, {
+ zod: zod(_Part) as unknown as z.ZodType<
+ | TextPart
+ | SubtaskPart
+ | ReasoningPart
+ | FilePart
+ | ToolPart
+ | StepStartPart
+ | StepFinishPart
+ | SnapshotPart
+ | PatchPart
+ | AgentPart
+ | RetryPart
+ | CompactionPart
+ >,
+})
+export type Part =
+ | TextPart
+ | SubtaskPart
+ | ReasoningPart
+ | FilePart
+ | ToolPart
+ | StepStartPart
+ | StepFinishPart
+ | SnapshotPart
+ | PatchPart
+ | AgentPart
+ | RetryPart
+ | CompactionPart
+
+// Errors are still NamedError-based Zod; bridge via ZodOverride so the derived
+// Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the
+// error classes to Schema.TaggedErrorClass is a separate slice.
+const AssistantErrorZod = z.discriminatedUnion("name", [
+ AuthError.Schema,
+ NamedError.Unknown.Schema,
+ OutputLengthError.Schema,
+ AbortedError.Schema,
+ StructuredOutputError.Schema,
+ ContextOverflowError.Schema,
+ APIError.Schema,
+])
+type AssistantError = z.infer
+
+// ── Prompt input schemas ─────────────────────────────────────────────────────
+//
+// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the
+// ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may
+// omit `id` to let the server allocate one. These Schema-Struct variants
+// carry that shape, and `SessionPrompt.PromptInput` just references the
+// derived `.zod` (no omit/partial gymnastics needed at the call site).
+
+export const TextPartInput = Schema.Struct({
+ id: Schema.optional(PartID),
+ type: Schema.Literal("text"),
+ text: Schema.String,
+ synthetic: Schema.optional(Schema.Boolean),
+ ignored: Schema.optional(Schema.Boolean),
+ time: Schema.optional(
+ Schema.Struct({
+ start: Schema.Number,
+ end: Schema.optional(Schema.Number),
+ }),
+ ),
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+})
+ .annotate({ identifier: "TextPartInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type TextPartInput = Types.DeepMutable>
+
+export const FilePartInput = Schema.Struct({
+ id: Schema.optional(PartID),
+ type: Schema.Literal("file"),
+ mime: Schema.String,
+ filename: Schema.optional(Schema.String),
+ url: Schema.String,
+ source: Schema.optional(_FilePartSource),
+})
+ .annotate({ identifier: "FilePartInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type FilePartInput = Types.DeepMutable>
+
+export const AgentPartInput = Schema.Struct({
+ id: Schema.optional(PartID),
+ type: Schema.Literal("agent"),
+ name: Schema.String,
+ source: Schema.optional(
+ Schema.Struct({
+ value: Schema.String,
+ start: Schema.Number.check(Schema.isInt()),
+ end: Schema.Number.check(Schema.isInt()),
+ }),
+ ),
+})
+ .annotate({ identifier: "AgentPartInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type AgentPartInput = Types.DeepMutable>
+
+export const SubtaskPartInput = Schema.Struct({
+ id: Schema.optional(PartID),
+ type: Schema.Literal("subtask"),
+ prompt: Schema.String,
+ description: Schema.String,
+ agent: Schema.String,
+ model: Schema.optional(
+ Schema.Struct({
+ providerID: ProviderID,
+ modelID: ModelID,
+ }),
+ ),
+ command: Schema.optional(Schema.String),
+})
+ .annotate({ identifier: "SubtaskPartInput" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type SubtaskPartInput = Types.DeepMutable>
+
+export const Assistant = Schema.Struct({
+ ...messageBase,
+ role: Schema.Literal("assistant"),
+ time: Schema.Struct({
+ created: Schema.Number,
+ completed: Schema.optional(Schema.Number),
+ }),
+ error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })),
+ parentID: MessageID,
+ modelID: ModelID,
+ providerID: ProviderID,
/**
* @deprecated
*/
- mode: z.string(),
- agent: z.string(),
- path: z.object({
- cwd: z.string(),
- root: z.string(),
+ mode: Schema.String,
+ agent: Schema.String,
+ path: Schema.Struct({
+ cwd: Schema.String,
+ root: Schema.String,
}),
- summary: z.boolean().optional(),
- cost: z.number(),
- tokens: z.object({
- total: z.number().optional(),
- input: z.number(),
- output: z.number(),
- reasoning: z.number(),
- cache: z.object({
- read: z.number(),
- write: z.number(),
+ summary: Schema.optional(Schema.Boolean),
+ cost: Schema.Number,
+ tokens: Schema.Struct({
+ total: Schema.optional(Schema.Number),
+ input: Schema.Number,
+ output: Schema.Number,
+ reasoning: Schema.Number,
+ cache: Schema.Struct({
+ read: Schema.Number,
+ write: Schema.Number,
}),
}),
- structured: z.any().optional(),
- variant: z.string().optional(),
- finish: z.string().optional(),
-}).meta({
- ref: "AssistantMessage",
+ structured: Schema.optional(Schema.Any),
+ variant: Schema.optional(Schema.String),
+ finish: Schema.optional(Schema.String),
})
-export type Assistant = z.infer
+ .annotate({ identifier: "AssistantMessage" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Assistant = Omit>, "error"> & {
+ error?: AssistantError
+}
-export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({
- ref: "Message",
+const _Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
+export const Info = Object.assign(_Info, {
+ zod: zod(_Info) as unknown as z.ZodType,
})
-export type Info = z.infer
+export type Info = User | Assistant
export const Event = {
Updated: SyncEvent.define({
@@ -462,7 +583,7 @@ export const Event = {
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
- info: Info,
+ info: Info.zod,
}),
}),
Removed: SyncEvent.define({
@@ -480,7 +601,7 @@ export const Event = {
aggregate: "sessionID",
schema: z.object({
sessionID: SessionID.zod,
- part: Part,
+ part: Part.zod,
time: z.number(),
}),
}),
@@ -506,24 +627,29 @@ export const Event = {
}),
}
-export const WithParts = z.object({
- info: Info,
- parts: z.array(Part),
-})
-export type WithParts = z.infer
+export const WithParts = Schema.Struct({
+ info: _Info,
+ parts: Schema.Array(_Part),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type WithParts = {
+ info: Info
+ parts: Part[]
+}
-const Cursor = z.object({
- id: MessageID.zod,
- time: z.number(),
+const Cursor = Schema.Struct({
+ id: MessageID,
+ time: Schema.Number,
})
-type Cursor = z.infer
+type Cursor = typeof Cursor.Type
+
+const decodeCursor = Schema.decodeUnknownSync(Cursor)
export const cursor = {
encode(input: Cursor) {
return Buffer.from(JSON.stringify(input)).toString("base64url")
},
decode(input: string) {
- return Cursor.parse(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
+ return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
},
}
@@ -580,7 +706,7 @@ function providerMeta(metadata: Record | undefined) {
export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: WithParts[],
model: Provider.Model,
- options?: { stripMedia?: boolean },
+ options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
) {
const result: UIMessage[] = []
const toolNames = new Set()
@@ -719,7 +845,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
if (part.type === "tool") {
toolNames.add(part.tool)
if (part.state.status === "completed") {
- const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output
+ const outputText = part.state.time.compacted
+ ? "[Old tool result content cleared]"
+ : truncateToolOutput(part.state.output, options?.toolOutputMaxChars)
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
// For providers that don't support media in tool results, extract media files
@@ -835,7 +963,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
export function toModelMessages(
input: WithParts[],
model: Provider.Model,
- options?: { stripMedia?: boolean },
+ options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
): Promise {
return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer)))
}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 67dc9e7a26..dd04df63af 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -1206,7 +1206,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ message: info, parts },
)
- const parsed = MessageV2.Info.safeParse(info)
+ const parsed = MessageV2.Info.zod.safeParse(info)
if (!parsed.success) {
log.error("invalid user message before save", {
sessionID: input.sessionID,
@@ -1217,7 +1217,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
}
parts.forEach((part, index) => {
- const p = MessageV2.Part.safeParse(part)
+ const p = MessageV2.Part.zod.safeParse(part)
if (p.success) return
log.error("invalid user part before save", {
sessionID: input.sessionID,
@@ -1681,55 +1681,30 @@ export const PromptInput = z.object({
.record(z.string(), z.boolean())
.optional()
.describe("@deprecated tools and permissions have been merged, you can set permissions on the session itself now"),
- format: MessageV2.Format.optional(),
+ format: MessageV2.Format.zod.optional(),
system: z.string().optional(),
variant: z.string().optional(),
parts: z.array(
z.discriminatedUnion("type", [
- MessageV2.TextPart.omit({
- messageID: true,
- sessionID: true,
- })
- .partial({
- id: true,
- })
- .meta({
- ref: "TextPartInput",
- }),
- MessageV2.FilePart.omit({
- messageID: true,
- sessionID: true,
- })
- .partial({
- id: true,
- })
- .meta({
- ref: "FilePartInput",
- }),
- MessageV2.AgentPart.omit({
- messageID: true,
- sessionID: true,
- })
- .partial({
- id: true,
- })
- .meta({
- ref: "AgentPartInput",
- }),
- MessageV2.SubtaskPart.omit({
- messageID: true,
- sessionID: true,
- })
- .partial({
- id: true,
- })
- .meta({
- ref: "SubtaskPartInput",
- }),
+ MessageV2.TextPartInput.zod as unknown as z.ZodObject,
+ MessageV2.FilePartInput.zod as unknown as z.ZodObject,
+ MessageV2.AgentPartInput.zod as unknown as z.ZodObject,
+ MessageV2.SubtaskPartInput.zod as unknown as z.ZodObject,
]),
),
})
-export type PromptInput = z.infer
+// `z.discriminatedUnion` erases the discriminated members' shapes back to
+// `{}` because the derived `.zod` on each input is typed as an opaque
+// `z.ZodType`. Restore the precise `parts` type from the exported Schema
+// input types so callers see a proper tagged union.
+type PartInputUnion =
+ | MessageV2.TextPartInput
+ | MessageV2.FilePartInput
+ | MessageV2.AgentPartInput
+ | MessageV2.SubtaskPartInput
+export type PromptInput = Omit, "parts"> & {
+ parts: PartInputUnion[]
+}
export const LoopInput = z.object({
sessionID: SessionID.zod,
@@ -1757,14 +1732,19 @@ export const CommandInput = z.object({
arguments: z.string(),
command: z.string(),
variant: z.string().optional(),
+ // Inlined (no `.meta({ ref })`) to keep the original SDK output — the
+ // PromptInput call site below references FilePartInput by ref via the
+ // Schema export in message-v2.ts.
parts: z
.array(
z.discriminatedUnion("type", [
- MessageV2.FilePart.omit({
- messageID: true,
- sessionID: true,
- }).partial({
- id: true,
+ z.object({
+ id: PartID.zod.optional(),
+ type: z.literal("file"),
+ mime: z.string(),
+ filename: z.string().optional(),
+ url: z.string(),
+ source: MessageV2.FilePartSource.zod.optional(),
}),
]),
)
diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts
index efed280c98..487cbcd34a 100644
--- a/packages/opencode/src/session/schema.ts
+++ b/packages/opencode/src/session/schema.ts
@@ -1,15 +1,14 @@
import { Schema } from "effect"
-import z from "zod"
import { Identifier } from "@/id/id"
-import { ZodOverride } from "@/util/effect-zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe(
Schema.brand("SessionID"),
withStatics((s) => ({
descending: (id?: string) => s.make(Identifier.descending("session", id)),
- zod: Identifier.schema("session").pipe(z.custom>()),
+ zod: zod(s),
})),
)
@@ -19,7 +18,7 @@ export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.sche
Schema.brand("MessageID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
- zod: Identifier.schema("message").pipe(z.custom>()),
+ zod: zod(s),
})),
)
@@ -29,7 +28,7 @@ export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema(
Schema.brand("PartID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
- zod: Identifier.schema("part").pipe(z.custom>()),
+ zod: zod(s),
})),
)
diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts
index ba144da9f0..a7607798ba 100644
--- a/packages/opencode/src/session/session.ts
+++ b/packages/opencode/src/session/session.ts
@@ -127,7 +127,7 @@ export const Info = z
additions: z.number(),
deletions: z.number(),
files: z.number(),
- diffs: Snapshot.FileDiff.array().optional(),
+ diffs: Snapshot.FileDiff.zod.array().optional(),
})
.optional(),
share: z
@@ -239,7 +239,7 @@ export const Event = {
"session.diff",
z.object({
sessionID: SessionID.zod,
- diff: Snapshot.FileDiff.array(),
+ diff: Snapshot.FileDiff.zod.array(),
}),
),
Error: BusEvent.define(
@@ -247,7 +247,7 @@ export const Event = {
z.object({
sessionID: SessionID.zod.optional(),
// z.lazy defers access to break circular dep: session → message-v2 → provider → plugin → session
- error: z.lazy(() => MessageV2.Assistant.shape.error),
+ error: z.lazy(() => (MessageV2.Assistant.zod as unknown as z.ZodObject).shape.error),
}),
),
}
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index d38034e998..ddc4cb29ea 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -1,4 +1,4 @@
-import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect"
+import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
@@ -10,25 +10,25 @@ import { Hash } from "@opencode-ai/shared/util/hash"
import { Config } from "../config"
import { Global } from "../global"
import { Log } from "../util"
+import { withStatics } from "@/util/schema"
+import { zod } from "@/util/effect-zod"
-export const Patch = z.object({
- hash: z.string(),
- files: z.string().array(),
+export const Patch = Schema.Struct({
+ hash: Schema.String,
+ files: Schema.mutable(Schema.Array(Schema.String)),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Patch = typeof Patch.Type
+
+export const FileDiff = Schema.Struct({
+ file: Schema.String,
+ patch: Schema.String,
+ additions: Schema.Number,
+ deletions: Schema.Number,
+ status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
})
-export type Patch = z.infer
-
-export const FileDiff = z
- .object({
- file: z.string(),
- patch: z.string(),
- additions: z.number(),
- deletions: z.number(),
- status: z.enum(["added", "deleted", "modified"]).optional(),
- })
- .meta({
- ref: "SnapshotFileDiff",
- })
-export type FileDiff = z.infer
+ .annotate({ identifier: "SnapshotFileDiff" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type FileDiff = typeof FileDiff.Type
const log = Log.create({ service: "snapshot" })
const prune = "7.days"
diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts
index 37cdbd718f..e714b86ae0 100644
--- a/packages/opencode/src/sync/schema.ts
+++ b/packages/opencode/src/sync/schema.ts
@@ -1,14 +1,13 @@
import { Schema } from "effect"
-import z from "zod"
import { Identifier } from "@/id/id"
-import { ZodOverride } from "@/util/effect-zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe(
Schema.brand("EventID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("event", id)),
- zod: Identifier.schema("event").pipe(z.custom>()),
+ zod: zod(s),
})),
)
diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts
index 7da7dd255c..33112c43c5 100644
--- a/packages/opencode/src/tool/apply_patch.ts
+++ b/packages/opencode/src/tool/apply_patch.ts
@@ -14,6 +14,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file"
import { Format } from "../format"
+import * as Bom from "@/util/bom"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
@@ -59,6 +60,7 @@ export const ApplyPatchTool = Tool.define(
diff: string
additions: number
deletions: number
+ bom: boolean
}> = []
let totalDiff = ""
@@ -72,11 +74,12 @@ export const ApplyPatchTool = Tool.define(
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
- const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
+ const next = Bom.split(newContent)
+ const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, next.text))
let additions = 0
let deletions = 0
- for (const change of diffLines(oldContent, newContent)) {
+ for (const change of diffLines(oldContent, next.text)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
@@ -84,11 +87,12 @@ export const ApplyPatchTool = Tool.define(
fileChanges.push({
filePath,
oldContent,
- newContent,
+ newContent: next.text,
type: "add",
diff,
additions,
deletions,
+ bom: next.bom,
})
totalDiff += diff + "\n"
@@ -104,13 +108,16 @@ export const ApplyPatchTool = Tool.define(
)
}
- const oldContent = yield* afs.readFileString(filePath)
+ const source = yield* Bom.readFile(afs, filePath)
+ const oldContent = source.text
let newContent = oldContent
+ let bom = source.bom
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
+ bom = fileUpdate.bom
} catch (error) {
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
}
@@ -136,6 +143,7 @@ export const ApplyPatchTool = Tool.define(
diff,
additions,
deletions,
+ bom,
})
totalDiff += diff + "\n"
@@ -143,17 +151,16 @@ export const ApplyPatchTool = Tool.define(
}
case "delete": {
- const contentToDelete = yield* afs
- .readFileString(filePath)
- .pipe(
- Effect.catch((error) =>
- Effect.fail(
- new Error(
- `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
- ),
+ const source = yield* Bom.readFile(afs, filePath).pipe(
+ Effect.catch((error) =>
+ Effect.fail(
+ new Error(
+ `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
),
),
- )
+ ),
+ )
+ const contentToDelete = source.text
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
const deletions = contentToDelete.split("\n").length
@@ -166,6 +173,7 @@ export const ApplyPatchTool = Tool.define(
diff: deleteDiff,
additions: 0,
deletions,
+ bom: source.bom,
})
totalDiff += deleteDiff + "\n"
@@ -207,12 +215,12 @@ export const ApplyPatchTool = Tool.define(
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
- yield* afs.writeWithDirs(change.filePath, change.newContent)
+ yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom))
updates.push({ file: change.filePath, event: "add" })
break
case "update":
- yield* afs.writeWithDirs(change.filePath, change.newContent)
+ yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom))
updates.push({ file: change.filePath, event: "change" })
break
@@ -220,7 +228,7 @@ export const ApplyPatchTool = Tool.define(
if (change.movePath) {
// Create parent directories (recursive: true is safe on existing/root dirs)
- yield* afs.writeWithDirs(change.movePath!, change.newContent)
+ yield* afs.writeWithDirs(change.movePath!, Bom.join(change.newContent, change.bom))
yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" })
@@ -234,7 +242,9 @@ export const ApplyPatchTool = Tool.define(
}
if (edited) {
- yield* format.file(edited)
+ if (yield* format.file(edited)) {
+ yield* Bom.syncFile(afs, edited, change.bom)
+ }
yield* bus.publish(File.Event.Edited, { file: edited })
}
}
@@ -248,7 +258,7 @@ export const ApplyPatchTool = Tool.define(
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
- yield* lsp.touchFile(target, true)
+ yield* lsp.touchFile(target, "document")
}
const diagnostics = yield* lsp.diagnostics()
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 2f53cd1949..35dd85b476 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -18,6 +18,7 @@ import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import * as Bom from "@/util/bom"
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
@@ -84,7 +85,11 @@ export const EditTool = Tool.define(
Effect.gen(function* () {
if (params.oldString === "") {
const existed = yield* afs.existsSafe(filePath)
- contentNew = params.newString
+ const source = existed ? yield* Bom.readFile(afs, filePath) : { bom: false, text: "" }
+ const next = Bom.split(params.newString)
+ const desiredBom = source.bom || next.bom
+ contentOld = source.text
+ contentNew = next.text
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
@@ -95,8 +100,10 @@ export const EditTool = Tool.define(
diff,
},
})
- yield* afs.writeWithDirs(filePath, params.newString)
- yield* format.file(filePath)
+ yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))
+ if (yield* format.file(filePath)) {
+ contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
+ }
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
@@ -108,13 +115,16 @@ export const EditTool = Tool.define(
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) throw new Error(`File ${filePath} not found`)
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
- contentOld = yield* afs.readFileString(filePath)
+ const source = yield* Bom.readFile(afs, filePath)
+ contentOld = source.text
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
- const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
+ const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending)
- contentNew = replace(contentOld, old, next, params.replaceAll)
+ const next = Bom.split(replace(contentOld, old, replacement, params.replaceAll))
+ const desiredBom = source.bom || next.bom
+ contentNew = next.text
diff = trimDiff(
createTwoFilesPatch(
@@ -134,14 +144,15 @@ export const EditTool = Tool.define(
},
})
- yield* afs.writeWithDirs(filePath, contentNew)
- yield* format.file(filePath)
+ yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))
+ if (yield* format.file(filePath)) {
+ contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
+ }
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
- contentNew = yield* afs.readFileString(filePath)
diff = trimDiff(
createTwoFilesPatch(
filePath,
@@ -153,15 +164,17 @@ export const EditTool = Tool.define(
}).pipe(Effect.orDie),
)
+ let additions = 0
+ let deletions = 0
+ for (const change of diffLines(contentOld, contentNew)) {
+ if (change.added) additions += change.count || 0
+ if (change.removed) deletions += change.count || 0
+ }
const filediff: Snapshot.FileDiff = {
file: filePath,
patch: diff,
- additions: 0,
- deletions: 0,
- }
- for (const change of diffLines(contentOld, contentNew)) {
- if (change.added) filediff.additions += change.count || 0
- if (change.removed) filediff.deletions += change.count || 0
+ additions,
+ deletions,
}
yield* ctx.metadata({
@@ -173,7 +186,7 @@ export const EditTool = Tool.define(
})
let output = "Edit applied successfully."
- yield* lsp.touchFile(filePath, true)
+ yield* lsp.touchFile(filePath, "document")
const diagnostics = yield* lsp.diagnostics()
const normalizedFilePath = AppFileSystem.normalizePath(filePath)
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts
index 263bfe81d2..0a0edc61ed 100644
--- a/packages/opencode/src/tool/lsp.ts
+++ b/packages/opencode/src/tool/lsp.ts
@@ -55,7 +55,7 @@ export const LspTool = Tool.define(
const available = yield* lsp.hasClients(file)
if (!available) throw new Error("No LSP server available for this file type.")
- yield* lsp.touchFile(file, true)
+ yield* lsp.touchFile(file, "document")
const result: unknown[] = yield* (() => {
switch (args.operation) {
diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts
deleted file mode 100644
index 004d3c870d..0000000000
--- a/packages/opencode/src/tool/multiedit.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import z from "zod"
-import { Effect } from "effect"
-import * as Tool from "./tool"
-import { EditTool } from "./edit"
-import DESCRIPTION from "./multiedit.txt"
-import path from "path"
-import { Instance } from "../project/instance"
-
-export const MultiEditTool = Tool.define(
- "multiedit",
- Effect.gen(function* () {
- const editInfo = yield* EditTool
- const edit = yield* editInfo.init()
-
- return {
- description: DESCRIPTION,
- parameters: z.object({
- filePath: z.string().describe("The absolute path to the file to modify"),
- edits: z
- .array(
- z.object({
- filePath: z.string().describe("The absolute path to the file to modify"),
- oldString: z.string().describe("The text to replace"),
- newString: z.string().describe("The text to replace it with (must be different from oldString)"),
- replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
- }),
- )
- .describe("Array of edit operations to perform sequentially on the file"),
- }),
- execute: (
- params: {
- filePath: string
- edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }>
- },
- ctx: Tool.Context,
- ) =>
- Effect.gen(function* () {
- const results = []
- for (const [, entry] of params.edits.entries()) {
- const result = yield* edit.execute(
- {
- filePath: params.filePath,
- oldString: entry.oldString,
- newString: entry.newString,
- replaceAll: entry.replaceAll,
- },
- ctx,
- )
- results.push(result)
- }
- return {
- title: path.relative(Instance.worktree, params.filePath),
- metadata: {
- results: results.map((r) => r.metadata),
- },
- output: results.at(-1)!.output,
- }
- }),
- }
- }),
-)
diff --git a/packages/opencode/src/tool/multiedit.txt b/packages/opencode/src/tool/multiedit.txt
deleted file mode 100644
index bb4815124d..0000000000
--- a/packages/opencode/src/tool/multiedit.txt
+++ /dev/null
@@ -1,41 +0,0 @@
-This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file.
-
-Before using this tool:
-
-1. Use the Read tool to understand the file's contents and context
-2. Verify the directory path is correct
-
-To make multiple file edits, provide the following:
-1. file_path: The absolute path to the file to modify (must be absolute, not relative)
-2. edits: An array of edit operations to perform, where each edit contains:
- - oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation)
- - newString: The edited text to replace the oldString
- - replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false.
-
-IMPORTANT:
-- All edits are applied in sequence, in the order they are provided
-- Each edit operates on the result of the previous edit
-- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
-- This tool is ideal when you need to make several changes to different parts of the same file
-
-CRITICAL REQUIREMENTS:
-1. All edits follow the same requirements as the single Edit tool
-2. The edits are atomic - either all succeed or none are applied
-3. Plan your edits carefully to avoid conflicts between sequential operations
-
-WARNING:
-- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace)
-- The tool will fail if edits.oldString and edits.newString are the same
-- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
-
-When making edits:
-- Ensure all edits result in idiomatic, correct code
-- Do not leave the code in a broken state
-- Always use absolute file paths (starting with /)
-- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
-- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
-
-If you want to create a new file, use:
-- A new file path, including dir name if needed
-- First edit: empty oldString and the new file's contents as newString
-- Subsequent edits: normal edit operations on the created content
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index c9b3048626..a9b95346a1 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -75,7 +75,7 @@ export const ReadTool = Tool.define(
})
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
- yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
+ yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope))
})
const readSample = Effect.fn("ReadTool.readSample")(function* (
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index e27593e597..0211e33bcb 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -157,9 +157,9 @@ export const layer: Layer.Layer<
if (matches.length) yield* config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
- const mod = yield* Effect.promise(
- () => import(process.platform === "win32" ? match : pathToFileURL(match).href),
- )
+ // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`.
+ // Import it as `file://` so Node on Windows accepts the dynamic import.
+ const mod = yield* Effect.promise(() => import(pathToFileURL(match).href))
for (const [id, def] of Object.entries(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts
index ac41fd1606..9ce7bece2b 100644
--- a/packages/opencode/src/tool/schema.ts
+++ b/packages/opencode/src/tool/schema.ts
@@ -1,8 +1,7 @@
import { Schema } from "effect"
-import z from "zod"
import { Identifier } from "@/id/id"
-import { ZodOverride } from "@/util/effect-zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID"))
@@ -12,6 +11,6 @@ export type ToolID = typeof toolIdSchema.Type
export const ToolID = toolIdSchema.pipe(
withStatics((schema: typeof toolIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)),
- zod: Identifier.schema("tool").pipe(z.custom()),
+ zod: zod(schema),
})),
)
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index 741091b21d..80198f4555 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -13,6 +13,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Instance } from "../project/instance"
import { trimDiff } from "./edit"
import { assertExternalDirectoryEffect } from "./external-directory"
+import * as Bom from "@/util/bom"
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
@@ -38,9 +39,13 @@ export const WriteTool = Tool.define(
yield* assertExternalDirectoryEffect(ctx, filepath)
const exists = yield* fs.existsSafe(filepath)
- const contentOld = exists ? yield* fs.readFileString(filepath) : ""
+ const source = exists ? yield* Bom.readFile(fs, filepath) : { bom: false, text: "" }
+ const next = Bom.split(params.content)
+ const desiredBom = source.bom || next.bom
+ const contentOld = source.text
+ const contentNew = next.text
- const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
+ const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
@@ -51,8 +56,10 @@ export const WriteTool = Tool.define(
},
})
- yield* fs.writeWithDirs(filepath, params.content)
- yield* format.file(filepath)
+ yield* fs.writeWithDirs(filepath, Bom.join(contentNew, desiredBom))
+ if (yield* format.file(filepath)) {
+ yield* Bom.syncFile(fs, filepath, desiredBom)
+ }
yield* bus.publish(File.Event.Edited, { file: filepath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filepath,
@@ -60,7 +67,7 @@ export const WriteTool = Tool.define(
})
let output = "Wrote file successfully."
- yield* lsp.touchFile(filepath, true)
+ yield* lsp.touchFile(filepath, "document")
const diagnostics = yield* lsp.diagnostics()
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
let projectDiagnosticsCount = 0
diff --git a/packages/opencode/src/util/bom.ts b/packages/opencode/src/util/bom.ts
new file mode 100644
index 0000000000..484228f3d4
--- /dev/null
+++ b/packages/opencode/src/util/bom.ts
@@ -0,0 +1,31 @@
+import { Effect } from "effect"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+
+const BOM_CODE = 0xfeff
+const BOM = String.fromCharCode(BOM_CODE)
+
+export function split(text: string) {
+ if (text.charCodeAt(0) !== BOM_CODE) return { bom: false, text }
+ return { bom: true, text: text.slice(1) }
+}
+
+export function join(text: string, bom: boolean) {
+ const stripped = split(text).text
+ if (!bom) return stripped
+ return BOM + stripped
+}
+
+export const readFile = Effect.fn("Bom.readFile")(function* (fs: AppFileSystem.Interface, filePath: string) {
+ return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath)))
+})
+
+export const syncFile = Effect.fn("Bom.syncFile")(function* (
+ fs: AppFileSystem.Interface,
+ filePath: string,
+ bom: boolean,
+) {
+ const current = yield* readFile(fs, filePath)
+ if (current.bom === bom) return current.text
+ yield* fs.writeWithDirs(filePath, join(current.text, bom))
+ return current.text
+})
diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts
index bf1caa035b..f6d2c5e5c0 100644
--- a/packages/opencode/src/util/effect-zod.ts
+++ b/packages/opencode/src/util/effect-zod.ts
@@ -8,43 +8,6 @@ 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
@@ -85,11 +48,9 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
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 described = desc ? out.describe(desc) : out
+ const described = desc ? checked.describe(desc) : checked
return ref ? described.meta({ ref }) : described
}
diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts
index 75fef9fc9a..fbda2dc50e 100644
--- a/packages/opencode/src/util/error.ts
+++ b/packages/opencode/src/util/error.ts
@@ -26,6 +26,10 @@ export function errorMessage(error: unknown): string {
return error.message
}
+ if (isRecord(error) && isRecord(error.data) && typeof error.data.message === "string" && error.data.message) {
+ return error.data.message
+ }
+
const text = String(error)
if (text && text !== "[object Object]") return text
diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts
new file mode 100644
index 0000000000..e144f2f906
--- /dev/null
+++ b/packages/opencode/src/util/named-schema-error.ts
@@ -0,0 +1,54 @@
+import { Schema } from "effect"
+import z from "zod"
+import { zod } from "@/util/effect-zod"
+
+/**
+ * Create a Schema-backed NamedError-shaped class.
+ *
+ * Drop-in replacement for `NamedError.create(tag, zodShape)` but backed by
+ * `Schema.Struct` under the hood. The wire shape emitted by the derived
+ * `.Schema` is still `{ name: tag, data: {...fields} }` so the generated
+ * OpenAPI/SDK output is byte-identical to the original NamedError schema.
+ *
+ * Preserves the existing surface:
+ * - static `Schema` (Zod schema of the wire shape)
+ * - static `isInstance(x)`
+ * - instance `toObject()` returning `{ name, data }`
+ * - `new X({ ...data }, { cause })`
+ */
+export function namedSchemaError(tag: Tag, fields: Fields) {
+ // Wire shape matches the original NamedError output so the SDK stays stable.
+ const dataSchema = Schema.Struct(fields)
+ const wire = z
+ .object({
+ name: z.literal(tag),
+ data: zod(dataSchema),
+ })
+ .meta({ ref: tag })
+
+ type Data = Schema.Schema.Type
+
+ class NamedSchemaError extends Error {
+ static readonly Schema = wire
+ static readonly tag = tag
+ public static isInstance(input: unknown): input is NamedSchemaError {
+ return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag
+ }
+
+ public override readonly name: Tag = tag
+ public readonly data: Data
+
+ constructor(data: Data, options?: ErrorOptions) {
+ super(tag, options)
+ this.data = data
+ }
+
+ toObject(): { name: Tag; data: Data } {
+ return { name: tag, data: this.data }
+ }
+ }
+
+ Object.defineProperty(NamedSchemaError, "name", { value: tag })
+
+ return NamedSchemaError
+}
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index 7e9a6fe90b..50a3668f98 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -474,7 +474,7 @@ test("legacy tools config converts to permissions", async () => {
})
})
-test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => {
+test("legacy tools config maps write/edit/patch to edit permission", async () => {
await using tmp = await tmpdir({
config: {
agent: {
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 754da3af62..815919ee20 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -1465,35 +1465,6 @@ test("migrates legacy patch tool to edit permission", async () => {
})
})
-test("migrates legacy multiedit tool to edit permission", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- await Filesystem.write(
- path.join(dir, "opencode.json"),
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- agent: {
- test: {
- tools: {
- multiedit: false,
- },
- },
- },
- }),
- )
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const config = await load()
- expect(config.agent?.["test"]?.permission).toEqual({
- edit: "deny",
- })
- },
- })
-})
-
test("migrates mixed legacy tools config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1562,7 +1533,16 @@ test("merges legacy tools with existing permission config", async () => {
})
})
-test("permission config preserves key order", async () => {
+test("permission config canonicalises known keys first, preserves rest-key insertion order", async () => {
+ // ConfigPermission.Info is a StructWithRest schema — the decoder reorders
+ // keys into declaration-order for known permission names (edit, read,
+ // todowrite, external_directory are declared in `config/permission.ts`),
+ // followed by rest keys in the user's insertion order.
+ //
+ // Rule precedence is NOT affected by this reordering: `Permission.fromConfig`
+ // sorts wildcards before specifics before iterating. See the
+ // "fromConfig - specific key beats wildcard regardless of JSON key order"
+ // test in test/permission/next.test.ts for the behavioural guarantee.
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
@@ -1590,12 +1570,15 @@ test("permission config preserves key order", async () => {
fn: async () => {
const config = await load()
expect(Object.keys(config.permission!)).toEqual([
- "*",
- "edit",
- "write",
- "external_directory",
+ // known fields that the user provided, in declaration order from
+ // config/permission.ts (read, edit, ..., external_directory, todowrite)
"read",
+ "edit",
+ "external_directory",
"todowrite",
+ // rest keys (not in the known list), in user's insertion order
+ "*",
+ "write",
"thoughts_*",
"reasoning_model_*",
"tools_*",
@@ -2288,7 +2271,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
test("parseManagedPlist strips MDM metadata keys", async () => {
const config = ConfigParse.schema(
- Config.Info,
+ Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2316,7 +2299,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
test("parseManagedPlist parses server settings", async () => {
const config = ConfigParse.schema(
- Config.Info,
+ Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2336,7 +2319,7 @@ test("parseManagedPlist parses server settings", async () => {
test("parseManagedPlist parses permission rules", async () => {
const config = ConfigParse.schema(
- Config.Info,
+ Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2366,7 +2349,7 @@ test("parseManagedPlist parses permission rules", async () => {
test("parseManagedPlist parses enabled_providers", async () => {
const config = ConfigParse.schema(
- Config.Info,
+ Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2383,7 +2366,7 @@ test("parseManagedPlist parses enabled_providers", async () => {
test("parseManagedPlist handles empty config", async () => {
const config = ConfigParse.schema(
- Config.Info,
+ Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
"test:mobileconfig",
diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts
index 5990635aa2..b4e52529c1 100644
--- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts
+++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts
@@ -169,7 +169,9 @@ describe("cross-spawn spawner", () => {
'process.stderr.write("stderr\\n", done)',
].join("\n"),
)
- const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
+ const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
+ concurrency: 2,
+ })
expect(stdout).toBe("stdout")
expect(stderr).toBe("stderr")
}),
diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js
index be62f96f38..e6818009e1 100644
--- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js
+++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js
@@ -1,7 +1,23 @@
// Simple JSON-RPC 2.0 LSP-like fake server over stdio
-// Implements a minimal LSP handshake and triggers a request upon notification
let nextId = 1
+let readBuffer = Buffer.alloc(0)
+let lastChange = null
+let initializeParams = null
+let diagnosticRequestCount = 0
+let registeredCapability = false
+const pendingClientRequests = new Map()
+let pullConfig = {
+ delayMs: 0,
+ registerOn: undefined,
+ registrations: [],
+ documentDiagnostics: [],
+ documentDiagnosticsByIdentifier: {},
+ documentDelayMsByIdentifier: {},
+ workspaceDiagnostics: [],
+ workspaceDiagnosticsByIdentifier: {},
+ workspaceDelayMsByIdentifier: {},
+}
function encode(message) {
const json = JSON.stringify(message)
@@ -14,29 +30,19 @@ function decodeFrames(buffer) {
let idx
while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) {
const header = buffer.slice(0, idx).toString("utf8")
- const m = /Content-Length:\s*(\d+)/i.exec(header)
- const len = m ? parseInt(m[1], 10) : 0
+ const match = /Content-Length:\s*(\d+)/i.exec(header)
+ const length = match ? parseInt(match[1], 10) : 0
const bodyStart = idx + 4
- const bodyEnd = bodyStart + len
+ const bodyEnd = bodyStart + length
if (buffer.length < bodyEnd) break
- const body = buffer.slice(bodyStart, bodyEnd).toString("utf8")
- results.push(body)
+ results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"))
buffer = buffer.slice(bodyEnd)
}
return { messages: results, rest: buffer }
}
-let readBuffer = Buffer.alloc(0)
-
-process.stdin.on("data", (chunk) => {
- readBuffer = Buffer.concat([readBuffer, chunk])
- const { messages, rest } = decodeFrames(readBuffer)
- readBuffer = rest
- for (const m of messages) handle(m)
-})
-
-function send(msg) {
- process.stdout.write(encode(msg))
+function send(message) {
+ process.stdout.write(encode(message))
}
function sendRequest(method, params) {
@@ -45,6 +51,50 @@ function sendRequest(method, params) {
return id
}
+function sendResponse(id, result) {
+ send({ jsonrpc: "2.0", id, result })
+}
+
+function sendNotification(method, params) {
+ send({ jsonrpc: "2.0", method, params })
+}
+
+function maybeRegister(method) {
+ if (pullConfig.registerOn !== method || registeredCapability) return
+ registeredCapability = true
+ sendRequest("client/registerCapability", {
+ registrations: pullConfig.registrations.map((registration, index) => ({
+ id: registration.id ?? `pull-${index}`,
+ method: registration.method ?? "textDocument/diagnostic",
+ registerOptions: registration.registerOptions ?? registration,
+ })),
+ })
+}
+
+function delayed(id, result, delayMs = pullConfig.delayMs) {
+ if (!delayMs) {
+ sendResponse(id, result)
+ return
+ }
+ setTimeout(() => sendResponse(id, result), delayMs)
+}
+
+function diagnosticsForIdentifier(identifier) {
+ return pullConfig.documentDiagnosticsByIdentifier[identifier] ?? pullConfig.documentDiagnostics
+}
+
+function workspaceDiagnosticsForIdentifier(identifier) {
+ return pullConfig.workspaceDiagnosticsByIdentifier[identifier] ?? pullConfig.workspaceDiagnostics
+}
+
+function documentDelayForIdentifier(identifier) {
+ return pullConfig.documentDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
+}
+
+function workspaceDelayForIdentifier(identifier) {
+ return pullConfig.workspaceDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
+}
+
function handle(raw) {
let data
try {
@@ -52,24 +102,148 @@ function handle(raw) {
} catch {
return
}
+
+ if (typeof data.method === "undefined" && typeof data.id !== "undefined") {
+ const pending = pendingClientRequests.get(data.id)
+ if (!pending) return
+ pendingClientRequests.delete(data.id)
+ sendResponse(pending, data.result ?? null)
+ return
+ }
+
if (data.method === "initialize") {
- send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } })
+ initializeParams = data.params
+ sendResponse(data.id, {
+ capabilities: {
+ textDocumentSync: {
+ change: 2,
+ },
+ },
+ })
return
}
- if (data.method === "initialized") {
+
+ if (data.method === "test/get-initialize-params") {
+ sendResponse(data.id, initializeParams)
return
}
- if (data.method === "workspace/didChangeConfiguration") {
+
+ if (data.method === "test/request-configuration") {
+ const id = sendRequest("workspace/configuration", data.params)
+ pendingClientRequests.set(id, data.id)
return
}
+
+ if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") {
+ return
+ }
+
+ if (data.method === "textDocument/didOpen") {
+ maybeRegister("didOpen")
+ return
+ }
+
+ if (data.method === "textDocument/didChange") {
+ lastChange = data.params
+ maybeRegister("didChange")
+ return
+ }
+
if (data.method === "test/trigger") {
const method = data.params && data.params.method
+ if (method === "client/registerCapability") {
+ sendRequest(method, {
+ registrations: [
+ {
+ id: "test-diagnostic-registration",
+ method: "textDocument/diagnostic",
+ registerOptions: { identifier: "syntax" },
+ },
+ ],
+ })
+ return
+ }
+ if (method === "client/unregisterCapability") {
+ sendRequest(method, {
+ unregisterations: [{ id: "test-diagnostic-registration", method: "textDocument/diagnostic" }],
+ })
+ return
+ }
if (method) sendRequest(method, {})
return
}
- if (typeof data.id !== "undefined") {
- // Respond OK to any request from client to keep transport flowing
- send({ jsonrpc: "2.0", id: data.id, result: null })
+
+ if (data.method === "test/configure-pull-diagnostics") {
+ pullConfig = {
+ delayMs: data.params?.delayMs ?? 0,
+ registerOn: data.params?.registerOn,
+ registrations: data.params?.registrations ?? [],
+ documentDiagnostics: data.params?.documentDiagnostics ?? [],
+ documentDiagnosticsByIdentifier: data.params?.documentDiagnosticsByIdentifier ?? {},
+ documentDelayMsByIdentifier: data.params?.documentDelayMsByIdentifier ?? {},
+ workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [],
+ workspaceDiagnosticsByIdentifier: data.params?.workspaceDiagnosticsByIdentifier ?? {},
+ workspaceDelayMsByIdentifier: data.params?.workspaceDelayMsByIdentifier ?? {},
+ }
+ registeredCapability = false
+ sendResponse(data.id, null)
return
}
+
+ if (data.method === "test/register-configured-pull-diagnostics") {
+ maybeRegister(undefined)
+ sendResponse(data.id, null)
+ return
+ }
+
+ if (data.method === "test/publish-diagnostics") {
+ sendNotification("textDocument/publishDiagnostics", data.params)
+ return
+ }
+
+ if (data.method === "test/get-last-change") {
+ sendResponse(data.id, lastChange)
+ return
+ }
+
+ if (data.method === "test/get-diagnostic-request-count") {
+ sendResponse(data.id, diagnosticRequestCount)
+ return
+ }
+
+ if (data.method === "textDocument/diagnostic") {
+ diagnosticRequestCount += 1
+ delayed(
+ data.id,
+ {
+ kind: "full",
+ items: diagnosticsForIdentifier(data.params?.identifier ?? ""),
+ },
+ documentDelayForIdentifier(data.params?.identifier ?? ""),
+ )
+ return
+ }
+
+ if (data.method === "workspace/diagnostic") {
+ diagnosticRequestCount += 1
+ delayed(
+ data.id,
+ {
+ items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""),
+ },
+ workspaceDelayForIdentifier(data.params?.identifier ?? ""),
+ )
+ return
+ }
+
+ if (typeof data.id !== "undefined") {
+ sendResponse(data.id, null)
+ }
}
+
+process.stdin.on("data", (chunk) => {
+ readBuffer = Buffer.concat([readBuffer, chunk])
+ const { messages, rest } = decodeFrames(readBuffer)
+ readBuffer = rest
+ for (const message of messages) handle(message)
+})
diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts
index 5530e195b2..2f6f235aa1 100644
--- a/packages/opencode/test/format/format.test.ts
+++ b/packages/opencode/test/format/format.test.ts
@@ -126,6 +126,24 @@ describe("Format", () => {
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
+ it.live("file() returns false when no formatter runs", () =>
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const file = `${dir}/test.txt`
+ yield* Effect.promise(() => Bun.write(file, "x"))
+
+ const formatted = yield* Format.Service.use((fmt) => fmt.file(file))
+ expect(formatted).toBe(false)
+ }),
+ {
+ config: {
+ formatter: false,
+ },
+ },
+ ),
+ )
+
it.live("status() initializes formatter state per directory", () =>
Effect.gen(function* () {
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
@@ -219,7 +237,7 @@ describe("Format", () => {
yield* Format.Service.use((fmt) =>
Effect.gen(function* () {
yield* fmt.init()
- yield* fmt.file(file)
+ expect(yield* fmt.file(file)).toBe(true)
}),
)
@@ -229,11 +247,21 @@ describe("Format", () => {
config: {
formatter: {
first: {
- command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"],
+ command: [
+ "node",
+ "-e",
+ "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')",
+ "$FILE",
+ ],
extensions: [".seq"],
},
second: {
- command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"],
+ command: [
+ "node",
+ "-e",
+ "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')",
+ "$FILE",
+ ],
extensions: [".seq"],
},
},
diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts
index d6eaa317f9..4862f68394 100644
--- a/packages/opencode/test/lsp/client.test.ts
+++ b/packages/opencode/test/lsp/client.test.ts
@@ -1,11 +1,12 @@
-import { describe, expect, test, beforeEach } from "bun:test"
+import { beforeEach, describe, expect, test } from "bun:test"
import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../fixture/fixture"
import { LSPClient } from "../../src/lsp"
import { LSPServer } from "../../src/lsp"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util"
-// Minimal fake LSP server that speaks JSON-RPC over stdio
function spawnFakeServer() {
const { spawn } = require("child_process")
const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
@@ -39,10 +40,8 @@ describe("LSPClient interop", () => {
method: "workspace/workspaceFolders",
})
- await new Promise((r) => setTimeout(r, 100))
-
+ await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
-
await client.shutdown()
})
@@ -64,10 +63,8 @@ describe("LSPClient interop", () => {
method: "client/registerCapability",
})
- await new Promise((r) => setTimeout(r, 100))
-
+ await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
-
await client.shutdown()
})
@@ -89,10 +86,397 @@ describe("LSPClient interop", () => {
method: "client/unregisterCapability",
})
- await new Promise((r) => setTimeout(r, 100))
-
+ await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
+ await client.shutdown()
+ })
+
+ test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
+ const handle = spawnFakeServer() as any
+
+ const client = await Instance.provide({
+ directory: process.cwd(),
+ fn: () =>
+ LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: process.cwd(),
+ directory: process.cwd(),
+ }),
+ })
+
+ const params = await client.connection.sendRequest("test/get-initialize-params", {})
+ expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
+ expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
await client.shutdown()
})
+
+ test("workspace/configuration returns one result per requested item", async () => {
+ const handle = spawnFakeServer() as any
+ const initialization = {
+ alpha: {
+ beta: 1,
+ },
+ gamma: true,
+ }
+
+ const client = await Instance.provide({
+ directory: process.cwd(),
+ fn: () =>
+ LSPClient.create({
+ serverID: "fake",
+ server: {
+ ...(handle as unknown as LSPServer.Handle),
+ initialization,
+ },
+ root: process.cwd(),
+ directory: process.cwd(),
+ }),
+ })
+
+ const response = await client.connection.sendRequest("test/request-configuration", {
+ items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
+ })
+
+ expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
+
+ await client.shutdown()
+ })
+
+ test("sends ranged didChange for incremental sync servers", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.ts")
+ await Bun.write(file, "first\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.notify.open({ path: file })
+ await Bun.write(file, "second\nthird\n")
+ await client.notify.open({ path: file })
+
+ const change = await client.connection.sendRequest<{
+ textDocument: { version: number }
+ contentChanges: {
+ range?: { start: { line: number; character: number }; end: { line: number; character: number } }
+ text: string
+ }[]
+ }>("test/get-last-change", {})
+ expect(change.textDocument.version).toBe(1)
+ expect(change.contentChanges).toEqual([
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 1, character: 0 },
+ },
+ text: "second\nthird\n",
+ },
+ ])
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("document mode falls back to push diagnostics", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ const version = await client.notify.open({ path: file })
+ const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
+ await client.connection.sendNotification("test/publish-diagnostics", {
+ uri: pathToFileURL(file).href,
+ version,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "push diagnostic",
+ severity: 1,
+ },
+ ],
+ })
+ await wait
+
+ const diagnostics = client.diagnostics.get(file) ?? []
+ expect(diagnostics).toHaveLength(1)
+ expect(diagnostics[0]?.message).toBe("push diagnostic")
+
+ const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
+ expect(count).toBe(0)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("document mode accepts matching push diagnostics published before waiting", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ const version = await client.notify.open({ path: file })
+ await client.connection.sendNotification("test/publish-diagnostics", {
+ uri: pathToFileURL(file).href,
+ version,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "push diagnostic",
+ severity: 1,
+ },
+ ],
+ })
+
+ for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 25))
+ }
+
+ expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic")
+
+ const started = Date.now()
+ await client.waitForDiagnostics({ path: file, version, mode: "document" })
+ expect(Date.now() - started).toBeLessThan(1_000)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("document mode waits for pull diagnostics", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.cs")
+ await Bun.write(file, "class C {}\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registerOn: "didOpen",
+ registrations: [{ identifier: "DocumentCompilerSemantic" }],
+ documentDiagnosticsByIdentifier: {
+ DocumentCompilerSemantic: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "pull diagnostic",
+ severity: 1,
+ },
+ ],
+ },
+ })
+
+ const version = await client.notify.open({ path: file })
+ await client.waitForDiagnostics({ path: file, version, mode: "document" })
+
+ const diagnostics = client.diagnostics.get(file) ?? []
+ expect(diagnostics).toHaveLength(1)
+ expect(diagnostics[0]?.message).toBe("pull diagnostic")
+
+ const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
+ expect(count).toBeGreaterThan(0)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.cs")
+ await Bun.write(file, "class C {}\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registrations: [{ identifier: "fast" }, { identifier: "slow" }],
+ documentDiagnosticsByIdentifier: {
+ fast: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "fast diagnostic",
+ severity: 1,
+ },
+ ],
+ slow: [],
+ },
+ documentDelayMsByIdentifier: {
+ slow: 2_500,
+ },
+ })
+
+ const version = await client.notify.open({ path: file })
+ await client.connection.sendRequest("test/register-configured-pull-diagnostics", {})
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ const started = Date.now()
+ await client.waitForDiagnostics({ path: file, version, mode: "document" })
+
+ expect(Date.now() - started).toBeLessThan(1_000)
+ expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
+ expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("full mode includes workspace pull diagnostics", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.cs")
+ const related = path.join(tmp.path, "other.cs")
+ await Bun.write(file, "class C {}\n")
+ await Bun.write(related, "class D {}\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registerOn: "didOpen",
+ registrations: [
+ { identifier: "DocumentCompilerSemantic" },
+ { identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true },
+ ],
+ documentDiagnosticsByIdentifier: {
+ DocumentCompilerSemantic: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "current file",
+ severity: 1,
+ },
+ ],
+ },
+ workspaceDiagnosticsByIdentifier: {
+ WorkspaceDocumentsAndProject: [
+ {
+ uri: pathToFileURL(related).href,
+ items: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "workspace file",
+ severity: 1,
+ },
+ ],
+ },
+ ],
+ },
+ })
+
+ const version = await client.notify.open({ path: file })
+ await client.waitForDiagnostics({ path: file, version, mode: "full" })
+
+ expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
+ expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("full mode treats an empty workspace pull response as handled", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.cs")
+ await Bun.write(file, "class C {}\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registerOn: "didOpen",
+ registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
+ workspaceDiagnosticsByIdentifier: {
+ WorkspaceDocumentsAndProject: [],
+ },
+ })
+
+ const version = await client.notify.open({ path: file })
+ const started = Date.now()
+ await client.waitForDiagnostics({ path: file, version, mode: "full" })
+
+ expect(Date.now() - started).toBeLessThan(1_000)
+
+ await client.shutdown()
+ },
+ })
+ })
})
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index d654d4b876..372e1be7eb 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -128,6 +128,67 @@ test("fromConfig - does not expand tilde in middle of path", () => {
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
})
+// Top-level wildcard-vs-specific precedence semantics.
+//
+// fromConfig sorts top-level keys so wildcard permissions (containing "*")
+// come before specific permissions. Combined with `findLast` in evaluate(),
+// this gives the intuitive semantic "specific tool rules override the `*`
+// fallback", regardless of the order the user wrote the keys in their JSON.
+//
+// Sub-pattern order inside a single permission key (e.g. `bash: { "*": "allow", "rm": "deny" }`)
+// still depends on insertion order — only top-level keys are sorted.
+
+test("fromConfig - specific key beats wildcard regardless of JSON key order", () => {
+ const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
+ const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" })
+
+ // Both orderings produce the same ruleset
+ expect(wildcardFirst).toEqual(specificFirst)
+
+ // And both evaluate bash → allow (bash rule wins over * fallback)
+ expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
+ expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow")
+})
+
+test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => {
+ const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" })
+ expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask")
+ expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
+})
+
+test("fromConfig - top-level ordering: wildcards first, specifics after", () => {
+ const ruleset = Permission.fromConfig({
+ bash: "allow",
+ "*": "ask",
+ edit: "deny",
+ "mcp_*": "allow",
+ })
+ // wildcards (* and mcp_*) come before specifics (bash, edit)
+ const permissions = ruleset.map((r) => r.permission)
+ expect(permissions.slice(0, 2).sort()).toEqual(["*", "mcp_*"])
+ expect(permissions.slice(2)).toEqual(["bash", "edit"])
+})
+
+test("fromConfig - sub-pattern insertion order inside a tool key is preserved (only top-level sorts)", () => {
+ // Sub-patterns within a single tool key use the documented "`*` first,
+ // specific patterns after" convention (findLast picks specifics). The
+ // top-level sort must not touch sub-pattern ordering.
+ const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } })
+ expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"])
+ // * fallback for unknown commands
+ expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny")
+ // specific pattern wins for git commands (it's last, findLast picks it)
+ expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow")
+})
+
+test("fromConfig - canonical documented example unchanged", () => {
+ // Regression guard for the example in docs/permissions.mdx
+ const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" })
+ expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("read", "foo.ts", ruleset).action).toBe("ask")
+})
+
test("fromConfig - expands exact tilde to home directory", () => {
const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
@@ -422,9 +483,9 @@ test("disabled - disables tool when denied", () => {
expect(result.has("read")).toBe(false)
})
-test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
+test("disabled - disables edit/write/apply_patch when edit denied", () => {
const result = Permission.disabled(
- ["edit", "write", "apply_patch", "multiedit", "bash"],
+ ["edit", "write", "apply_patch", "bash"],
[
{ permission: "*", pattern: "*", action: "allow" },
{ permission: "edit", pattern: "*", action: "deny" },
@@ -433,7 +494,6 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", ()
expect(result.has("edit")).toBe(true)
expect(result.has("write")).toBe(true)
expect(result.has("apply_patch")).toBe(true)
- expect(result.has("multiedit")).toBe(true)
expect(result.has("bash")).toBe(false)
})
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index 4dc9ee5efa..4664b6c258 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -472,3 +472,87 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
})
})
+
+describe("Project.fromDirectory with bare repos", () => {
+ test("worktree from bare repo should cache in bare repo, not parent", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const parentDir = path.dirname(tmp.path)
+ const barePath = path.join(parentDir, `bare-${Date.now()}.git`)
+ const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
+
+ try {
+ await $`git clone --bare ${tmp.path} ${barePath}`.quiet()
+ await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()
+
+ const { project } = await run((svc) => svc.fromDirectory(worktreePath))
+
+ expect(project.id).not.toBe(ProjectID.global)
+ expect(project.worktree).toBe(barePath)
+
+ const correctCache = path.join(barePath, "opencode")
+ const wrongCache = path.join(parentDir, ".git", "opencode")
+
+ expect(await Bun.file(correctCache).exists()).toBe(true)
+ expect(await Bun.file(wrongCache).exists()).toBe(false)
+ } finally {
+ await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()
+ }
+ })
+
+ test("different bare repos under same parent should not share project ID", async () => {
+ await using tmp1 = await tmpdir({ git: true })
+ await using tmp2 = await tmpdir({ git: true })
+
+ const parentDir = path.dirname(tmp1.path)
+ const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`)
+ const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`)
+ const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`)
+ const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`)
+
+ try {
+ await $`git clone --bare ${tmp1.path} ${bareA}`.quiet()
+ await $`git clone --bare ${tmp2.path} ${bareB}`.quiet()
+ await $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet()
+ await $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet()
+
+ const { project: projA } = await run((svc) => svc.fromDirectory(worktreeA))
+ const { project: projB } = await run((svc) => svc.fromDirectory(worktreeB))
+
+ expect(projA.id).not.toBe(projB.id)
+
+ const cacheA = path.join(bareA, "opencode")
+ const cacheB = path.join(bareB, "opencode")
+ const wrongCache = path.join(parentDir, ".git", "opencode")
+
+ expect(await Bun.file(cacheA).exists()).toBe(true)
+ expect(await Bun.file(cacheB).exists()).toBe(true)
+ expect(await Bun.file(wrongCache).exists()).toBe(false)
+ } finally {
+ await $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow()
+ }
+ })
+
+ test("bare repo without .git suffix is still detected via core.bare", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const parentDir = path.dirname(tmp.path)
+ const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`)
+ const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
+
+ try {
+ await $`git clone --bare ${tmp.path} ${barePath}`.quiet()
+ await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()
+
+ const { project } = await run((svc) => svc.fromDirectory(worktreePath))
+
+ expect(project.id).not.toBe(ProjectID.global)
+ expect(project.worktree).toBe(barePath)
+
+ const correctCache = path.join(barePath, "opencode")
+ expect(await Bun.file(correctCache).exists()).toBe(true)
+ } finally {
+ await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()
+ }
+ })
+})
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 7a7631710d..791fcdedc6 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -2113,7 +2113,24 @@ describe("ProviderTransform.variants", () => {
expect(result).toEqual({})
})
- test("mistral returns empty object", () => {
+ test("mistral with reasoning returns variants", () => {
+ const model = createMockModel({
+ id: "mistral/mistral-small-latest",
+ providerID: "mistral",
+ api: {
+ id: "mistral-small-latest",
+ url: "https://api.mistral.com",
+ npm: "@ai-sdk/mistral",
+ },
+ capabilities: { reasoning: true },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({
+ high: { reasoningEffort: "high" },
+ })
+ })
+
+ test("mistral without reasoning returns empty object", () => {
const model = createMockModel({
id: "mistral/mistral-large",
providerID: "mistral",
@@ -2122,6 +2139,22 @@ describe("ProviderTransform.variants", () => {
url: "https://api.mistral.com",
npm: "@ai-sdk/mistral",
},
+ capabilities: { reasoning: false },
+ })
+ const result = ProviderTransform.variants(model)
+ expect(result).toEqual({})
+ })
+
+ test("mistral large with reasoning returns empty object (only small supports reasoning)", () => {
+ const model = createMockModel({
+ id: "mistral/mistral-large",
+ providerID: "mistral",
+ api: {
+ id: "mistral-large-latest",
+ url: "https://api.mistral.com",
+ npm: "@ai-sdk/mistral",
+ },
+ capabilities: { reasoning: true },
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts
index 14b47922b4..037613d469 100644
--- a/packages/opencode/test/session/compaction.test.ts
+++ b/packages/opencode/test/session/compaction.test.ts
@@ -143,6 +143,45 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string
return msg
}
+async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) {
+ const msg: MessageV2.Assistant = {
+ id: MessageID.ascending(),
+ role: "assistant",
+ sessionID,
+ mode: "compaction",
+ agent: "compaction",
+ path: { cwd: root, root },
+ cost: 0,
+ tokens: {
+ output: 0,
+ input: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ },
+ modelID: ref.modelID,
+ providerID: ref.providerID,
+ parentID,
+ summary: true,
+ time: { created: Date.now() },
+ finish: "end_turn",
+ }
+ await svc.updateMessage(msg)
+ await svc.updatePart({
+ id: PartID.ascending(),
+ messageID: msg.id,
+ sessionID,
+ type: "text",
+ text,
+ })
+ return msg
+}
+
+async function lastCompactionPart(sessionID: SessionID) {
+ return (await svc.messages({ sessionID }))
+ .at(-2)
+ ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction")
+}
+
function fake(
input: Parameters[0],
result: "continue" | "compact",
@@ -168,7 +207,7 @@ function layer(result: "continue" | "compact") {
}
function cfg(compaction?: Config.Info["compaction"]) {
- const base = Config.Info.parse({})
+ const base = Config.Info.zod.parse({})
return Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...base, compaction }),
})
@@ -946,12 +985,9 @@ describe("session.compaction.process", () => {
),
)
- const part = (await svc.messages({ sessionID: session.id }))
- .at(-2)
- ?.parts.find((item) => item.type === "compaction")
-
+ const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
- if (part?.type === "compaction") expect(part.tail_start_id).toBe(keep.id)
+ expect(part?.tail_start_id).toBe(keep.id)
} finally {
await rt.dispose()
}
@@ -991,12 +1027,9 @@ describe("session.compaction.process", () => {
),
)
- const part = (await svc.messages({ sessionID: session.id }))
- .at(-2)
- ?.parts.find((item) => item.type === "compaction")
-
+ const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
- if (part?.type === "compaction") expect(part.tail_start_id).toBe(keep.id)
+ expect(part?.tail_start_id).toBe(keep.id)
} finally {
await rt.dispose()
}
@@ -1042,12 +1075,9 @@ describe("session.compaction.process", () => {
),
)
- const part = (await svc.messages({ sessionID: session.id }))
- .at(-2)
- ?.parts.find((item) => item.type === "compaction")
-
+ const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
- if (part?.type === "compaction") expect(part.tail_start_id).toBeUndefined()
+ expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("yyyy")
} finally {
await rt.dispose()
@@ -1103,12 +1133,9 @@ describe("session.compaction.process", () => {
),
)
- const part = (await svc.messages({ sessionID: session.id }))
- .at(-2)
- ?.parts.find((item) => item.type === "compaction")
-
+ const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
- if (part?.type === "compaction") expect(part.tail_start_id).toBeUndefined()
+ expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("recent image turn")
expect(captured).toContain("Attached image/png: big.png")
} finally {
@@ -1118,6 +1145,76 @@ describe("session.compaction.process", () => {
})
})
+ test("retains a split turn suffix when a later message fits the preserve token budget", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const stub = llm()
+ let captured = ""
+ stub.push(
+ reply("summary", (input) => {
+ captured = JSON.stringify(input.messages)
+ }),
+ )
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const session = await svc.create({})
+ await user(session.id, "older")
+ const recent = await user(session.id, "recent turn")
+ const large = await assistant(session.id, recent.id, tmp.path)
+ await svc.updatePart({
+ id: PartID.ascending(),
+ messageID: large.id,
+ sessionID: session.id,
+ type: "text",
+ text: "z".repeat(2_000),
+ })
+ const keep = await assistant(session.id, recent.id, tmp.path)
+ await svc.updatePart({
+ id: PartID.ascending(),
+ messageID: keep.id,
+ sessionID: session.id,
+ type: "text",
+ text: "keep tail",
+ })
+ await SessionCompaction.create({
+ sessionID: session.id,
+ agent: "build",
+ model: ref,
+ auto: false,
+ })
+
+ const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 }))
+ try {
+ const msgs = await svc.messages({ sessionID: session.id })
+ const parent = msgs.at(-1)?.info.id
+ expect(parent).toBeTruthy()
+ await rt.runPromise(
+ SessionCompaction.Service.use((svc) =>
+ svc.process({
+ parentID: parent!,
+ messages: msgs,
+ sessionID: session.id,
+ auto: false,
+ }),
+ ),
+ )
+
+ const part = await lastCompactionPart(session.id)
+ expect(part?.type).toBe("compaction")
+ expect(part?.tail_start_id).toBe(keep.id)
+ expect(captured).toContain("zzzz")
+ expect(captured).not.toContain("keep tail")
+
+ const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
+ expect(filtered[0]?.info.id).toBe(keep.id)
+ expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
+ } finally {
+ await rt.dispose()
+ }
+ },
+ })
+ })
+
test("allows plugins to disable synthetic continue prompt", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -1530,6 +1627,80 @@ describe("session.compaction.process", () => {
})
})
+ test("anchors repeated compactions with the previous summary", async () => {
+ const stub = llm()
+ let captured = ""
+ stub.push(reply("summary one"))
+ stub.push(
+ reply("summary two", (input) => {
+ captured = JSON.stringify(input.messages)
+ }),
+ )
+
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const session = await svc.create({})
+ await user(session.id, "older context")
+ await user(session.id, "keep this turn")
+ await SessionCompaction.create({
+ sessionID: session.id,
+ agent: "build",
+ model: ref,
+ auto: false,
+ })
+
+ const rt = liveRuntime(stub.layer, wide())
+ try {
+ let msgs = await svc.messages({ sessionID: session.id })
+ let parent = msgs.at(-1)?.info.id
+ expect(parent).toBeTruthy()
+ await rt.runPromise(
+ SessionCompaction.Service.use((svc) =>
+ svc.process({
+ parentID: parent!,
+ messages: msgs,
+ sessionID: session.id,
+ auto: false,
+ }),
+ ),
+ )
+
+ await user(session.id, "latest turn")
+ await SessionCompaction.create({
+ sessionID: session.id,
+ agent: "build",
+ model: ref,
+ auto: false,
+ })
+
+ msgs = MessageV2.filterCompacted(MessageV2.stream(session.id))
+ parent = msgs.at(-1)?.info.id
+ expect(parent).toBeTruthy()
+ await rt.runPromise(
+ SessionCompaction.Service.use((svc) =>
+ svc.process({
+ parentID: parent!,
+ messages: msgs,
+ sessionID: session.id,
+ auto: false,
+ }),
+ ),
+ )
+
+ expect(captured).toContain("")
+ expect(captured).toContain("summary one")
+ expect(captured.match(/summary one/g)?.length).toBe(1)
+ expect(captured).toContain("## Constraints & Preferences")
+ expect(captured).toContain("## Progress")
+ } finally {
+ await rt.dispose()
+ }
+ },
+ })
+ })
+
test("keeps recent pre-compaction turns across repeated compactions", async () => {
const stub = llm()
stub.push(reply("summary one"))
@@ -1604,6 +1775,76 @@ describe("session.compaction.process", () => {
},
})
})
+
+ test("ignores previous summaries when sizing the retained tail", async () => {
+ await using tmp = await tmpdir()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const session = await svc.create({})
+ await user(session.id, "older")
+ const keep = await user(session.id, "keep this turn")
+ const keepReply = await assistant(session.id, keep.id, tmp.path)
+ await svc.updatePart({
+ id: PartID.ascending(),
+ messageID: keepReply.id,
+ sessionID: session.id,
+ type: "text",
+ text: "keep reply",
+ })
+
+ await SessionCompaction.create({
+ sessionID: session.id,
+ agent: "build",
+ model: ref,
+ auto: false,
+ })
+ const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id
+ expect(firstCompaction).toBeTruthy()
+ await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800))
+
+ const recent = await user(session.id, "recent turn")
+ const recentReply = await assistant(session.id, recent.id, tmp.path)
+ await svc.updatePart({
+ id: PartID.ascending(),
+ messageID: recentReply.id,
+ sessionID: session.id,
+ type: "text",
+ text: "recent reply",
+ })
+
+ await SessionCompaction.create({
+ sessionID: session.id,
+ agent: "build",
+ model: ref,
+ auto: false,
+ })
+
+ const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 }))
+ try {
+ const msgs = await svc.messages({ sessionID: session.id })
+ const parent = msgs.at(-1)?.info.id
+ expect(parent).toBeTruthy()
+ await rt.runPromise(
+ SessionCompaction.Service.use((svc) =>
+ svc.process({
+ parentID: parent!,
+ messages: msgs,
+ sessionID: session.id,
+ auto: false,
+ }),
+ ),
+ )
+
+ const part = await lastCompactionPart(session.id)
+ expect(part?.type).toBe("compaction")
+ expect(part?.tail_start_id).toBe(keep.id)
+ } finally {
+ await rt.dispose()
+ }
+ },
+ })
+ })
})
describe("util.token.estimate", () => {
diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts
index 55ae65c560..231d58c21a 100644
--- a/packages/opencode/test/session/message-v2.test.ts
+++ b/packages/opencode/test/session/message-v2.test.ts
@@ -585,6 +585,76 @@ describe("session.message-v2.toModelMessage", () => {
])
})
+ test("truncates tool output when requested", async () => {
+ const userID = "m-user"
+ const assistantID = "m-assistant"
+
+ const input: MessageV2.WithParts[] = [
+ {
+ info: userInfo(userID),
+ parts: [
+ {
+ ...basePart(userID, "u1"),
+ type: "text",
+ text: "run tool",
+ },
+ ] as MessageV2.Part[],
+ },
+ {
+ info: assistantInfo(assistantID, userID),
+ parts: [
+ {
+ ...basePart(assistantID, "a1"),
+ type: "tool",
+ callID: "call-1",
+ tool: "bash",
+ state: {
+ status: "completed",
+ input: { cmd: "ls" },
+ output: "abcdefghij",
+ title: "Bash",
+ metadata: {},
+ time: { start: 0, end: 1 },
+ },
+ },
+ ] as MessageV2.Part[],
+ },
+ ]
+
+ expect(await MessageV2.toModelMessages(input, model, { toolOutputMaxChars: 4 })).toStrictEqual([
+ {
+ role: "user",
+ content: [{ type: "text", text: "run tool" }],
+ },
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "tool-call",
+ toolCallId: "call-1",
+ toolName: "bash",
+ input: { cmd: "ls" },
+ providerExecuted: undefined,
+ },
+ ],
+ },
+ {
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ toolCallId: "call-1",
+ toolName: "bash",
+ output: {
+ type: "text",
+ value: "abcd\n[Tool output truncated for compaction: omitted 6 chars]",
+ },
+ },
+ ],
+ },
+ ])
+ })
+
test("converts assistant tool error into error-text tool result", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts
index d8dcf5e7cb..df2d18b9f1 100644
--- a/packages/opencode/test/session/messages-pagination.test.ts
+++ b/packages/opencode/test/session/messages-pagination.test.ts
@@ -837,6 +837,70 @@ describe("MessageV2.filterCompacted", () => {
})
})
+ test("retains an assistant tail when compaction starts inside a turn", async () => {
+ await Instance.provide({
+ directory: root,
+ fn: async () => {
+ const session = await svc.create({})
+
+ const u1 = await addUser(session.id, "first")
+ const a1 = await addAssistant(session.id, u1, { finish: "end_turn" })
+ await svc.updatePart({
+ id: PartID.ascending(),
+ sessionID: session.id,
+ messageID: a1,
+ type: "text",
+ text: "first reply",
+ })
+
+ const u2 = await addUser(session.id, "second")
+ const a2 = await addAssistant(session.id, u2, { finish: "end_turn" })
+ await svc.updatePart({
+ id: PartID.ascending(),
+ sessionID: session.id,
+ messageID: a2,
+ type: "text",
+ text: "second reply",
+ })
+ const a3 = await addAssistant(session.id, u2, { finish: "end_turn" })
+ await svc.updatePart({
+ id: PartID.ascending(),
+ sessionID: session.id,
+ messageID: a3,
+ type: "text",
+ text: "tail reply",
+ })
+
+ const c1 = await addUser(session.id)
+ await addCompactionPart(session.id, c1, a3)
+ const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" })
+ await svc.updatePart({
+ id: PartID.ascending(),
+ sessionID: session.id,
+ messageID: s1,
+ type: "text",
+ text: "summary",
+ })
+
+ const u3 = await addUser(session.id, "third")
+ const a4 = await addAssistant(session.id, u3, { finish: "end_turn" })
+ await svc.updatePart({
+ id: PartID.ascending(),
+ sessionID: session.id,
+ messageID: a4,
+ type: "text",
+ text: "third reply",
+ })
+
+ const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
+
+ expect(result.map((item) => item.info.id)).toEqual([a3, c1, s1, u3, a4])
+
+ await svc.remove(session.id)
+ },
+ })
+ })
+
test("prefers latest compaction boundary when repeated compactions exist", async () => {
await Instance.provide({
directory: root,
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
deleted file mode 100644
index fae3954e84..0000000000
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ /dev/null
@@ -1,1589 +0,0 @@
-import { NodeFileSystem } from "@effect/platform-node"
-import { FetchHttpClient } from "effect/unstable/http"
-import { expect } from "bun:test"
-import { Cause, Effect, Exit, Fiber, Layer } from "effect"
-import path from "path"
-import { Agent as AgentSvc } from "../../src/agent/agent"
-import { Bus } from "../../src/bus"
-import { Command } from "../../src/command"
-import { Config } from "../../src/config"
-import { LSP } from "../../src/lsp"
-import { MCP } from "../../src/mcp"
-import { Permission } from "../../src/permission"
-import { Plugin } from "../../src/plugin"
-import { Provider as ProviderSvc } from "../../src/provider"
-import { Env } from "../../src/env"
-import { ModelID, ProviderID } from "../../src/provider/schema"
-import { Question } from "../../src/question"
-import { Todo } from "../../src/session/todo"
-import { Session } from "../../src/session"
-import { LLM } from "../../src/session/llm"
-import { MessageV2 } from "../../src/session/message-v2"
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { SessionCompaction } from "../../src/session/compaction"
-import { SessionSummary } from "../../src/session/summary"
-import { Instruction } from "../../src/session/instruction"
-import { SessionProcessor } from "../../src/session/processor"
-import { SessionPrompt } from "../../src/session/prompt"
-import { SessionRevert } from "../../src/session/revert"
-import { SessionRunState } from "../../src/session/run-state"
-import { MessageID, PartID, SessionID } from "../../src/session/schema"
-import { SessionStatus } from "../../src/session/status"
-import { Skill } from "../../src/skill"
-import { SystemPrompt } from "../../src/session/system"
-import { Shell } from "../../src/shell/shell"
-import { Snapshot } from "../../src/snapshot"
-import { ToolRegistry } from "../../src/tool"
-import { Truncate } from "../../src/tool"
-import { Log } from "../../src/util"
-import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
-import { Format } from "../../src/format"
-import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
-import { testEffect } from "../lib/effect"
-import { reply, TestLLMServer } from "../lib/llm-server"
-
-void Log.init({ print: false })
-
-const summary = Layer.succeed(
- SessionSummary.Service,
- SessionSummary.Service.of({
- summarize: () => Effect.void,
- diff: () => Effect.succeed([]),
- computeDiff: () => Effect.succeed([]),
- }),
-)
-
-const ref = {
- providerID: ProviderID.make("test"),
- modelID: ModelID.make("test-model"),
-}
-
-function defer() {
- let resolve!: (value: T | PromiseLike) => void
- const promise = new Promise((done) => {
- resolve = done
- })
- return { promise, resolve }
-}
-
-function withSh(fx: () => Effect.Effect) {
- return Effect.acquireUseRelease(
- Effect.sync(() => {
- const prev = process.env.SHELL
- process.env.SHELL = "/bin/sh"
- Shell.preferred.reset()
- return prev
- }),
- () => fx(),
- (prev) =>
- Effect.sync(() => {
- if (prev === undefined) delete process.env.SHELL
- else process.env.SHELL = prev
- Shell.preferred.reset()
- }),
- )
-}
-
-function toolPart(parts: MessageV2.Part[]) {
- return parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
-}
-
-type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted }
-type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError }
-
-function completedTool(parts: MessageV2.Part[]) {
- const part = toolPart(parts)
- expect(part?.state.status).toBe("completed")
- return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined
-}
-
-function errorTool(parts: MessageV2.Part[]) {
- const part = toolPart(parts)
- expect(part?.state.status).toBe("error")
- return part?.state.status === "error" ? (part as ErrorToolPart) : undefined
-}
-
-const mcp = Layer.succeed(
- MCP.Service,
- MCP.Service.of({
- status: () => Effect.succeed({}),
- clients: () => Effect.succeed({}),
- tools: () => Effect.succeed({}),
- prompts: () => Effect.succeed({}),
- resources: () => Effect.succeed({}),
- add: () => Effect.succeed({ status: { status: "disabled" as const } }),
- connect: () => Effect.void,
- disconnect: () => Effect.void,
- getPrompt: () => Effect.succeed(undefined),
- readResource: () => Effect.succeed(undefined),
- startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
- authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
- finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
- removeAuth: () => Effect.void,
- supportsOAuth: () => Effect.succeed(false),
- hasStoredTokens: () => Effect.succeed(false),
- getAuthStatus: () => Effect.succeed("not_authenticated" as const),
- }),
-)
-
-const lsp = Layer.succeed(
- LSP.Service,
- LSP.Service.of({
- init: () => Effect.void,
- status: () => Effect.succeed([]),
- hasClients: () => Effect.succeed(false),
- touchFile: () => Effect.void,
- diagnostics: () => Effect.succeed({}),
- hover: () => Effect.succeed(undefined),
- definition: () => Effect.succeed([]),
- references: () => Effect.succeed([]),
- implementation: () => Effect.succeed([]),
- documentSymbol: () => Effect.succeed([]),
- workspaceSymbol: () => Effect.succeed([]),
- prepareCallHierarchy: () => Effect.succeed([]),
- incomingCalls: () => Effect.succeed([]),
- outgoingCalls: () => Effect.succeed([]),
- }),
-)
-
-const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
-const run = SessionRunState.layer.pipe(Layer.provide(status))
-const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
-function makeHttp() {
- const deps = Layer.mergeAll(
- Session.defaultLayer,
- Snapshot.defaultLayer,
- LLM.defaultLayer,
- Env.defaultLayer,
- AgentSvc.defaultLayer,
- Command.defaultLayer,
- Permission.defaultLayer,
- Plugin.defaultLayer,
- Config.defaultLayer,
- ProviderSvc.defaultLayer,
- lsp,
- mcp,
- AppFileSystem.defaultLayer,
- status,
- ).pipe(Layer.provideMerge(infra))
- const question = Question.layer.pipe(Layer.provideMerge(deps))
- const todo = Todo.layer.pipe(Layer.provideMerge(deps))
- const registry = ToolRegistry.layer.pipe(
- Layer.provide(Skill.defaultLayer),
- Layer.provide(FetchHttpClient.layer),
- Layer.provide(CrossSpawnSpawner.defaultLayer),
- Layer.provide(Ripgrep.defaultLayer),
- Layer.provide(Format.defaultLayer),
- Layer.provideMerge(todo),
- Layer.provideMerge(question),
- Layer.provideMerge(deps),
- )
- const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
- const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
- const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
- return Layer.mergeAll(
- TestLLMServer.layer,
- SessionPrompt.layer.pipe(
- Layer.provide(SessionRevert.defaultLayer),
- Layer.provide(summary),
- Layer.provideMerge(run),
- Layer.provideMerge(compact),
- Layer.provideMerge(proc),
- Layer.provideMerge(registry),
- Layer.provideMerge(trunc),
- Layer.provide(Instruction.defaultLayer),
- Layer.provide(SystemPrompt.defaultLayer),
- Layer.provideMerge(deps),
- ),
- ).pipe(Layer.provide(summary))
-}
-
-const it = testEffect(makeHttp())
-const unix = process.platform !== "win32" ? it.live : it.live.skip
-
-// Config that registers a custom "test" provider with a "test-model" model
-// so provider model lookup succeeds inside the loop.
-const cfg = {
- provider: {
- test: {
- name: "Test",
- id: "test",
- env: [],
- npm: "@ai-sdk/openai-compatible",
- models: {
- "test-model": {
- id: "test-model",
- name: "Test Model",
- attachment: false,
- reasoning: false,
- temperature: false,
- tool_call: true,
- release_date: "2025-01-01",
- limit: { context: 100000, output: 10000 },
- cost: { input: 0, output: 0 },
- options: {},
- },
- },
- options: {
- apiKey: "test-key",
- baseURL: "http://localhost:1/v1",
- },
- },
- },
-}
-
-function providerCfg(url: string) {
- return {
- ...cfg,
- provider: {
- ...cfg.provider,
- test: {
- ...cfg.provider.test,
- options: {
- ...cfg.provider.test.options,
- baseURL: url,
- },
- },
- },
- }
-}
-
-const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) {
- const session = yield* Session.Service
- const msg = yield* session.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID,
- agent: "build",
- model: ref,
- time: { created: Date.now() },
- })
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: msg.id,
- sessionID,
- type: "text",
- text,
- })
- return msg
-})
-
-const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) {
- const session = yield* Session.Service
- const msg = yield* user(sessionID, "hello")
- const assistant: MessageV2.Assistant = {
- id: MessageID.ascending(),
- role: "assistant",
- parentID: msg.id,
- sessionID,
- mode: "build",
- agent: "build",
- cost: 0,
- path: { cwd: "/tmp", root: "/tmp" },
- tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- modelID: ref.modelID,
- providerID: ref.providerID,
- time: { created: Date.now() },
- ...(opts?.finish ? { finish: opts.finish } : {}),
- }
- yield* session.updateMessage(assistant)
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID: assistant.id,
- sessionID,
- type: "text",
- text: "hi there",
- })
- return { user: msg, assistant }
-})
-
-const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
- Effect.gen(function* () {
- const session = yield* Session.Service
- yield* session.updatePart({
- id: PartID.ascending(),
- messageID,
- sessionID,
- type: "subtask",
- prompt: "look into the cache key path",
- description: "inspect bug",
- agent: "general",
- model,
- })
- })
-
-const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
- const config = yield* Config.Service
- const prompt = yield* SessionPrompt.Service
- const run = yield* SessionRunState.Service
- const sessions = yield* Session.Service
- yield* config.get()
- const chat = yield* sessions.create(input ?? { title: "Pinned" })
- return { prompt, run, sessions, chat }
-})
-
-// Loop semantics
-
-it.live("loop exits immediately when last assistant has stop finish", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* seed(chat.id, { finish: "stop" })
-
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
- expect(yield* llm.calls).toBe(0)
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("loop calls LLM and returns assistant message", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.text("world")
-
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- const parts = result.parts.filter((p) => p.type === "text")
- expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true)
- expect(yield* llm.hits).toHaveLength(1)
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("static loop returns assistant text through local provider", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Prompt provider",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
-
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
-
- yield* llm.text("world")
-
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(result.info.role).toBe("assistant")
- expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
- expect(yield* llm.hits).toHaveLength(1)
- expect(yield* llm.pending).toBe(0)
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("static loop consumes queued replies across turns", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Prompt provider turns",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
-
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello one" }],
- })
-
- yield* llm.text("world one")
-
- const first = yield* prompt.loop({ sessionID: session.id })
- expect(first.info.role).toBe("assistant")
- expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
-
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello two" }],
- })
-
- yield* llm.text("world two")
-
- const second = yield* prompt.loop({ sessionID: session.id })
- expect(second.info.role).toBe("assistant")
- expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
-
- expect(yield* llm.hits).toHaveLength(2)
- expect(yield* llm.pending).toBe(0)
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("loop continues when finish is tool-calls", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.tool("first", { value: "first" })
- yield* llm.text("second")
-
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(yield* llm.calls).toBe(2)
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") {
- expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
- expect(result.info.finish).toBe("stop")
- }
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("glob tool keeps instance context during prompt runs", () =>
- provideTmpdirServer(
- ({ dir, llm }) =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Glob context",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- const file = path.join(dir, "probe.txt")
- yield* Effect.promise(() => Bun.write(file, "probe"))
-
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "find text files" }],
- })
- yield* llm.tool("glob", { pattern: "**/*.txt" })
- yield* llm.text("done")
-
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(result.info.role).toBe("assistant")
-
- const msgs = yield* MessageV2.filterCompactedEffect(session.id)
- const tool = msgs
- .flatMap((msg) => msg.parts)
- .find(
- (part): part is CompletedToolPart =>
- part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
- )
- if (!tool) return
-
- expect(tool.state.output).toContain(file)
- expect(tool.state.output).not.toContain("No context found for instance")
- expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("loop continues when finish is stop but assistant has tool parts", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const session = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* prompt.prompt({
- sessionID: session.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- })
- yield* llm.push(reply().tool("first", { value: "first" }).stop())
- yield* llm.text("second")
-
- const result = yield* prompt.loop({ sessionID: session.id })
- expect(yield* llm.calls).toBe(2)
- expect(result.info.role).toBe("assistant")
- if (result.info.role === "assistant") {
- expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
- expect(result.info.finish).toBe("stop")
- }
- }),
- { git: true, config: providerCfg },
- ),
-)
-
-it.live("failed subtask preserves metadata on error tool state", () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.tool("task", {
- description: "inspect bug",
- prompt: "look into the cache key path",
- subagent_type: "general",
- })
- yield* llm.text("done")
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
-
- const result = yield* prompt.loop({ sessionID: chat.id })
- expect(result.info.role).toBe("assistant")
- expect(yield* llm.calls).toBe(2)
-
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- expect(taskMsg?.info.role).toBe("assistant")
- if (!taskMsg || taskMsg.info.role !== "assistant") return
-
- const tool = errorTool(taskMsg.parts)
- if (!tool) return
-
- expect(tool.state.error).toContain("Tool execution failed")
- expect(tool.state.metadata).toBeDefined()
- expect(tool.state.metadata?.sessionId).toBeDefined()
- expect(tool.state.metadata?.model).toEqual({
- providerID: ProviderID.make("test"),
- modelID: ModelID.make("missing-model"),
- })
- }),
- {
- git: true,
- config: (url) => ({
- ...providerCfg(url),
- agent: {
- general: {
- model: "test/missing-model",
- },
- },
- }),
- },
- ),
-)
-
-it.live(
- "running subtask preserves metadata after tool-call transition",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
-
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
-
- const tool = yield* Effect.promise(async () => {
- const end = Date.now() + 5_000
- while (Date.now() < end) {
- const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
- if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
- await new Promise((done) => setTimeout(done, 20))
- }
- throw new Error("timed out waiting for running subtask metadata")
- })
-
- if (tool.state.status !== "running") return
- expect(typeof tool.state.metadata?.sessionId).toBe("string")
- expect(tool.state.title).toBeDefined()
- expect(tool.state.metadata?.model).toBeDefined()
-
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- { git: true, config: providerCfg },
- ),
- 5_000,
-)
-
-it.live(
- "running task tool preserves metadata after tool-call transition",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.tool("task", {
- description: "inspect bug",
- prompt: "look into the cache key path",
- subagent_type: "general",
- })
- yield* llm.hang
- yield* user(chat.id, "hello")
-
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
-
- const tool = yield* Effect.promise(async () => {
- const end = Date.now() + 5_000
- while (Date.now() < end) {
- const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id))
- const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build")
- const tool = assistant?.parts.find(
- (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task",
- )
- if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool
- await new Promise((done) => setTimeout(done, 20))
- }
- throw new Error("timed out waiting for running task metadata")
- })
-
- if (tool.state.status !== "running") return
- expect(typeof tool.state.metadata?.sessionId).toBe("string")
- expect(tool.state.title).toBe("inspect bug")
- expect(tool.state.metadata?.model).toBeDefined()
-
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- { git: true, config: providerCfg },
- ),
- 10_000,
-)
-
-it.live(
- "loop sets status to busy then idle",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const status = yield* SessionStatus.Service
-
- yield* llm.hang
-
- const chat = yield* sessions.create({})
- yield* user(chat.id, "hi")
-
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- expect((yield* status.get(chat.id)).type).toBe("busy")
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- expect((yield* status.get(chat.id)).type).toBe("idle")
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-// Cancel semantics
-
-it.live(
- "cancel interrupts loop and resolves with an assistant message",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* seed(chat.id)
-
- yield* llm.hang
-
- yield* user(chat.id, "more")
-
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- }
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-it.live(
- "cancel records MessageAbortedError on interrupted process",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- yield* user(chat.id, "hello")
-
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* prompt.cancel(chat.id)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- const info = exit.value.info
- if (info.role === "assistant") {
- expect(info.error?.name).toBe("MessageAbortedError")
- }
- }
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-it.live(
- "cancel finalizes subtask tool state",
- () =>
- provideTmpdirInstance(
- () =>
- Effect.gen(function* () {
- const ready = defer()
- const aborted = defer()
- const registry = yield* ToolRegistry.Service
- const { task } = yield* registry.named()
- const original = task.execute
- task.execute = (_args, ctx) =>
- Effect.callback((_resume) => {
- ready.resolve()
- ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
- return Effect.sync(() => aborted.resolve())
- })
- yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
-
- const { prompt, chat } = yield* boot()
- const msg = yield* user(chat.id, "hello")
- yield* addSubtask(chat.id, msg.id)
-
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.promise(() => ready.promise)
- yield* prompt.cancel(chat.id)
- yield* Effect.promise(() => aborted.promise)
-
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
-
- const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
- const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
- expect(taskMsg?.info.role).toBe("assistant")
- if (!taskMsg || taskMsg.info.role !== "assistant") return
-
- const tool = toolPart(taskMsg.parts)
- expect(tool?.type).toBe("tool")
- if (!tool) return
-
- expect(tool.state.status).not.toBe("running")
- expect(taskMsg.info.time.completed).toBeDefined()
- expect(taskMsg.info.finish).toBeDefined()
- }),
- { git: true, config: cfg },
- ),
- 30_000,
-)
-
-it.live(
- "cancel with queued callers resolves all cleanly",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- yield* user(chat.id, "hello")
-
- const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- yield* prompt.cancel(chat.id)
- const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
- expect(Exit.isSuccess(exitA)).toBe(true)
- expect(Exit.isSuccess(exitB)).toBe(true)
- if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) {
- expect(exitA.value.info.id).toBe(exitB.value.info.id)
- }
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-// Queue semantics
-
-it.live("concurrent loop callers get same result", () =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- yield* seed(chat.id, { finish: "stop" })
-
- const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
- concurrency: "unbounded",
- })
-
- expect(a.info.id).toBe(b.info.id)
- expect(a.info.role).toBe("assistant")
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true },
- ),
-)
-
-it.live(
- "concurrent loop callers all receive same error result",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
-
- yield* llm.fail("boom")
- yield* user(chat.id, "hello")
-
- const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], {
- concurrency: "unbounded",
- })
- expect(a.info.id).toBe(b.info.id)
- expect(a.info.role).toBe("assistant")
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-it.live(
- "prompt submitted during an active run is included in the next LLM input",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const gate = defer()
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
-
- yield* llm.hold("first", gate.promise)
- yield* llm.text("second")
-
- const a = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "build",
- model: ref,
- parts: [{ type: "text", text: "first" }],
- })
- .pipe(Effect.forkChild)
-
- yield* llm.wait(1)
-
- const id = MessageID.ascending()
- const b = yield* prompt
- .prompt({
- sessionID: chat.id,
- messageID: id,
- agent: "build",
- model: ref,
- parts: [{ type: "text", text: "second" }],
- })
- .pipe(Effect.forkChild)
-
- yield* Effect.promise(async () => {
- const end = Date.now() + 5000
- while (Date.now() < end) {
- const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id }))
- if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return
- await new Promise((done) => setTimeout(done, 20))
- }
- throw new Error("timed out waiting for second prompt to save")
- })
-
- gate.resolve()
-
- const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
- expect(Exit.isSuccess(ea)).toBe(true)
- expect(Exit.isSuccess(eb)).toBe(true)
- expect(yield* llm.calls).toBe(2)
-
- const msgs = yield* sessions.messages({ sessionID: chat.id })
- const assistants = msgs.filter((msg) => msg.info.role === "assistant")
- expect(assistants).toHaveLength(2)
- const last = assistants.at(-1)
- if (!last || last.info.role !== "assistant") throw new Error("expected second assistant")
- expect(last.info.parentID).toBe(id)
- expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
-
- const inputs = yield* llm.inputs
- expect(inputs).toHaveLength(2)
- expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second")
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-it.live(
- "assertNotBusy throws BusyError when loop running",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const run = yield* SessionRunState.Service
- const sessions = yield* Session.Service
- yield* llm.hang
-
- const chat = yield* sessions.create({})
- yield* user(chat.id, "hi")
-
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
-
- const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
- }
-
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-it.live("assertNotBusy succeeds when idle", () =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- const run = yield* SessionRunState.Service
- const sessions = yield* Session.Service
-
- const chat = yield* sessions.create({})
- const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isSuccess(exit)).toBe(true)
- }),
- { git: true },
- ),
-)
-
-// Shell semantics
-
-it.live(
- "shell rejects with BusyError when loop running",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Pinned" })
- yield* llm.hang
- yield* user(chat.id, "hi")
-
- const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
-
- const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
- }
-
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(fiber)
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-unix("shell captures stdout and stderr in completed tool output", () =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "printf out && printf err >&2",
- })
-
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
-
- expect(tool.state.output).toContain("out")
- expect(tool.state.output).toContain("err")
- expect(tool.state.metadata.output).toContain("out")
- expect(tool.state.metadata.output).toContain("err")
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true, config: cfg },
- ),
-)
-
-unix("shell completes a fast command on the preferred shell", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "pwd",
- })
-
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
-
- expect(tool.state.input.command).toBe("pwd")
- expect(tool.state.output).toContain(dir)
- expect(tool.state.metadata.output).toContain(dir)
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true, config: cfg },
- ),
-)
-
-unix(
- "shell uses configured shell over env shell",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- if (!Bun.which("bash")) return
-
- const { prompt, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "[[ 1 -eq 1 ]] && printf configured",
- })
-
- const tool = completedTool(result.parts)
- if (!tool) return
- expect(tool.state.output).toContain("configured")
- }),
- { git: true, config: { ...cfg, shell: "bash" } },
- ),
- ),
- 30_000,
-)
-
-unix("shell lists files from the project directory", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n"))
-
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "command ls",
- })
-
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
-
- expect(tool.state.input.command).toBe("command ls")
- expect(tool.state.output).toContain("README.md")
- expect(tool.state.metadata.output).toContain("README.md")
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true, config: cfg },
- ),
-)
-
-unix("shell captures stderr from a failing command", () =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
- const result = yield* prompt.shell({
- sessionID: chat.id,
- agent: "build",
- command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1",
- })
-
- expect(result.info.role).toBe("assistant")
- const tool = completedTool(result.parts)
- if (!tool) return
-
- expect(tool.state.output).toContain("not found")
- expect(tool.state.metadata.output).toContain("not found")
- yield* run.assertNotBusy(chat.id)
- }),
- { git: true, config: cfg },
- ),
-)
-
-unix(
- "shell updates running metadata before process exit",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
-
- const fiber = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" })
- .pipe(Effect.forkChild)
-
- yield* Effect.promise(async () => {
- const start = Date.now()
- while (Date.now() - start < 5000) {
- const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id))
- const taskMsg = msgs.find((item) => item.info.role === "assistant")
- const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
- if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return
- await new Promise((done) => setTimeout(done, 20))
- }
- throw new Error("timed out waiting for running shell metadata")
- })
-
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isSuccess(exit)).toBe(true)
- }),
- { git: true, config: cfg },
- ),
- ),
- 30_000,
-)
-
-it.live(
- "loop waits while shell runs and starts after shell exits",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.text("after-shell")
-
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- expect(yield* llm.calls).toBe(0)
-
- yield* Fiber.await(sh)
- const exit = yield* Fiber.await(loop)
-
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true)
- }
- expect(yield* llm.calls).toBe(1)
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-it.live(
- "shell completion resumes queued loop callers",
- () =>
- provideTmpdirServer(
- Effect.fnUntraced(function* ({ llm }) {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Pinned",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
- yield* llm.text("done")
-
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- expect(yield* llm.calls).toBe(0)
-
- yield* Fiber.await(sh)
- const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
-
- expect(Exit.isSuccess(ea)).toBe(true)
- expect(Exit.isSuccess(eb)).toBe(true)
- if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) {
- expect(ea.value.info.id).toBe(eb.value.info.id)
- expect(ea.value.info.role).toBe("assistant")
- }
- expect(yield* llm.calls).toBe(1)
- }),
- { git: true, config: providerCfg },
- ),
- 3_000,
-)
-
-unix(
- "cancel interrupts shell and resolves cleanly",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- const { prompt, run, chat } = yield* boot()
-
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- yield* prompt.cancel(chat.id)
-
- const status = yield* SessionStatus.Service
- expect((yield* status.get(chat.id)).type).toBe("idle")
- const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit)
- expect(Exit.isSuccess(busy)).toBe(true)
-
- const exit = yield* Fiber.await(sh)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- const tool = completedTool(exit.value.parts)
- if (tool) {
- expect(tool.state.output).toContain("User aborted the command")
- }
- }
- }),
- { git: true, config: cfg },
- ),
- ),
- 30_000,
-)
-
-unix(
- "command ! expansion uses configured shell over env shell",
- () =>
- withSh(() =>
- provideTmpdirServer(
- ({ llm }) =>
- Effect.gen(function* () {
- if (!Bun.which("bash")) return
-
- const { prompt, chat } = yield* boot()
- yield* llm.text("done")
-
- const result = yield* prompt.command({
- sessionID: chat.id,
- command: "probe",
- arguments: "",
- })
-
- expect(result.info.role).toBe("assistant")
- const inputs = yield* llm.inputs
- expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
- }),
- {
- git: true,
- config: (url) => ({
- ...providerCfg(url),
- shell: "bash",
- command: {
- probe: {
- template: "Probe: !`[[ 1 -eq 1 ]] && printf configured`",
- },
- },
- }),
- },
- ),
- ),
- 30_000,
-)
-
-unix(
- "cancel persists aborted shell result when shell ignores TERM",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
-
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "trap '' TERM; sleep 30" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- yield* prompt.cancel(chat.id)
-
- const exit = yield* Fiber.await(sh)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isSuccess(exit)) {
- expect(exit.value.info.role).toBe("assistant")
- const tool = completedTool(exit.value.parts)
- if (tool) {
- expect(tool.state.output).toContain("User aborted the command")
- }
- }
- }),
- { git: true, config: cfg },
- ),
- ),
- 30_000,
-)
-
-unix(
- "cancel finalizes interrupted bash tool output through normal truncation",
- () =>
- provideTmpdirServer(
- ({ dir, llm }) =>
- Effect.gen(function* () {
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({
- title: "Interrupted bash truncation",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- })
-
- yield* prompt.prompt({
- sessionID: chat.id,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "run bash" }],
- })
-
- yield* llm.tool("bash", {
- command:
- 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30',
- description: "Print many lines",
- timeout: 30_000,
- workdir: path.resolve(dir),
- })
-
- const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* llm.wait(1)
- yield* Effect.sleep(150)
- yield* prompt.cancel(chat.id)
-
- const exit = yield* Fiber.await(run)
- expect(Exit.isSuccess(exit)).toBe(true)
- if (Exit.isFailure(exit)) return
-
- const tool = completedTool(exit.value.parts)
- if (!tool) return
-
- expect(tool.state.metadata.truncated).toBe(true)
- expect(typeof tool.state.metadata.outputPath).toBe("string")
- expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./)
- expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/)
- expect(tool.state.output).not.toContain("Tool execution aborted")
- }),
- { git: true, config: providerCfg },
- ),
- 30_000,
-)
-
-unix(
- "cancel interrupts loop queued behind shell",
- () =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
-
- const sh = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- yield* prompt.cancel(chat.id)
-
- const exit = yield* Fiber.await(loop)
- expect(Exit.isSuccess(exit)).toBe(true)
-
- yield* Fiber.await(sh)
- }),
- { git: true, config: cfg },
- ),
- 30_000,
-)
-
-unix(
- "shell rejects when another shell is already running",
- () =>
- withSh(() =>
- provideTmpdirInstance(
- (_dir) =>
- Effect.gen(function* () {
- const { prompt, chat } = yield* boot()
-
- const a = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" })
- .pipe(Effect.forkChild)
- yield* Effect.sleep(50)
-
- const exit = yield* prompt
- .shell({ sessionID: chat.id, agent: "build", command: "echo hi" })
- .pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError)
- }
-
- yield* prompt.cancel(chat.id)
- yield* Fiber.await(a)
- }),
- { git: true, config: cfg },
- ),
- ),
- 30_000,
-)
-
-// Abort signal propagation tests for inline tool execution
-
-/** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */
-function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
- const ready = defer()
- const aborted = defer()
- const original = tool.execute
- tool.execute = (_args: any, ctx: any) => {
- ready.resolve()
- ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
- return Effect.callback(() => {})
- }
- const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
- return { ready, aborted, restore }
-}
-
-it.live(
- "interrupt propagates abort signal to read tool via file part (text/plain)",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const registry = yield* ToolRegistry.Service
- const { read } = yield* registry.named()
- const { ready, aborted, restore } = hangUntilAborted(read)
- yield* restore
-
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Abort Test" })
-
- const testFile = path.join(dir, "test.txt")
- yield* Effect.promise(() => Bun.write(testFile, "hello world"))
-
- const fiber = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "build",
- parts: [
- { type: "text", text: "read this" },
- { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" },
- ],
- })
- .pipe(Effect.forkChild)
-
- yield* Effect.promise(() => ready.promise)
- yield* Fiber.interrupt(fiber)
-
- yield* Effect.promise(() =>
- Promise.race([
- aborted.promise,
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
- ),
- ]),
- )
- }),
- { git: true, config: cfg },
- ),
- 30_000,
-)
-
-it.live(
- "interrupt propagates abort signal to read tool via file part (directory)",
- () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const registry = yield* ToolRegistry.Service
- const { read } = yield* registry.named()
- const { ready, aborted, restore } = hangUntilAborted(read)
- yield* restore
-
- const prompt = yield* SessionPrompt.Service
- const sessions = yield* Session.Service
- const chat = yield* sessions.create({ title: "Abort Test" })
-
- const fiber = yield* prompt
- .prompt({
- sessionID: chat.id,
- agent: "build",
- parts: [
- { type: "text", text: "read this" },
- { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" },
- ],
- })
- .pipe(Effect.forkChild)
-
- yield* Effect.promise(() => ready.promise)
- yield* Fiber.interrupt(fiber)
-
- yield* Effect.promise(() =>
- Promise.race([
- aborted.promise,
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
- ),
- ]),
- )
- }),
- { git: true, config: cfg },
- ),
- 30_000,
-)
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 2b489da9e9..2af58a0aa9 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -1,22 +1,64 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { FetchHttpClient } from "effect/unstable/http"
+import { expect } from "bun:test"
+import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
-import { describe, expect, test } from "bun:test"
-import { NamedError } from "@opencode-ai/shared/util/error"
import { fileURLToPath } from "url"
-import { Effect, Layer } from "effect"
-import { Instance } from "../../src/project/instance"
+import { NamedError } from "@opencode-ai/shared/util/error"
+import { Agent as AgentSvc } from "../../src/agent/agent"
+import { Bus } from "../../src/bus"
+import { Command } from "../../src/command"
+import { Config } from "../../src/config"
+import { LSP } from "../../src/lsp"
+import { MCP } from "../../src/mcp"
+import { Permission } from "../../src/permission"
+import { Plugin } from "../../src/plugin"
+import { Provider as ProviderSvc } from "../../src/provider"
+import { Env } from "../../src/env"
import { ModelID, ProviderID } from "../../src/provider/schema"
+import { Question } from "../../src/question"
+import { Todo } from "../../src/session/todo"
import { Session } from "../../src/session"
+import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { SessionCompaction } from "../../src/session/compaction"
+import { SessionSummary } from "../../src/session/summary"
+import { Instruction } from "../../src/session/instruction"
+import { SessionProcessor } from "../../src/session/processor"
import { SessionPrompt } from "../../src/session/prompt"
+import { SessionRevert } from "../../src/session/revert"
+import { SessionRunState } from "../../src/session/run-state"
+import { MessageID, PartID, SessionID } from "../../src/session/schema"
+import { SessionStatus } from "../../src/session/status"
+import { Skill } from "../../src/skill"
+import { SystemPrompt } from "../../src/session/system"
+import { Shell } from "../../src/shell/shell"
+import { Snapshot } from "../../src/snapshot"
+import { ToolRegistry } from "../../src/tool"
+import { Truncate } from "../../src/tool"
import { Log } from "../../src/util"
-import { tmpdir } from "../fixture/fixture"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { Ripgrep } from "../../src/file/ripgrep"
+import { Format } from "../../src/format"
+import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+import { reply, TestLLMServer } from "../lib/llm-server"
void Log.init({ print: false })
-function run(fx: Effect.Effect) {
- return Effect.runPromise(
- fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
- )
+const summary = Layer.succeed(
+ SessionSummary.Service,
+ SessionSummary.Service.of({
+ summarize: () => Effect.void,
+ diff: () => Effect.succeed([]),
+ computeDiff: () => Effect.succeed([]),
+ }),
+)
+
+const ref = {
+ providerID: ProviderID.make("test"),
+ modelID: ModelID.make("test-model"),
}
function defer() {
@@ -27,563 +69,1884 @@ function defer() {
return { promise, resolve }
}
-function chat(text: string) {
- const payload =
- [
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { role: "assistant" } }],
- })}`,
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: { content: text } }],
- })}`,
- `data: ${JSON.stringify({
- id: "chatcmpl-1",
- object: "chat.completion.chunk",
- choices: [{ delta: {}, finish_reason: "stop" }],
- })}`,
- "data: [DONE]",
- ].join("\n\n") + "\n\n"
-
- const encoder = new TextEncoder()
- return new ReadableStream({
- start(ctrl) {
- ctrl.enqueue(encoder.encode(payload))
- ctrl.close()
- },
- })
+function withSh(fx: () => Effect.Effect) {
+ return Effect.acquireUseRelease(
+ Effect.sync(() => {
+ const prev = process.env.SHELL
+ process.env.SHELL = "/bin/sh"
+ Shell.preferred.reset()
+ return prev
+ }),
+ () => fx(),
+ (prev) =>
+ Effect.sync(() => {
+ if (prev === undefined) delete process.env.SHELL
+ else process.env.SHELL = prev
+ Shell.preferred.reset()
+ }),
+ )
}
-function hanging(ready: () => void) {
- const encoder = new TextEncoder()
- let timer: ReturnType