Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 2d1ee3a2ac Merge branch 'dev' into tui-favorite-sort-on-query 2026-04-17 20:41:42 -04:00
Kit Langton 054bcdca43 tui: boost favorites and recents when filtering model dialog
When the user types a query in the model selection dialog, favorites
and recents previously lost all positional priority and were ranked
purely by fuzzysort score on title/category. Now matching favorites
float to the top, followed by matching recents, then everything else,
each group preserving fuzzysort order.
2026-04-17 15:51:11 -04:00
112 changed files with 1911 additions and 6287 deletions
+18 -18
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.14",
"version": "1.4.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -322,7 +322,7 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.95",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
@@ -458,7 +458,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -493,7 +493,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -508,7 +508,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.4.14",
"version": "1.4.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -532,7 +532,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -740,7 +740,7 @@
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qJKWEy+cNx3bLSJi/XpIVhv0P8KO0JFB1SvEroNWN8gKm820SIglBmXS10DTeXJdM5PPbQX4i/wJj5BHEk2LRQ=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
+1
View File
@@ -236,6 +236,7 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Spc0qzg4he0d0GwHcqj7uBmvK4DIF1tEbsZ6M+pxpWc=",
"aarch64-linux": "sha256-gwz/PKBbT+72hr7vUG28cdx4Z7/Sf06PNMr9JBjAYg0=",
"aarch64-darwin": "sha256-Lj8p9P/QzLqxiM1OVSwcbtTsms8AcW3A6H0575ERufw=",
"x86_64-darwin": "sha256-y0e+TnXj6wKDqSC5hQAWjpKadaFvL6GJ6Mba5anBM+Y="
"x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
"aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
"aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
"x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.4.14",
"version": "1.4.11",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.4.14",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
-7
View File
@@ -558,13 +558,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي لـ",
"workspace.monthlyLimit.currentUsage.beforeAmount": "هو $",
"workspace.redeem.title": "استرداد قسيمة",
"workspace.redeem.subtitle": "استرد رمز القسيمة للحصول على رصيد أو مزايا.",
"workspace.redeem.placeholder": "أدخل رمز القسيمة",
"workspace.redeem.redeem": "استرداد",
"workspace.redeem.redeeming": "جارٍ الاسترداد...",
"workspace.redeem.success": "تم استرداد القسيمة بنجاح.",
"workspace.reload.title": "إعادة الشحن التلقائي",
"workspace.reload.disabled.before": "إعادة الشحن التلقائي",
"workspace.reload.disabled.state": "معطّل",
-7
View File
@@ -567,13 +567,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso atual para",
"workspace.monthlyLimit.currentUsage.beforeAmount": "é $",
"workspace.redeem.title": "Resgatar Cupom",
"workspace.redeem.subtitle": "Resgate um código de cupom para receber créditos ou vantagens.",
"workspace.redeem.placeholder": "Digite o código do cupom",
"workspace.redeem.redeem": "Resgatar",
"workspace.redeem.redeeming": "Resgatando...",
"workspace.redeem.success": "Cupom resgatado com sucesso.",
"workspace.reload.title": "Recarga Automática",
"workspace.reload.disabled.before": "A recarga automática está",
"workspace.reload.disabled.state": "desativada",
-7
View File
@@ -563,13 +563,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
"workspace.redeem.title": "Indløs kupon",
"workspace.redeem.subtitle": "Indløs en kuponkode for at få kreditter eller fordele.",
"workspace.redeem.placeholder": "Indtast kuponkode",
"workspace.redeem.redeem": "Indløs",
"workspace.redeem.redeeming": "Indløser...",
"workspace.redeem.success": "Kuponen blev indløst.",
"workspace.reload.title": "Automatisk genopfyldning",
"workspace.reload.disabled.before": "Automatisk genopfyldning er",
"workspace.reload.disabled.state": "deaktiveret",
-7
View File
@@ -566,13 +566,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für",
"workspace.monthlyLimit.currentUsage.beforeAmount": "ist $",
"workspace.redeem.title": "Gutschein einlösen",
"workspace.redeem.subtitle": "Löse einen Gutscheincode ein, um Guthaben oder Vorteile zu erhalten.",
"workspace.redeem.placeholder": "Gutscheincode eingeben",
"workspace.redeem.redeem": "Einlösen",
"workspace.redeem.redeeming": "Wird eingelöst...",
"workspace.redeem.success": "Gutschein erfolgreich eingelöst.",
"workspace.reload.title": "Auto-Reload",
"workspace.reload.disabled.before": "Auto-Reload ist",
"workspace.reload.disabled.state": "deaktiviert",
-7
View File
@@ -559,13 +559,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Current usage for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "is $",
"workspace.redeem.title": "Redeem Coupon",
"workspace.redeem.subtitle": "Redeem a coupon code to claim credits or perks.",
"workspace.redeem.placeholder": "Enter coupon code",
"workspace.redeem.redeem": "Redeem",
"workspace.redeem.redeeming": "Redeeming...",
"workspace.redeem.success": "Coupon redeemed successfully.",
"workspace.reload.title": "Auto Reload",
"workspace.reload.disabled.before": "Auto reload is",
"workspace.reload.disabled.state": "disabled",
-7
View File
@@ -567,13 +567,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso actual para",
"workspace.monthlyLimit.currentUsage.beforeAmount": "es $",
"workspace.redeem.title": "Canjear cupón",
"workspace.redeem.subtitle": "Canjea un código de cupón para obtener crédito o beneficios.",
"workspace.redeem.placeholder": "Introduce el código del cupón",
"workspace.redeem.redeem": "Canjear",
"workspace.redeem.redeeming": "Canjeando...",
"workspace.redeem.success": "Cupón canjeado correctamente.",
"workspace.reload.title": "Auto Recarga",
"workspace.reload.disabled.before": "La auto recarga está",
"workspace.reload.disabled.state": "deshabilitada",
-7
View File
@@ -569,13 +569,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "L'utilisation actuelle pour",
"workspace.monthlyLimit.currentUsage.beforeAmount": "est de",
"workspace.redeem.title": "Utiliser un coupon",
"workspace.redeem.subtitle": "Utilisez un code promo pour obtenir du crédit ou des avantages.",
"workspace.redeem.placeholder": "Saisissez le code promo",
"workspace.redeem.redeem": "Utiliser",
"workspace.redeem.redeeming": "Utilisation...",
"workspace.redeem.success": "Coupon utilisé avec succès.",
"workspace.reload.title": "Rechargement automatique",
"workspace.reload.disabled.before": "Le rechargement automatique est",
"workspace.reload.disabled.state": "désactivé",
-7
View File
@@ -565,13 +565,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per",
"workspace.monthlyLimit.currentUsage.beforeAmount": "è $",
"workspace.redeem.title": "Riscatta Coupon",
"workspace.redeem.subtitle": "Riscatta un codice coupon per ottenere credito o vantaggi.",
"workspace.redeem.placeholder": "Inserisci il codice coupon",
"workspace.redeem.redeem": "Riscatta",
"workspace.redeem.redeeming": "Riscatto in corso...",
"workspace.redeem.success": "Coupon riscattato con successo.",
"workspace.reload.title": "Ricarica Auto",
"workspace.reload.disabled.before": "La ricarica auto è",
"workspace.reload.disabled.state": "disabilitata",
-7
View File
@@ -564,13 +564,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "現在の使用状況(",
"workspace.monthlyLimit.currentUsage.beforeAmount": ")は $",
"workspace.redeem.title": "クーポンを利用",
"workspace.redeem.subtitle": "クーポンコードを利用して、クレジットや特典を受け取ります。",
"workspace.redeem.placeholder": "クーポンコードを入力",
"workspace.redeem.redeem": "利用する",
"workspace.redeem.redeeming": "利用中...",
"workspace.redeem.success": "クーポンを利用しました。",
"workspace.reload.title": "自動チャージ",
"workspace.reload.disabled.before": "自動チャージは",
"workspace.reload.disabled.state": "無効",
-7
View File
@@ -558,13 +558,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "현재",
"workspace.monthlyLimit.currentUsage.beforeAmount": "사용량: $",
"workspace.redeem.title": "쿠폰 사용",
"workspace.redeem.subtitle": "쿠폰 코드를 사용해 크레딧이나 혜택을 받으세요.",
"workspace.redeem.placeholder": "쿠폰 코드를 입력하세요",
"workspace.redeem.redeem": "사용",
"workspace.redeem.redeeming": "사용 중...",
"workspace.redeem.success": "쿠폰을 성공적으로 사용했습니다.",
"workspace.reload.title": "자동 충전",
"workspace.reload.disabled.before": "자동 충전이",
"workspace.reload.disabled.state": "비활성화",
-7
View File
@@ -564,13 +564,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende forbruk for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
"workspace.redeem.title": "Løs inn kupong",
"workspace.redeem.subtitle": "Løs inn en kupongkode for å få kreditt eller fordeler.",
"workspace.redeem.placeholder": "Skriv inn kupongkode",
"workspace.redeem.redeem": "Løs inn",
"workspace.redeem.redeeming": "Løser inn...",
"workspace.redeem.success": "Kupongen ble løst inn.",
"workspace.reload.title": "Auto-påfyll",
"workspace.reload.disabled.before": "Auto-påfyll er",
"workspace.reload.disabled.state": "deaktivert",
-7
View File
@@ -565,13 +565,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktualne użycie za",
"workspace.monthlyLimit.currentUsage.beforeAmount": "wynosi $",
"workspace.redeem.title": "Zrealizuj kupon",
"workspace.redeem.subtitle": "Zrealizuj kod kuponu, aby otrzymać środki lub korzyści.",
"workspace.redeem.placeholder": "Wpisz kod kuponu",
"workspace.redeem.redeem": "Zrealizuj",
"workspace.redeem.redeeming": "Realizowanie...",
"workspace.redeem.success": "Kupon został zrealizowany.",
"workspace.reload.title": "Automatyczne doładowanie",
"workspace.reload.disabled.before": "Automatyczne doładowanie jest",
"workspace.reload.disabled.state": "wyłączone",
-7
View File
@@ -571,13 +571,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Текущее использование за",
"workspace.monthlyLimit.currentUsage.beforeAmount": "составляет $",
"workspace.redeem.title": "Активировать купон",
"workspace.redeem.subtitle": "Активируйте код купона, чтобы получить кредит или бонусы.",
"workspace.redeem.placeholder": "Введите код купона",
"workspace.redeem.redeem": "Активировать",
"workspace.redeem.redeeming": "Активация...",
"workspace.redeem.success": "Купон успешно активирован.",
"workspace.reload.title": "Автопополнение",
"workspace.reload.disabled.before": "Автопополнение",
"workspace.reload.disabled.state": "отключено",
-7
View File
@@ -560,13 +560,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "การใช้งานปัจจุบันสำหรับ",
"workspace.monthlyLimit.currentUsage.beforeAmount": "คือ $",
"workspace.redeem.title": "แลกคูปอง",
"workspace.redeem.subtitle": "แลกรหัสคูปองเพื่อรับเครดิตหรือสิทธิพิเศษ",
"workspace.redeem.placeholder": "กรอกรหัสคูปอง",
"workspace.redeem.redeem": "แลก",
"workspace.redeem.redeeming": "กำลังแลก...",
"workspace.redeem.success": "แลกคูปองสำเร็จ",
"workspace.reload.title": "โหลดซ้ำอัตโนมัติ",
"workspace.reload.disabled.before": "การโหลดซ้ำอัตโนมัติ",
"workspace.reload.disabled.state": "ปิดใช้งานอยู่",
-7
View File
@@ -567,13 +567,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Şu anki kullanım",
"workspace.monthlyLimit.currentUsage.beforeAmount": "$",
"workspace.redeem.title": "Kupon Kullan",
"workspace.redeem.subtitle": "Kredi veya avantajlardan yararlanmak için bir kupon kodu kullanın.",
"workspace.redeem.placeholder": "Kupon kodunu girin",
"workspace.redeem.redeem": "Kullan",
"workspace.redeem.redeeming": "Kullanılıyor...",
"workspace.redeem.success": "Kupon başarıyla kullanıldı.",
"workspace.reload.title": "Otomatik Yeniden Yükleme",
"workspace.reload.disabled.before": "Otomatik yeniden yükleme:",
"workspace.reload.disabled.state": "devre dışı",
-7
View File
@@ -542,13 +542,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前",
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $",
"workspace.redeem.title": "兑换优惠券",
"workspace.redeem.subtitle": "兑换优惠码以领取充值额度或权益。",
"workspace.redeem.placeholder": "输入优惠码",
"workspace.redeem.redeem": "兑换",
"workspace.redeem.redeeming": "兑换中...",
"workspace.redeem.success": "优惠券兑换成功。",
"workspace.reload.title": "自动充值",
"workspace.reload.disabled.before": "自动充值已",
"workspace.reload.disabled.state": "禁用",
-7
View File
@@ -542,13 +542,6 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "目前",
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $",
"workspace.redeem.title": "兌換優惠券",
"workspace.redeem.subtitle": "兌換優惠碼以領取儲值額度或權益。",
"workspace.redeem.placeholder": "輸入優惠碼",
"workspace.redeem.redeem": "兌換",
"workspace.redeem.redeeming": "兌換中...",
"workspace.redeem.success": "優惠券兌換成功。",
"workspace.reload.title": "自動儲值",
"workspace.reload.disabled.before": "自動儲值已",
"workspace.reload.disabled.state": "停用",
@@ -9,7 +9,6 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { User } from "@opencode-ai/console-core/user.js"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -110,8 +109,6 @@ export async function POST(input: APIEvent) {
if (type === "lite") {
const workspaceID = body.data.object.metadata?.workspaceID
const userID = body.data.object.metadata?.userID
const userEmail = body.data.object.metadata?.userEmail
const coupon = body.data.object.metadata?.coupon
const customerID = body.data.object.customer as string
const invoiceID = body.data.object.latest_invoice as string
const subscriptionID = body.data.object.id as string
@@ -159,10 +156,6 @@ export async function POST(input: APIEvent) {
id: Identifier.create("lite"),
userID: userID,
})
if (userEmail && coupon === LiteData.firstMonth100Coupon) {
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
}
})
})
}
@@ -3,7 +3,6 @@ import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { RedeemSection } from "./redeem-section"
import { createMemo, Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
@@ -25,7 +24,6 @@ export default function () {
<Show when={billingInfo()?.customerID}>
<ReloadSection />
<MonthlyLimitSection />
<RedeemSection />
<PaymentSection />
</Show>
</Show>
@@ -1,61 +0,0 @@
.root {
[data-slot="redeem-container"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 20rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
}
}
[data-slot="redeem-form"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
[data-slot="input-row"] {
display: flex;
gap: var(--space-2);
align-items: stretch;
@media (max-width: 30rem) {
flex-direction: column;
}
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
[data-slot="form-success"] {
color: var(--color-success, var(--color-accent));
font-size: var(--font-size-sm);
line-height: 1.4;
}
}
}
@@ -1,71 +0,0 @@
import { json, action, useParams, useSubmission } from "@solidjs/router"
import { Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { CouponType } from "@opencode-ai/console-core/schema/billing.sql.js"
import styles from "./redeem-section.module.css"
import { queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const redeem = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const code = (form.get("code") as string | null)?.trim().toUpperCase()
if (!code) return { error: "Coupon code is required." }
if (!(CouponType as readonly string[]).includes(code)) return { error: "Invalid coupon code." }
return json(
await withActor(async () => {
const actor = Actor.assert("user")
const email = await User.getAuthEmail(actor.properties.userID)
if (!email) return { error: "No email on account." }
return Billing.redeemCoupon(email, code as (typeof CouponType)[number])
.then(() => ({ error: undefined, data: true }))
.catch((e) => ({ error: e.message as string }))
}, workspaceID),
{ revalidate: queryBillingInfo.key },
)
}, "billing.redeemCoupon")
export function RedeemSection() {
const params = useParams()
const i18n = useI18n()
const submission = useSubmission(redeem)
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.redeem.title")}</h2>
<p>{i18n.t("workspace.redeem.subtitle")}</p>
</div>
<div data-slot="redeem-container">
<form action={redeem} method="post" data-slot="redeem-form">
<div data-slot="input-row">
<input
required
data-component="input"
name="code"
type="text"
autocomplete="off"
placeholder={i18n.t("workspace.redeem.placeholder")}
/>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? i18n.t("workspace.redeem.redeeming") : i18n.t("workspace.redeem.redeem")}
</button>
</div>
<Show when={submission.result && (submission.result as any).error}>
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<Show when={submission.result && !(submission.result as any).error && (submission.result as any).data}>
<div data-slot="form-success">{i18n.t("workspace.redeem.success")}</div>
</Show>
<input type="hidden" name="workspaceID" value={params.id} />
</form>
</div>
</section>
)
}
@@ -1,6 +0,0 @@
CREATE TABLE `coupon` (
`email` varchar(255),
`type` enum('BUILDATHON','GOFREEMONTH') NOT NULL,
`time_redeemed` timestamp(3),
CONSTRAINT PRIMARY KEY(`email`,`type`)
);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.4.14",
"version": "1.4.11",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,24 +0,0 @@
import { Database } from "../src/drizzle/index.js"
import { CouponTable, CouponType } from "../src/schema/billing.sql.js"
const email = process.argv[2]
const type = process.argv[3] as (typeof CouponType)[number]
if (!email || !type) {
console.error(`Usage: bun create-coupon.ts <email> <${CouponType.join("|")}>`)
process.exit(1)
}
if (!(CouponType as readonly string[]).includes(type)) {
console.error(`Error: type must be one of ${CouponType.join(", ")}`)
process.exit(1)
}
await Database.use((tx) =>
tx.insert(CouponTable).values({
email,
type,
}),
)
console.log(`Created ${type} coupon for ${email}`)
+5 -49
View File
@@ -1,14 +1,6 @@
import { Stripe } from "stripe"
import { and, Database, eq, isNull, sql } from "./drizzle"
import {
BillingTable,
CouponTable,
CouponType,
LiteTable,
PaymentTable,
SubscriptionTable,
UsageTable,
} from "./schema/billing.sql"
import { Database, eq, sql } from "./drizzle"
import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
@@ -155,37 +147,6 @@ export namespace Billing {
return amountInMicroCents
}
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
const coupon = await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
.then((rows) => rows[0]),
)
if (!coupon) throw new Error("Invalid coupon code")
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
await Database.use((tx) =>
tx
.update(CouponTable)
.set({ timeRedeemed: sql`now()` })
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
)
}
export const hasCoupon = async (email: string, type: (typeof CouponType)[number]) => {
return await Database.use((tx) =>
tx
.select()
.from(CouponTable)
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type), isNull(CouponTable.timeRedeemed)))
.then((rows) => rows.length > 0),
)
}
export const setMonthlyLimit = fn(z.number(), async (input) => {
return await Database.use((tx) =>
tx
@@ -284,19 +245,16 @@ export namespace Billing {
const user = Actor.assert("user")
const { successUrl, cancelUrl, method } = input
const email = (await User.getAuthEmail(user.properties.userID))!
const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get()
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const coupon = (await Billing.hasCoupon(email, "GOFREEMONTH"))
? LiteData.firstMonth100Coupon
: LiteData.firstMonth50Coupon
const createSession = () =>
Billing.stripe().checkout.sessions.create({
mode: "subscription",
discounts: [{ coupon }],
discounts: [{ coupon: LiteData.firstMonthCoupon(email!) }],
...(billing.customerID
? {
customer: billing.customerID,
@@ -306,7 +264,7 @@ export namespace Billing {
},
}
: {
customer_email: email,
customer_email: email!,
}),
...(() => {
if (method === "alipay") {
@@ -354,8 +312,6 @@ export namespace Billing {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
userEmail: email,
coupon,
type: "lite",
},
},
+6 -2
View File
@@ -11,7 +11,11 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonth100Coupon = Resource.ZEN_LITE_PRICE.firstMonth100Coupon
export const firstMonth50Coupon = Resource.ZEN_LITE_PRICE.firstMonth50Coupon
export const firstMonthCoupon = fn(z.string(), (email) => {
const invitees = Resource.ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES.value.split(",")
return invitees.includes(email)
? Resource.ZEN_LITE_PRICE.firstMonth100Coupon
: Resource.ZEN_LITE_PRICE.firstMonth50Coupon
})
export const planName = fn(z.void(), () => "lite")
}
@@ -1,15 +1,4 @@
import {
bigint,
boolean,
index,
int,
json,
mysqlEnum,
mysqlTable,
primaryKey,
uniqueIndex,
varchar,
} from "drizzle-orm/mysql-core"
import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
@@ -132,14 +121,3 @@ export const UsageTable = mysqlTable(
},
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
)
export const CouponType = ["BUILDATHON", "GOFREEMONTH"] as const
export const CouponTable = mysqlTable(
"coupon",
{
email: varchar("email", { length: 255 }),
type: mysqlEnum("type", CouponType).notNull(),
timeRedeemed: utc("time_redeemed"),
},
(table) => [primaryKey({ columns: [table.email, table.type] })],
)
+4
View File
@@ -142,6 +142,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.4.14",
"version": "1.4.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+4
View File
@@ -142,6 +142,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.4.14",
"version": "1.4.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+4
View File
@@ -142,6 +142,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.14",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.4.14",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.4.14",
"version": "1.4.11",
"private": true,
"type": "module",
"license": "MIT",
+4
View File
@@ -142,6 +142,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.4.14"
version = "1.4.11"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.4.14",
"version": "1.4.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+4
View File
@@ -142,6 +142,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.14",
"version": "1.4.11",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -79,7 +79,7 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.95",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
+8 -21
View File
@@ -7,20 +7,6 @@ import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
async function published(name: string, version: string) {
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
}
async function publish(dir: string, name: string, version: string) {
if (await published(name, version)) {
console.log(`already published ${name}@${version}`)
return
}
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
}
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
const pkg = await Bun.file(`./dist/${filepath}`).json()
@@ -54,10 +40,14 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
)
const tasks = Object.entries(binaries).map(async ([name]) => {
await publish(`./dist/${name}`, name, binaries[name])
if (process.platform !== "win32") {
await $`chmod -R 755 .`.cwd(`./dist/${name}`)
}
await $`bun pm pack`.cwd(`./dist/${name}`)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(`./dist/${name}`)
})
await Promise.all(tasks)
await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version)
await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${Script.channel}`
const image = "ghcr.io/anomalyco/opencode"
const platforms = "linux/amd64,linux/arm64"
@@ -114,7 +104,6 @@ if (!Script.preview) {
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/aur-${pkg} && git push`
break
@@ -187,8 +176,6 @@ if (!Script.preview) {
await $`git clone ${tap} ./dist/homebrew-tap`
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
await $`cd ./dist/homebrew-tap && git add opencode.rb`
if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) {
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
}
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
}
+26 -252
View File
@@ -1,19 +1,12 @@
# Schema migration
Practical reference for migrating data types in `packages/opencode` from
Zod-first definitions to Effect Schema with Zod compatibility shims.
Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
## Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs,
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
compatibility boundaries by exposing a `.zod` static derived from the Effect
schema via `@/util/effect-zod`.
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
moves to `@effect/platform`, every Schema-first DTO can flow through
`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
`effect-zod` walker plus every `.zod` static can be deleted.
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
## Preferred shapes
@@ -31,14 +24,17 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
}
```
If the class cannot reference itself cleanly during initialization, use the
two-step `withStatics` pattern:
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
```ts
export const Info = Schema.Struct({
const _Info = Schema.Struct({
id: FooID,
name: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
})
export const Info = Object.assign(_Info, {
zod: zod(_Info),
})
```
### Errors
@@ -53,89 +49,27 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
### IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose
`static readonly zod` for compatibility when callers still expect Zod.
### Refinements
Reuse named refinements instead of re-spelling `z.number().int().positive()`
in every schema. The `effect-zod` walker translates the Effect versions into
the corresponding zod methods, so JSON Schema output (`type: integer`,
`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
```ts
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
```
See `test/util/effect-zod.test.ts` for the full set of translated checks.
Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
## Compatibility rule
During migration, route validators, tool parameters, and any existing
Zod-based boundary should consume the derived `.zod` schema instead of
maintaining a second hand-written Zod schema.
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- new domain models should not start Zod-first unless there is a concrete
boundary-specific need
- new domain models should not start Zod-first unless there is a concrete boundary-specific need
## When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary and is not reused elsewhere
- the type is only used at an HTTP or tool boundary
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently
creating a parallel schema source of truth.
## Escape hatches
The walker in `@/util/effect-zod` exposes three 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.
### `ZodOverride` annotation
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.
### Local `DeepMutable<T>` in `config/config.ts`
`Schema.Struct` produces `readonly` types. Some consumer code (notably the
`Config` service) mutates `Info` objects directly, so a readonly-stripping
utility is needed when casting the derived zod schema's output type.
`Types.DeepMutable` from effect-smol would be a drop-in, but it widens
`unknown` to `{}` in the fallback branch — a bug that affects any schema
using `Schema.Record(String, Schema.Unknown)`.
Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown
to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and
`Types.DeepMutable` used directly.
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
## Ordering
@@ -147,179 +81,19 @@ Migrate in this order:
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
This keeps shared types canonical first and makes boundary updates mostly
mechanical.
This keeps shared types canonical first and makes boundary updates mostly mechanical.
## Progress tracker
## Checklist
### `src/config/` ✅ complete
All of `packages/opencode/src/config/` has been migrated. Files that still
import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
type annotations — the `export const <Info|Spec>` values are all Effect
Schema at source.
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
- [x] server, layout
- [x] keybinds
- [x] permission#Info
- [x] agent
- [x] config.ts root
### `src/*/schema.ts` leaf modules
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`
### Session domain
Major cluster. Message + event types flow through the SSE API and every SDK
output, so byte-identical SDK surface is critical.
- [ ] `src/session/compaction.ts`
- [ ] `src/session/message-v2.ts`
- [ ] `src/session/message.ts`
- [ ] `src/session/prompt.ts`
- [ ] `src/session/revert.ts`
- [ ] `src/session/session.ts`
- [ ] `src/session/status.ts`
- [ ] `src/session/summary.ts`
- [ ] `src/session/todo.ts`
### Provider domain
- [ ] `src/provider/auth.ts`
- [ ] `src/provider/models.ts`
- [ ] `src/provider/provider.ts`
### Tool schemas
Each tool declares its parameters via a zod schema. Tools are consumed by
both the in-process runtime and the AI SDK's tool-calling layer, so the
emitted JSON Schema must stay byte-identical.
- [ ] `src/tool/apply_patch.ts`
- [ ] `src/tool/bash.ts`
- [ ] `src/tool/codesearch.ts`
- [ ] `src/tool/edit.ts`
- [ ] `src/tool/glob.ts`
- [ ] `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`
- [ ] `src/tool/registry.ts`
- [ ] `src/tool/skill.ts`
- [ ] `src/tool/task.ts`
- [ ] `src/tool/todo.ts`
- [ ] `src/tool/tool.ts`
- [ ] `src/tool/webfetch.ts`
- [ ] `src/tool/websearch.ts`
- [ ] `src/tool/write.ts`
### HTTP route boundaries
Every file in `src/server/routes/` uses hono-openapi with zod validators for
route inputs/outputs. Migrating these individually is the last step; most
will switch to `.zod` derived from the Schema-migrated domain types above,
which means touching them is largely mechanical once the domain side is
done.
- [ ] `src/server/error.ts`
- [ ] `src/server/event.ts`
- [ ] `src/server/projectors.ts`
- [ ] `src/server/routes/control/index.ts`
- [ ] `src/server/routes/control/workspace.ts`
- [ ] `src/server/routes/global.ts`
- [ ] `src/server/routes/instance/index.ts`
- [ ] `src/server/routes/instance/config.ts`
- [ ] `src/server/routes/instance/event.ts`
- [ ] `src/server/routes/instance/experimental.ts`
- [ ] `src/server/routes/instance/file.ts`
- [ ] `src/server/routes/instance/mcp.ts`
- [ ] `src/server/routes/instance/permission.ts`
- [ ] `src/server/routes/instance/project.ts`
- [ ] `src/server/routes/instance/provider.ts`
- [ ] `src/server/routes/instance/pty.ts`
- [ ] `src/server/routes/instance/question.ts`
- [ ] `src/server/routes/instance/session.ts`
- [ ] `src/server/routes/instance/sync.ts`
- [ ] `src/server/routes/instance/tui.ts`
The bigger prize for this group is the `@effect/platform` HTTP migration
described in `specs/effect/http-api.md`. Once that lands, every one of
these files changes shape entirely (`HttpApi.endpoint(...)` and friends),
so the Schema-first domain types become a prerequisite rather than a
sibling task.
### Everything else
Small / shared / control-plane / CLI. Mostly independent; can be done
piecewise.
- [ ] `src/acp/agent.ts`
- [ ] `src/agent/agent.ts`
- [ ] `src/bus/bus-event.ts`
- [ ] `src/bus/index.ts`
- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
- [ ] `src/cli/cmd/tui/config/tui.ts`
- [ ] `src/cli/cmd/tui/event.ts`
- [ ] `src/cli/ui.ts`
- [ ] `src/command/index.ts`
- [ ] `src/control-plane/adaptors/worktree.ts`
- [ ] `src/control-plane/types.ts`
- [ ] `src/control-plane/workspace.ts`
- [ ] `src/file/index.ts`
- [ ] `src/file/ripgrep.ts`
- [ ] `src/file/watcher.ts`
- [ ] `src/format/index.ts`
- [ ] `src/id/id.ts`
- [ ] `src/ide/index.ts`
- [ ] `src/installation/index.ts`
- [ ] `src/lsp/client.ts`
- [ ] `src/lsp/lsp.ts`
- [ ] `src/mcp/auth.ts`
- [ ] `src/patch/index.ts`
- [ ] `src/plugin/github-copilot/models.ts`
- [ ] `src/project/project.ts`
- [ ] `src/project/vcs.ts`
- [ ] `src/pty/index.ts`
- [ ] `src/skill/index.ts`
- [ ] `src/snapshot/index.ts`
- [ ] `src/storage/db.ts`
- [ ] `src/storage/storage.ts`
- [ ] `src/sync/index.ts`
- [ ] `src/util/fn.ts`
- [ ] `src/util/log.ts`
- [ ] `src/util/update-schema.ts`
- [ ] `src/worktree/index.ts`
### Do-not-migrate
- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
(it's what emits zod from Schema). Goes away only when the `.zod`
compatibility layer is no longer needed anywhere.
- [ ] Shared `schema.ts` leaf models are Effect Schema-first
- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
- [ ] Domain errors use `Schema.TaggedErrorClass`
- [ ] Migrated types expose `.zod` for back compatibility
- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
- [ ] New domain models default to Effect Schema first
## Notes
- Use `@/util/effect-zod` for all Schema Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more
valuable than converting every boundary in the same change.
- Every migrated file should leave the generated SDK output (`packages/sdk/
openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical
unless the change is deliberately user-visible.
- Use `@/util/effect-zod` for all Schema -> Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
+1 -13
View File
@@ -25,19 +25,7 @@ export const GenerateCommand = {
]
}
}
const raw = JSON.stringify(specs, null, 2)
// Format through prettier so output is byte-identical to committed file
// regardless of whether ./script/format.ts runs afterward.
const prettier = await import("prettier")
const babel = await import("prettier/plugins/babel")
const estree = await import("prettier/plugins/estree")
const format = prettier.format ?? prettier.default?.format
const json = await format(raw, {
parser: "json",
plugins: [babel.default ?? babel, estree.default ?? estree],
printWidth: 120,
})
const json = JSON.stringify(specs, null, 2)
// Wait for stdout to finish writing before process.exit() is called
await new Promise<void>((resolve, reject) => {
@@ -119,8 +119,18 @@ export function DialogModel(props: { providerID?: string }) {
: []
if (needle) {
const isFavorite = (v: { providerID: string; modelID: string }) =>
favorites.some((f) => f.providerID === v.providerID && f.modelID === v.modelID)
const isRecent = (v: { providerID: string; modelID: string }) =>
recents.some((r) => r.providerID === v.providerID && r.modelID === v.modelID)
const matches = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const fav = matches.filter((x) => isFavorite(x.value))
const rec = matches.filter((x) => !isFavorite(x.value) && isRecent(x.value))
const rest = matches.filter((x) => !isFavorite(x.value) && !isRecent(x.value))
return [
...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
...fav,
...rec,
...rest,
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
}
@@ -467,6 +467,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.status
},
get ready() {
return true
if (process.env.OPENCODE_FAST_BOOT) return true
return store.status !== "loading"
},
@@ -5,11 +5,11 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useDialog, type DialogContext } from "../../ui/dialog"
import { useDialog } from "../../ui/dialog"
import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
@@ -19,21 +19,9 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
dialog.setSize("large")
})
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
const options = createMemo((): DialogSelectOption<string>[] => {
const messages = sync.data.message[props.sessionID] ?? []
const fullSession = {
title: "Full session",
value: undefined,
onSelect: async (dialog: DialogContext) => {
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
route.navigate({
sessionID: forked.data!.id,
type: "session",
})
dialog.clear()
},
} satisfies DialogSelectOption<string | undefined>
const result = [] as DialogSelectOption<string | undefined>[]
const result = [] as DialogSelectOption<string>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find(
@@ -69,8 +57,9 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
},
})
}
return [fullSession, ...result.reverse()]
result.reverse()
return result
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
}
@@ -451,7 +451,7 @@ export function Session() {
},
},
{
title: "Fork session",
title: "Fork from message",
value: "session.fork",
keybind: "session_fork",
category: "Session",
@@ -462,7 +462,6 @@ export function Session() {
dialog.replace(() => (
<DialogForkFromTimeline
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => {
return child.id === messageID
})
+81 -96
View File
@@ -1,12 +1,10 @@
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 { Log } from "../util"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
@@ -15,102 +13,89 @@ import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
export const Info = z
.object({
model: ConfigModelID.zod.optional(),
variant: z
.string()
.optional()
.describe("Default model variant for this agent (applies only when using the agent's configured model)."),
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
hidden: z
.boolean()
.optional()
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
options: z.record(z.string(), z.any()).optional(),
color: z
.union([
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
.optional()
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
steps: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of agentic iterations before forcing text-only response"),
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
permission: ConfigPermission.Info.optional(),
})
.catchall(z.any())
.transform((agent, _ctx) => {
const knownKeys = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
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),
variant: Schema.optional(Schema.String).annotate({
description: "Default model variant for this agent (applies only when using the agent's configured model).",
}),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
prompt: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead",
}),
disable: Schema.optional(Schema.Boolean),
description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }),
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
hidden: Schema.optional(Schema.Boolean).annotate({
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
}),
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
color: Schema.optional(Color).annotate({
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
}),
steps: Schema.optional(PositiveInt).annotate({
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),
}),
[Schema.Record(Schema.String, Schema.Any)],
)
const KNOWN_KEYS = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
// Post-parse normalisation:
// - Promote any unknown-but-present keys into `options` so they survive the
// round-trip in a well-known field.
// - Translate the deprecated `tools: { name: boolean }` map into the new
// `permission` shape (write-adjacent tools collapse into `permission.edit`).
// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.
const normalize = (agent: z.infer<typeof Info>) => {
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!KNOWN_KEYS.has(key)) options[key] = value
}
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") {
permission.edit = action
continue
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
}
permission[tool] = action
}
globalThis.Object.assign(permission, agent.permission)
return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
}
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") {
permission.edit = action
continue
}
permission[tool] = action
}
Object.assign(permission, agent.permission)
export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
Omit<z.infer<ReturnType<typeof zod<typeof AgentSchema>>>, "options" | "permission" | "steps"> & {
options?: Record<string, unknown>
permission?: ConfigPermission.Info
steps?: number
}
>
const steps = agent.steps ?? agent.maxSteps
return { ...agent, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: ConfigPermission.Info
steps?: number
}
})
.meta({
ref: "AgentConfig",
})
export type Info = z.infer<typeof Info>
export async function load(dir: string) {
+171 -190
View File
@@ -21,25 +21,22 @@ import { isRecord } from "@/util/record"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { zod, ZodOverride } from "@/util/effect-zod"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
import { ConfigLayout } from "./layout"
import { ConfigLSP } from "./lsp"
import { ConfigManaged } from "./managed"
import { ConfigMCP } from "./mcp"
import { ConfigModelID } from "./model-id"
import { ConfigParse } from "./parse"
import { ConfigPaths } from "./paths"
import { ConfigPermission } from "./permission"
import { ConfigPlugin } from "./plugin"
import { ConfigManaged } from "./managed"
import { ConfigCommand } from "./command"
import { ConfigParse } from "./parse"
import { ConfigPermission } from "./permission"
import { ConfigProvider } from "./provider"
import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
import { ConfigPaths } from "./paths"
import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp"
import { ConfigVariable } from "./variable"
import { Npm } from "@/npm"
@@ -76,186 +73,170 @@ async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(
return config
}
export const Server = ConfigServer.Server.zod
export const Layout = ConfigLayout.Layout.zod
export type Layout = ConfigLayout.Layout
// Schemas that still live at the zod layer (have .transform / .preprocess /
// .meta not expressible in current Effect Schema) get referenced via a
// 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({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
server: Schema.optional(ConfigServer.Server).annotate({
description: "Server configuration for opencode serve and web commands",
}),
command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({
description: "Command configuration, see https://opencode.ai/docs/commands",
}),
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
watcher: Schema.optional(
Schema.Struct({
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
}),
),
snapshot: Schema.optional(Schema.Boolean).annotate({
description:
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
}),
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))),
share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({
description:
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
}),
autoshare: Schema.optional(Schema.Boolean).annotate({
description: "@deprecated Use 'share' field instead. Share newly created sessions automatically",
}),
autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({
description:
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
}),
disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Disable providers that are loaded automatically",
}),
enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "When set, ONLY these providers will be enabled. All other providers will be ignored",
}),
model: Schema.optional(ConfigModelID).annotate({
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
}),
small_model: Schema.optional(ConfigModelID).annotate({
description: "Small model to use for tasks like title generation in the format of provider/model",
}),
default_agent: Schema.optional(Schema.String).annotate({
description:
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
}),
username: Schema.optional(Schema.String).annotate({
description: "Custom username to display in conversations instead of system username",
}),
mode: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
build: Schema.optional(AgentRef),
plan: Schema.optional(AgentRef),
}),
[Schema.Record(Schema.String, AgentRef)],
),
).annotate({ description: "@deprecated Use `agent` field instead." }),
agent: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
// primary
plan: Schema.optional(AgentRef),
build: Schema.optional(AgentRef),
// subagent
general: Schema.optional(AgentRef),
explore: Schema.optional(AgentRef),
// specialized
title: Schema.optional(AgentRef),
summary: Schema.optional(AgentRef),
compaction: Schema.optional(AgentRef),
}),
[Schema.Record(Schema.String, AgentRef)],
),
).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
description: "Custom provider configurations and model overrides",
}),
mcp: Schema.optional(
Schema.Record(
Schema.String,
Schema.Union([
ConfigMCP.Info,
// Matches the legacy `{ enabled: false }` form used to disable a server.
Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }),
]),
),
).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
formatter: Schema.optional(ConfigFormatter.Info),
lsp: Schema.optional(ConfigLSP.Info),
instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Additional instruction files or patterns to include",
}),
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(PermissionRef),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
enterprise: Schema.optional(
Schema.Struct({
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
}),
),
compaction: Schema.optional(
Schema.Struct({
auto: Schema.optional(Schema.Boolean).annotate({
description: "Enable automatic compaction when context is full (default: true)",
}),
prune: Schema.optional(Schema.Boolean).annotate({
description: "Enable pruning of old tool outputs (default: true)",
}),
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
}),
}),
),
experimental: Schema.optional(
Schema.Struct({
disable_paste_summary: Schema.optional(Schema.Boolean),
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
openTelemetry: Schema.optional(Schema.Boolean).annotate({
description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)",
}),
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Tools that should only be available to primary agents.",
}),
continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
description: "Continue the agent loop when a tool call is denied",
}),
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
}),
),
})
// Schema.Struct produces readonly types by default, but the service code
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
// readonly recursively so callers get the same mutable shape zod inferred.
//
// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback
// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}`
// (since `keyof unknown = never`), which widens `Record<string, unknown>`
// fields like `ConfigPlugin.Options`. The local version gates on
// `extends object` so `unknown` passes through.
//
// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]`
// shape (otherwise the general array branch widens it to an array).
type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T extends readonly (infer U)[]
? DeepMutable<U>[]
: T extends object
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: 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<any>)
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
})
.strict()
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>
.meta({
ref: "ServerConfig",
})
export const Layout = z.enum(["auto", "stretch"]).meta({
ref: "LayoutConfig",
})
export type Layout = z.infer<typeof Layout>
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
logLevel: Log.Level.optional().describe("Log level"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), ConfigCommand.Info.zod)
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"),
skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"),
watcher: z
.object({
ignore: z.array(z.string()).optional(),
})
.optional(),
snapshot: z
.boolean()
.optional()
.describe(
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
plugin: ConfigPlugin.Spec.zod.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
.describe(
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
),
autoshare: z
.boolean()
.optional()
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
autoupdate: z
.union([z.boolean(), z.literal("notify")])
.optional()
.describe(
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
enabled_providers: z
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ConfigModelID.zod
.describe("Small model to use for tasks like title generation in the format of provider/model")
.optional(),
default_agent: z
.string()
.optional()
.describe(
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
),
username: z.string().optional().describe("Custom username to display in conversations instead of system username"),
mode: z
.object({
build: ConfigAgent.Info.optional(),
plan: ConfigAgent.Info.optional(),
})
.catchall(ConfigAgent.Info)
.optional()
.describe("@deprecated Use `agent` field instead."),
agent: z
.object({
// primary
plan: ConfigAgent.Info.optional(),
build: ConfigAgent.Info.optional(),
// subagent
general: ConfigAgent.Info.optional(),
explore: ConfigAgent.Info.optional(),
// specialized
title: ConfigAgent.Info.optional(),
summary: ConfigAgent.Info.optional(),
compaction: ConfigAgent.Info.optional(),
})
.catchall(ConfigAgent.Info)
.optional()
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
provider: z
.record(z.string(), ConfigProvider.Info.zod)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
.record(
z.string(),
z.union([
ConfigMCP.Info.zod,
z
.object({
enabled: z.boolean(),
})
.strict(),
]),
)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
formatter: ConfigFormatter.Info.zod.optional(),
lsp: ConfigLSP.Info.zod.optional(),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: ConfigPermission.Info.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({
url: z.string().optional().describe("Enterprise URL"),
})
.optional(),
compaction: z
.object({
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
reserved: z
.number()
.int()
.min(0)
.optional()
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
})
.optional(),
experimental: z
.object({
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
openTelemetry: z
.boolean()
.optional()
.describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"),
primary_tools: z
.array(z.string())
.optional()
.describe("Tools that should only be available to primary agents."),
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
mcp_timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
})
.strict()
.meta({
ref: "Config",
})
export type Info = z.output<typeof Info> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
+163 -124
View File
@@ -1,127 +1,166 @@
export * as ConfigKeybinds from "./keybinds"
import { Effect, Schema } from "effect"
import type z from "zod"
import { zod } from "@/util/effect-zod"
import z from "zod"
// Every keybind field has the same shape: an optional string with a default
// binding and a human description. `keybind()` keeps the declaration list
// below dense and readable.
const keybind = (value: string, description: string) =>
Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description })
// Windows prepends ctrl+z to the undo binding because `terminal_suspend`
// cannot consume ctrl+z on native Windows terminals (no POSIX suspend).
const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z"
const KeybindsSchema = Schema.Struct({
leader: keybind("ctrl+x", "Leader key for keybind combinations"),
app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
editor_open: keybind("<leader>e", "Open external editor"),
theme_list: keybind("<leader>t", "List available themes"),
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
username_toggle: keybind("none", "Toggle username visibility"),
status_view: keybind("<leader>s", "View status"),
session_export: keybind("<leader>x", "Export session to editor"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
session_delete: keybind("ctrl+d", "Delete session"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_compact: keybind("<leader>c", "Compact the session"),
messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
tool_details: keybind("none", "Toggle tool details visibility"),
model_list: keybind("<leader>m", "List available models"),
model_cycle_recent: keybind("f2", "Next recently used model"),
model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"),
model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
command_list: keybind("ctrl+p", "List available commands"),
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
variant_cycle: keybind("ctrl+t", "Cycle model variants"),
variant_list: keybind("none", "List model variants"),
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind("ctrl+v", "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
input_move_down: keybind("down", "Move cursor down in input"),
input_select_left: keybind("shift+left", "Select left in input"),
input_select_right: keybind("shift+right", "Select right in input"),
input_select_up: keybind("shift+up", "Select up in input"),
input_select_down: keybind("shift+down", "Select down in input"),
input_line_home: keybind("ctrl+a", "Move to start of line in input"),
input_line_end: keybind("ctrl+e", "Move to end of line in input"),
input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"),
input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"),
input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"),
input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"),
input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"),
input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"),
input_buffer_home: keybind("home", "Move to start of buffer in input"),
input_buffer_end: keybind("end", "Move to end of buffer in input"),
input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"),
input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"),
input_delete_line: keybind("ctrl+shift+d", "Delete line in input"),
input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"),
input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"),
input_backspace: keybind("backspace,shift+backspace", "Backspace in input"),
input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
input_undo: keybind(inputUndoDefault, "Undo in input"),
input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"),
input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
history_previous: keybind("up", "Previous history item"),
history_next: keybind("down", "Next history item"),
session_child_first: keybind("<leader>down", "Go to first child session"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
// `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`,
// but `tui.ts` already forces the binding to "none" on win32 before calling
// `Keybinds.parse(...)`, so the schema-level transform was redundant.
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
terminal_title_toggle: keybind("none", "Toggle terminal title"),
tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
plugin_manager: keybind("none", "Open plugin manager dialog"),
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
}).annotate({ identifier: "KeybindsConfig" })
export type Keybinds = Schema.Schema.Type<typeof KeybindsSchema>
// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`,
// which requires the runtime type to be a ZodObject, not just ZodType. Every
// field is `string().optional().default(...)` at runtime, so widen to that.
export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject<
Record<keyof Keybinds, z.ZodDefault<z.ZodOptional<z.ZodString>>>
>
export const Keybinds = z
.object({
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
status_view: z.string().optional().default("<leader>s").describe("View status"),
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
session_fork: z.string().optional().default("none").describe("Fork session from message"),
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.default("pagedown,ctrl+alt+f")
.describe("Scroll messages down by one page"),
messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
messages_half_page_down: z.string().optional().default("ctrl+alt+d").describe("Scroll messages down by half page"),
messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
messages_next: z.string().optional().default("none").describe("Navigate to next message"),
messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
messages_toggle_conceal: z
.string()
.optional()
.default("<leader>h")
.describe("Toggle code block concealment in messages"),
tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"),
model_list: z.string().optional().default("<leader>m").describe("List available models"),
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
variant_list: z.string().optional().default("none").describe("List model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("return").describe("Submit input"),
input_newline: z
.string()
.optional()
.default("shift+return,ctrl+return,alt+return,ctrl+j")
.describe("Insert newline in input"),
input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"),
input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"),
input_move_up: z.string().optional().default("up").describe("Move cursor up in input"),
input_move_down: z.string().optional().default("down").describe("Move cursor down in input"),
input_select_left: z.string().optional().default("shift+left").describe("Select left in input"),
input_select_right: z.string().optional().default("shift+right").describe("Select right in input"),
input_select_up: z.string().optional().default("shift+up").describe("Select up in input"),
input_select_down: z.string().optional().default("shift+down").describe("Select down in input"),
input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"),
input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"),
input_select_line_home: z.string().optional().default("ctrl+shift+a").describe("Select to start of line in input"),
input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"),
input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"),
input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"),
input_select_visual_line_home: z
.string()
.optional()
.default("alt+shift+a")
.describe("Select to start of visual line in input"),
input_select_visual_line_end: z
.string()
.optional()
.default("alt+shift+e")
.describe("Select to end of visual line in input"),
input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"),
input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"),
input_select_buffer_home: z
.string()
.optional()
.default("shift+home")
.describe("Select to start of buffer in input"),
input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"),
input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"),
input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"),
input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
input_undo: z
.string()
.optional()
// On Windows prepend ctrl+z since terminal_suspend releases the binding.
.default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")
.describe("Undo in input"),
input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
input_word_forward: z
.string()
.optional()
.default("alt+f,alt+right,ctrl+right")
.describe("Move word forward in input"),
input_word_backward: z
.string()
.optional()
.default("alt+b,alt+left,ctrl+left")
.describe("Move word backward in input"),
input_select_word_forward: z
.string()
.optional()
.default("alt+shift+f,alt+shift+right")
.describe("Select word forward in input"),
input_select_word_backward: z
.string()
.optional()
.default("alt+shift+b,alt+shift+left")
.describe("Select word backward in input"),
input_delete_word_forward: z
.string()
.optional()
.default("alt+d,alt+delete,ctrl+delete")
.describe("Delete word forward in input"),
input_delete_word_backward: z
.string()
.optional()
.default("ctrl+w,ctrl+backspace,alt+backspace")
.describe("Delete word backward in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"),
session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"),
session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
session_parent: z.string().optional().default("up").describe("Go to parent session"),
terminal_suspend: z
.string()
.optional()
.default("ctrl+z")
.transform((v) => (process.platform === "win32" ? "none" : v))
.describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
})
.strict()
.meta({
ref: "KeybindsConfig",
})
-10
View File
@@ -1,10 +0,0 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Layout = Schema.Literals(["auto", "stretch"])
.annotate({ identifier: "LayoutConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Layout = Schema.Schema.Type<typeof Layout>
export * as ConfigLayout from "./layout"
+40 -45
View File
@@ -1,8 +1,16 @@
export * as ConfigPermission from "./permission"
import { Schema } from "effect"
import { zod, ZodPreprocess } from "@/util/effect-zod"
import z from "zod"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const permissionPreprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __originalKeys: globalThis.Object.keys(val), ...val }
}
return val
}
export const Action = Schema.Literals(["ask", "allow", "deny"])
.annotate({ identifier: "PermissionActionConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -18,48 +26,6 @@ export const Rule = Schema.Union([Action, Object])
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type<typeof Rule>
// 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(
Schema.Struct({
__originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
read: Schema.optional(Rule),
edit: Schema.optional(Rule),
glob: Schema.optional(Rule),
grep: Schema.optional(Rule),
list: Schema.optional(Rule),
bash: Schema.optional(Rule),
task: Schema.optional(Rule),
external_directory: Schema.optional(Rule),
todowrite: Schema.optional(Action),
question: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
codesearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
doom_loop: Schema.optional(Action),
skill: Schema.optional(Rule),
}),
[Schema.Record(Schema.String, Rule)],
)
const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({
[ZodPreprocess]: permissionPreprocess,
})
// 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<string, Rule> => {
if (typeof x === "string") return { "*": x as Action }
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
@@ -72,5 +38,34 @@ const transform = (x: unknown): Record<string, Rule> => {
return result
}
export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" })
export type Info = Record<string, Rule>
export const Info = z
.preprocess(
permissionPreprocess,
z
.object({
__originalKeys: z.string().array().optional(),
read: Rule.zod.optional(),
edit: Rule.zod.optional(),
glob: Rule.zod.optional(),
grep: Rule.zod.optional(),
list: Rule.zod.optional(),
bash: Rule.zod.optional(),
task: Rule.zod.optional(),
external_directory: Rule.zod.optional(),
todowrite: Action.zod.optional(),
question: Action.zod.optional(),
webfetch: Action.zod.optional(),
websearch: Action.zod.optional(),
codesearch: Action.zod.optional(),
lsp: Rule.zod.optional(),
doom_loop: Action.zod.optional(),
skill: Rule.zod.optional(),
})
.catchall(Rule.zod)
.or(Action.zod),
)
.transform(transform)
.meta({
ref: "PermissionConfig",
})
export type Info = z.infer<typeof Info>
+6 -2
View File
@@ -1,8 +1,12 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import z from "zod"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
// Positive integer preserving exact Zod JSON Schema (type: integer, exclusiveMinimum: 0).
const PositiveInt = Schema.Number.annotate({
[ZodOverride]: z.number().int().positive(),
})
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
-20
View File
@@ -1,20 +0,0 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
export class Server extends Schema.Class<Server>("ServerConfig")({
port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
description: "Port to listen on",
}),
hostname: Schema.optional(Schema.String).annotate({ description: "Hostname to listen on" }),
mdns: Schema.optional(Schema.Boolean).annotate({ description: "Enable mDNS service discovery" }),
mdnsDomain: Schema.optional(Schema.String).annotate({
description: "Custom domain name for mDNS service (default: opencode.local)",
}),
cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Additional domains to allow for CORS",
}),
}) {
static readonly zod = zod(this)
}
export * as ConfigServer from "./server"
@@ -117,7 +117,6 @@ export const create = fn(CreateInput, async (input) => {
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
await adaptor.create(config, env)
+1 -7
View File
@@ -3,8 +3,6 @@ import { Log } from "@/util"
type Fields = Record<string, unknown>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
@@ -14,11 +12,7 @@ export interface Handle {
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(
Object.entries(input ?? {})
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => [normalizeKey(key), value]),
)
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
+1 -18
View File
@@ -21,29 +21,12 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
)
: undefined
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
function resource() {
const processMetadata = ensureProcessMetadata("main")
const attributes: Record<string, string> = (() => {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
})()
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...attributes,
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
+20 -28
View File
@@ -356,9 +356,8 @@ export const layer = Layer.effect(
)
const scan = Effect.fn("File.scan")(function* () {
const ctx = yield* InstanceState.context
if (ctx.directory === path.parse(ctx.directory).root) return
const isGlobalHome = ctx.directory === Global.Path.home && ctx.project.id === "global"
if (Instance.directory === path.parse(Instance.directory).root) return
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
const next: Entry = { files: [], dirs: [] }
if (isGlobalHome) {
@@ -367,14 +366,14 @@ export const layer = Layer.effect(
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = yield* appFs.readDirectoryEntries(ctx.directory).pipe(Effect.orElseSucceed(() => []))
const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
for (const entry of top) {
if (entry.type !== "directory") continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(ctx.directory, entry.name)
const base = path.join(Instance.directory, entry.name)
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
for (const child of children) {
if (child.type !== "directory") continue
@@ -385,7 +384,7 @@ export const layer = Layer.effect(
next.dirs = Array.from(dirs).toSorted()
} else {
const files = yield* rg.files({ cwd: ctx.directory }).pipe(
const files = yield* rg.files({ cwd: Instance.directory }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
@@ -417,7 +416,7 @@ export const layer = Layer.effect(
})
const gitText = Effect.fnUntraced(function* (args: string[]) {
return (yield* git.run(args, { cwd: (yield* InstanceState.context).directory })).text()
return (yield* git.run(args, { cwd: Instance.directory })).text()
})
const init = Effect.fn("File.init")(function* () {
@@ -425,8 +424,7 @@ export const layer = Layer.effect(
})
const status = Effect.fn("File.status")(function* () {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
if (Instance.project.vcs !== "git") return []
const diffOutput = yield* gitText([
"-c",
@@ -465,7 +463,7 @@ export const layer = Layer.effect(
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
const content = yield* appFs
.readFileString(path.join(ctx.directory, file))
.readFileString(path.join(Instance.directory, file))
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
if (content === undefined) continue
changed.push({
@@ -500,22 +498,19 @@ export const layer = Layer.effect(
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(ctx.directory, item.path)
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
return {
...item,
path: path.relative(ctx.directory, full),
path: path.relative(Instance.directory, full),
}
})
})
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file })
const ctx = yield* InstanceState.context
const full = path.join(ctx.directory, file)
const full = path.join(Instance.directory, file)
if (!Instance.containsPath(full, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
if (isImageByExtension(file)) {
const exists = yield* appFs.existsSafe(full)
@@ -558,13 +553,13 @@ export const layer = Layer.effect(
Effect.catch(() => Effect.succeed("")),
)
if (ctx.project.vcs === "git") {
if (Instance.project.vcs === "git") {
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
if (!diff.trim()) {
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
}
if (diff.trim()) {
const original = yield* git.show(ctx.directory, "HEAD", file)
const original = yield* git.show(Instance.directory, "HEAD", file)
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
@@ -578,24 +573,21 @@ export const layer = Layer.effect(
})
const list = Effect.fn("File.list")(function* (dir?: string) {
const ctx = yield* InstanceState.context
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (ctx.project.vcs === "git") {
if (Instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(ctx.worktree, ".gitignore")
const gitignore = path.join(Instance.project.worktree, ".gitignore")
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
if (gitignoreText) ig.add(gitignoreText)
const ignoreFile = path.join(ctx.worktree, ".ignore")
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
if (ignoreText) ig.add(ignoreText)
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
if (!Instance.containsPath(resolved, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
@@ -603,7 +595,7 @@ export const layer = Layer.effect(
for (const entry of entries) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(ctx.directory, absolute)
const file = path.relative(Instance.directory, absolute)
const type = entry.type === "directory" ? "directory" : "file"
nodes.push({
name: entry.name,
+19 -21
View File
@@ -1,17 +1,15 @@
import { Npm } from "../npm"
import type { InstanceContext } from "../project/instance"
import { Instance } from "../project/instance"
import { Filesystem } from "../util"
import { Process } from "../util"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {}
export interface Info {
name: string
environment?: Record<string, string>
extensions: string[]
enabled(context: Context): Promise<string[] | false>
enabled(): Promise<string[] | false>
}
export const gofmt: Info = {
@@ -67,8 +65,8 @@ export const prettier: Info = {
".graphql",
".gql",
],
async enabled(context) {
const items = await Filesystem.findUp("package.json", context.directory, context.worktree)
async enabled() {
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
@@ -89,9 +87,9 @@ export const oxfmt: Info = {
BUN_BE_BUN: "1",
},
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
async enabled(context) {
async enabled() {
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
const items = await Filesystem.findUp("package.json", context.directory, context.worktree)
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
@@ -139,10 +137,10 @@ export const biome: Info = {
".graphql",
".gql",
],
async enabled(context) {
async enabled() {
const configs = ["biome.json", "biome.jsonc"]
for (const config of configs) {
const found = await Filesystem.findUp(config, context.directory, context.worktree)
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
const bin = await Npm.which("@biomejs/biome")
if (bin) return [bin, "format", "--write", "$FILE"]
@@ -165,8 +163,8 @@ export const zig: Info = {
export const clang: Info = {
name: "clang-format",
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled(context) {
const items = await Filesystem.findUp(".clang-format", context.directory, context.worktree)
async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
if (items.length > 0) {
const match = which("clang-format")
if (match) return [match, "-i", "$FILE"]
@@ -188,11 +186,11 @@ export const ktlint: Info = {
export const ruff: Info = {
name: "ruff",
extensions: [".py", ".pyi"],
async enabled(context) {
async enabled() {
if (!which("ruff")) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, context.directory, context.worktree)
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0])
@@ -204,7 +202,7 @@ export const ruff: Info = {
}
const deps = ["requirements.txt", "pyproject.toml", "Pipfile"]
for (const dep of deps) {
const found = await Filesystem.findUp(dep, context.directory, context.worktree)
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) {
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return ["ruff", "format", "$FILE"]
@@ -235,8 +233,8 @@ export const rlang: Info = {
export const uvformat: Info = {
name: "uv",
extensions: [".py", ".pyi"],
async enabled(context) {
if (await ruff.enabled(context)) return false
async enabled() {
if (await ruff.enabled()) return false
const uv = which("uv")
if (uv == null) return false
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
@@ -288,9 +286,9 @@ export const dart: Info = {
export const ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
async enabled(context) {
async enabled() {
if (!which("ocamlformat")) return false
const items = await Filesystem.findUp(".ocamlformat", context.directory, context.worktree)
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
if (items.length > 0) return ["ocamlformat", "-i", "$FILE"]
return false
},
@@ -359,8 +357,8 @@ export const rustfmt: Info = {
export const pint: Info = {
name: "pint",
extensions: [".php"],
async enabled(context) {
const items = await Filesystem.findUp("composer.json", context.directory, context.worktree)
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
require?: Record<string, string>
+3 -3
View File
@@ -37,14 +37,14 @@ export const layer = Layer.effect(
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (ctx) {
Effect.fn("Format.state")(function* (_ctx) {
const commands: Record<string, string[] | false> = {}
const formatters: Record<string, Formatter.Info> = {}
async function getCommand(item: Formatter.Info) {
let cmd = commands[item.name]
if (cmd === false || cmd === undefined) {
cmd = await item.enabled(ctx)
cmd = await item.enabled()
commands[item.name] = cmd
}
return cmd
@@ -153,7 +153,7 @@ export const layer = Layer.effect(
...info,
name,
extensions: info.extensions ?? [],
enabled: builtIn && !info.command ? builtIn.enabled : async (_context) => info.command ?? false,
enabled: builtIn && !info.command ? builtIn.enabled : async () => info.command ?? false,
}
}
}
+20 -19
View File
@@ -11,6 +11,7 @@ import z from "zod"
import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/shared/util/error"
import { withTimeout } from "../util/timeout"
import { Instance } from "../project/instance"
import { Filesystem } from "../util"
const DIAGNOSTICS_DEBOUNCE_MS = 150
@@ -38,7 +39,7 @@ export const Event = {
),
}
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
@@ -144,33 +145,33 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return connection
},
notify: {
async open(request: { path: string }) {
request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
const text = await Filesystem.readText(request.path)
const extension = path.extname(request.path)
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
const text = await Filesystem.readText(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
const version = files[request.path]
const version = files[input.path]
if (version !== undefined) {
log.info("workspace/didChangeWatchedFiles", request)
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(request.path).href,
uri: pathToFileURL(input.path).href,
type: 2, // Changed
},
],
})
const next = version + 1
files[request.path] = next
files[input.path] = next
log.info("textDocument/didChange", {
path: request.path,
path: input.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: pathToFileURL(request.path).href,
uri: pathToFileURL(input.path).href,
version: next,
},
contentChanges: [{ text }],
@@ -178,36 +179,36 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return
}
log.info("workspace/didChangeWatchedFiles", request)
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(request.path).href,
uri: pathToFileURL(input.path).href,
type: 1, // Created
},
],
})
log.info("textDocument/didOpen", request)
diagnostics.delete(request.path)
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(request.path).href,
uri: pathToFileURL(input.path).href,
languageId,
version: 0,
text,
},
})
files[request.path] = 0
files[input.path] = 0
return
},
},
get diagnostics() {
return diagnostics
},
async waitForDiagnostics(request: { path: string }) {
async waitForDiagnostics(input: { path: string }) {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
)
log.info("waiting for diagnostics", { path: normalizedPath })
let unsub: () => void
+8 -17
View File
@@ -7,12 +7,12 @@ import { pathToFileURL, fileURLToPath } from "url"
import * as LSPServer from "./server"
import z from "zod"
import { Config } from "../config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
const log = Log.create({ service: "lsp" })
@@ -162,7 +162,7 @@ export const layer = Layer.effect(
const config = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* (ctx) {
Effect.fn("LSP.state")(function* () {
const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {}
@@ -187,7 +187,7 @@ export const layer = Layer.effect(
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async (_file, ctx) => ctx.directory),
root: existing?.root ?? (async () => Instance.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
@@ -225,13 +225,7 @@ export const layer = Layer.effect(
)
const getClients = Effect.fnUntraced(function* (file: string) {
const ctx = yield* InstanceState.context
if (
!AppFileSystem.contains(ctx.directory, file) &&
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
) {
return [] as LSPClient.Info[]
}
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
@@ -239,7 +233,7 @@ export const layer = Layer.effect(
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
.spawn(root, ctx)
.spawn(root)
.then((value) => {
if (!value) s.broken.add(key)
return value
@@ -257,7 +251,6 @@ export const layer = Layer.effect(
serverID: server.id,
server: handle,
root,
directory: ctx.directory,
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
@@ -280,7 +273,7 @@ export const layer = Layer.effect(
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file, ctx)
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
@@ -333,14 +326,13 @@ export const layer = Layer.effect(
})
const status = Effect.fn("LSP.status")(function* () {
const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state)
const result: Status[] = []
for (const client of s.clients) {
result.push({
id: client.serverID,
name: s.servers[client.serverID].id,
root: path.relative(ctx.directory, client.root),
root: path.relative(Instance.directory, client.root),
status: "connected",
})
}
@@ -348,13 +340,12 @@ export const layer = Layer.effect(
})
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file, ctx)
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
+42 -42
View File
@@ -6,7 +6,7 @@ import { Log } from "../util"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util"
import type { InstanceContext } from "../project/instance"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util"
import { Process } from "../util"
@@ -29,15 +29,15 @@ export interface Handle {
initialization?: Record<string, any>
}
type RootFunction = (file: string, ctx: InstanceContext) => Promise<string | undefined>
type RootFunction = (file: string) => Promise<string | undefined>
const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => {
return async (file, ctx) => {
return async (file) => {
if (excludePatterns) {
const excludedFiles = Filesystem.up({
targets: excludePatterns,
start: path.dirname(file),
stop: ctx.directory,
stop: Instance.directory,
})
const excluded = await excludedFiles.next()
await excludedFiles.return()
@@ -46,11 +46,11 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo
const files = Filesystem.up({
targets: includePatterns,
start: path.dirname(file),
stop: ctx.directory,
stop: Instance.directory,
})
const first = await files.next()
await files.return()
if (!first.value) return ctx.directory
if (!first.value) return Instance.directory
return path.dirname(first.value)
}
}
@@ -60,16 +60,16 @@ export interface Info {
extensions: string[]
global?: boolean
root: RootFunction
spawn(root: string, ctx: InstanceContext): Promise<Handle | undefined>
spawn(root: string): Promise<Handle | undefined>
}
export const Deno: Info = {
id: "deno",
root: async (file, ctx) => {
root: async (file) => {
const files = Filesystem.up({
targets: ["deno.json", "deno.jsonc"],
start: path.dirname(file),
stop: ctx.directory,
stop: Instance.directory,
})
const first = await files.next()
await files.return()
@@ -98,8 +98,8 @@ export const Typescript: Info = {
["deno.json", "deno.jsonc"],
),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(root, ctx) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
async spawn(root) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
const bin = await Npm.which("typescript-language-server")
@@ -154,8 +154,8 @@ export const ESLint: Info = {
id: "eslint",
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
async spawn(root, ctx) {
const eslint = Module.resolve("eslint", ctx.directory)
async spawn(root) {
const eslint = Module.resolve("eslint", Instance.directory)
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
@@ -219,7 +219,7 @@ export const Oxlint: Info = {
"package.json",
]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"],
async spawn(root, ctx) {
async spawn(root) {
const ext = process.platform === "win32" ? ".cmd" : ""
const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext)
@@ -232,7 +232,7 @@ export const Oxlint: Info = {
const candidates = Filesystem.up({
targets: [target],
start: root,
stop: ctx.worktree,
stop: Instance.worktree,
})
const first = await candidates.next()
await candidates.return()
@@ -344,10 +344,10 @@ export const Biome: Info = {
export const Gopls: Info = {
id: "gopls",
root: async (file, ctx) => {
const work = await NearestRoot(["go.work"])(file, ctx)
root: async (file) => {
const work = await NearestRoot(["go.work"])(file)
if (work) return work
return NearestRoot(["go.mod", "go.sum"])(file, ctx)
return NearestRoot(["go.mod", "go.sum"])(file)
},
extensions: [".go"],
async spawn(root) {
@@ -810,8 +810,8 @@ export const SourceKit: Info = {
export const RustAnalyzer: Info = {
id: "rust",
root: async (file, ctx) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx)
root: async (root) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root)
if (crateRoot === undefined) {
return undefined
}
@@ -834,7 +834,7 @@ export const RustAnalyzer: Info = {
currentDir = parentDir
// Stop if we've gone above the app root
if (!currentDir.startsWith(ctx.worktree)) break
if (!currentDir.startsWith(Instance.worktree)) break
}
return crateRoot
@@ -1031,8 +1031,8 @@ export const Astro: Info = {
id: "astro",
extensions: [".astro"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root, ctx) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
async spawn(root) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
if (!tsserver) {
log.info("typescript not found, required for Astro language server")
return
@@ -1067,7 +1067,7 @@ export const Astro: Info = {
export const JDTLS: Info = {
id: "jdtls",
root: async (file, ctx) => {
root: async (file) => {
// Without exclusions, NearestRoot defaults to instance directory so we can't
// distinguish between a) no project found and b) project found at instance dir.
// So we can't choose the root from (potential) monorepo markers first.
@@ -1077,12 +1077,12 @@ export const JDTLS: Info = {
const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers)
const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([
NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], exclusionsForMonorepos)(
file,
ctx,
),
NearestRoot(gradleMarkers, settingsMarkers)(file, ctx),
NearestRoot(settingsMarkers)(file, ctx),
NearestRoot(
["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"],
exclusionsForMonorepos,
)(file),
NearestRoot(gradleMarkers, settingsMarkers)(file),
NearestRoot(settingsMarkers)(file),
])
// If projectRoot is undefined we know we are in a monorepo or no project at all.
@@ -1189,18 +1189,18 @@ export const JDTLS: Info = {
export const KotlinLS: Info = {
id: "kotlin-ls",
extensions: [".kt", ".kts"],
root: async (file, ctx) => {
root: async (file) => {
// 1) Nearest Gradle root (multi-project or included build)
const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file, ctx)
const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file)
if (settingsRoot) return settingsRoot
// 2) Gradle wrapper (strong root signal)
const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file, ctx)
const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file)
if (wrapperRoot) return wrapperRoot
// 3) Single-project or module-level build
const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file, ctx)
const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file)
if (buildRoot) return buildRoot
// 4) Maven fallback
return NearestRoot(["pom.xml"])(file, ctx)
return NearestRoot(["pom.xml"])(file)
},
async spawn(root) {
const distPath = path.join(Global.Path.bin, "kotlin-ls")
@@ -1539,7 +1539,7 @@ export const Ocaml: Info = {
export const BashLS: Info = {
id: "bash",
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async (_file, ctx) => ctx.directory,
root: async () => Instance.directory,
async spawn(root) {
let binary = which("bash-language-server")
const args: string[] = []
@@ -1734,7 +1734,7 @@ export const TexLab: Info = {
export const DockerfileLS: Info = {
id: "dockerfile",
extensions: [".dockerfile", "Dockerfile"],
root: async (_file, ctx) => ctx.directory,
root: async () => Instance.directory,
async spawn(root) {
let binary = which("docker-langserver")
const args: string[] = []
@@ -1799,16 +1799,16 @@ export const Clojure: Info = {
export const Nixd: Info = {
id: "nixd",
extensions: [".nix"],
root: async (file, ctx) => {
root: async (file) => {
// First, look for flake.nix - the most reliable Nix project root indicator
const flakeRoot = await NearestRoot(["flake.nix"])(file, ctx)
if (flakeRoot && flakeRoot !== ctx.directory) return flakeRoot
const flakeRoot = await NearestRoot(["flake.nix"])(file)
if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot
// If no flake.nix, fall back to git repository root
if (ctx.worktree && ctx.worktree !== ctx.directory) return ctx.worktree
if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree
// Finally, use the instance directory as fallback
return ctx.directory
return Instance.directory
},
async spawn(root) {
const nixd = which("nixd")
+2 -1
View File
@@ -14,6 +14,7 @@ import { ConfigMCP } from "../config/mcp"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
import { Installation } from "../installation"
import { InstallationVersion } from "../installation/version"
import { withTimeout } from "@/util/timeout"
@@ -390,7 +391,7 @@ export const layer = Layer.effect(
mcp: ConfigMCP.Info & { type: "local" },
) {
const [cmd, ...args] = mcp.command
const cwd = yield* InstanceState.directory
const cwd = Instance.directory
const transport = new StdioClientTransport({
stderr: "pipe",
command: cmd,
+7 -11
View File
@@ -150,17 +150,13 @@ export const layer = Layer.effect(
if (!canWrite) return
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
if (
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add, dir })
return true
}
return false
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
)
return
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add, dir })
return
}
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
yield* Effect.gen(function* () {
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
@@ -334,13 +334,6 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
if (incoming.model.api.id.includes("gpt")) {
output.maxOutputTokens = undefined
}
// GitHub Copilot's /v1/messages shim rejects the GA `eager_input_streaming`
// field on tool definitions ("Extra inputs are not permitted"). Opt out of
// the @ai-sdk/anthropic default so it stops injecting the field.
if (incoming.model.api.npm === "@ai-sdk/anthropic") {
output.options.toolStreaming = false
}
},
"chat.headers": async (incoming, output) => {
if (!incoming.model.providerID.includes("github-copilot")) return
+1 -1
View File
@@ -96,7 +96,7 @@ export const Instance = {
if (AppFileSystem.contains(instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (instance.worktree === "/") return false
if (Instance.worktree === "/") return false
return AppFileSystem.contains(instance.worktree, filepath)
},
/**
+10 -5
View File
@@ -8,6 +8,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import { Log } from "@/util"
import { Instance } from "./instance"
import z from "zod"
const log = Log.create({ service: "vcs" })
@@ -204,17 +205,21 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Serv
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
const value = yield* InstanceState.get(state)
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
if (Instance.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(fs, git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
return yield* track(
fs,
git,
Instance.directory,
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
if (!ref) return []
return yield* compare(fs, git, ctx.directory, ref)
return yield* compare(fs, git, Instance.directory, ref)
}),
})
}),
+5 -2
View File
@@ -13,6 +13,7 @@ import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
import { Instance } from "../project/instance"
import { InstallationVersion } from "../installation/version"
import { Flag } from "../flag/flag"
import { zod } from "@/util/effect-zod"
@@ -536,7 +537,6 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
const directory = yield* InstanceState.directory
const aiGatewayHeaders = {
"User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
@@ -591,7 +591,10 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
log.info("gitlab model discovery starting", { instanceUrl })
const result = await discoverWorkflowModels({ instanceUrl, getHeaders }, { workingDirectory: directory })
const result = await discoverWorkflowModels(
{ instanceUrl, getHeaders },
{ workingDirectory: Instance.directory },
)
if (!result.models.length) {
log.info("gitlab model discovery skipped: no models found", {
@@ -635,9 +635,6 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
? { display: "summarized" }
: {}),
},
},
]),
+4 -5
View File
@@ -8,6 +8,7 @@ import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Log } from "../util"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
@@ -81,10 +82,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
)
const relative = Effect.fnUntraced(function* (instruction: string) {
const ctx = yield* InstanceState.context
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, ctx.directory, ctx.worktree)
.globUp(instruction, Instance.directory, Instance.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
if (!Flag.OPENCODE_CONFIG_DIR) {
@@ -119,13 +119,12 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const ctx = yield* InstanceState.context
const paths = new Set<string>()
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
@@ -192,9 +191,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const root = path.resolve(yield* InstanceState.directory)
const target = path.resolve(filepath)
const root = path.resolve(Instance.directory)
let current = path.dirname(target)
// Walk upward from the file being read and attach nearby instruction files once per message.
+2 -14
View File
@@ -74,7 +74,7 @@ const live: Layer.Layer<
.clone()
.tag("providerID", input.model.providerID)
.tag("modelID", input.model.id)
.tag("session.id", input.sessionID)
.tag("sessionID", input.sessionID)
.tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name)
.tag("mode", input.agent.mode)
@@ -317,18 +317,6 @@ const live: Layer.Layer<
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
const telemetryTracer = tracer
? new Proxy(tracer, {
get(target, prop, receiver) {
if (prop !== "startSpan") return Reflect.get(target, prop, receiver)
return (...args: Parameters<typeof target.startSpan>) => {
const span = target.startSpan(...args)
span.setAttribute("session.id", input.sessionID)
return span
}
},
})
: undefined
return streamText({
onError(error) {
@@ -402,7 +390,7 @@ const live: Layer.Layer<
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
tracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
+4 -2
View File
@@ -11,7 +11,6 @@ import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { ProviderError } from "@/provider"
import { iife } from "@/util/iife"
import { errorMessage } from "@/util/error"
import { isMedia } from "@/util/media"
import type { SystemError } from "bun"
import type { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema"
@@ -26,7 +25,10 @@ interface FetchDecompressionError extends Error {
}
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
export { isMedia }
export function isMedia(mime: string) {
return mime.startsWith("image/") || mime === "application/pdf"
}
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
+1 -1
View File
@@ -123,7 +123,7 @@ export const layer: Layer.Layer<
reasoningMap: {},
}
let aborted = false
const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
const slog = log.clone().tag("sessionID", input.sessionID).tag("messageID", input.assistantMessage.id)
const parse = (e: unknown) =>
MessageV2.fromError(e, {
+69 -72
View File
@@ -1,6 +1,7 @@
import z from "zod"
import { Effect, Option, Scope } from "effect"
import { Effect, Scope } from "effect"
import { createReadStream } from "fs"
import { open } from "fs/promises"
import * as path from "path"
import { createInterface } from "readline"
import * as Tool from "./tool"
@@ -10,14 +11,12 @@ import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const SAMPLE_BYTES = 4096
const parameters = z.object({
filePath: z.string().describe("The absolute path to the file or directory to read"),
@@ -78,68 +77,6 @@ export const ReadTool = Tool.define(
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
})
const readSample = Effect.fn("ReadTool.readSample")(function* (
filepath: string,
fileSize: number,
sampleSize: number,
) {
if (fileSize === 0) return new Uint8Array()
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(filepath, { flag: "r" })
return Option.getOrElse(yield* file.readAlloc(Math.min(sampleSize, fileSize)), () => new Uint8Array())
}),
)
})
const isBinaryFile = (filepath: string, bytes: Uint8Array) => {
const ext = path.extname(filepath).toLowerCase()
switch (ext) {
case ".zip":
case ".tar":
case ".gz":
case ".exe":
case ".dll":
case ".so":
case ".class":
case ".jar":
case ".war":
case ".7z":
case ".doc":
case ".docx":
case ".xls":
case ".xlsx":
case ".ppt":
case ".pptx":
case ".odt":
case ".ods":
case ".odp":
case ".bin":
case ".dat":
case ".obj":
case ".o":
case ".a":
case ".lib":
case ".wasm":
case ".pyc":
case ".pyo":
return true
}
if (bytes.length === 0) return false
let nonPrintableCount = 0
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) return true
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
nonPrintableCount++
}
}
return nonPrintableCount / bytes.length > 0.3
}
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
if (params.offset !== undefined && params.offset < 1) {
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
@@ -204,12 +141,12 @@ export const ReadTool = Tool.define(
}
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
if (isImageAttachment(mime) || isPdfAttachment(mime)) {
const bytes = yield* fs.readFile(filepath)
const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"
const mime = AppFileSystem.mimeType(filepath)
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
const isPdf = mime === "application/pdf"
if (isImage || isPdf) {
const msg = `${isImage ? "Image" : "PDF"} read successfully`
return {
title,
output: msg,
@@ -222,13 +159,13 @@ export const ReadTool = Tool.define(
{
type: "file" as const,
mime,
url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`,
url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`,
},
],
}
}
if (isBinaryFile(filepath, sample)) {
if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) {
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
}
@@ -324,3 +261,63 @@ async function lines(filepath: string, opts: { limit: number; offset: number })
return { raw, count, cut, more, offset: opts.offset }
}
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
const ext = path.extname(filepath).toLowerCase()
// binary check for common non-text extensions
switch (ext) {
case ".zip":
case ".tar":
case ".gz":
case ".exe":
case ".dll":
case ".so":
case ".class":
case ".jar":
case ".war":
case ".7z":
case ".doc":
case ".docx":
case ".xls":
case ".xlsx":
case ".ppt":
case ".pptx":
case ".odt":
case ".ods":
case ".odp":
case ".bin":
case ".dat":
case ".obj":
case ".o":
case ".a":
case ".lib":
case ".wasm":
case ".pyc":
case ".pyo":
return true
default:
break
}
if (fileSize === 0) return false
const fh = await open(filepath, "r")
try {
const sampleSize = Math.min(4096, fileSize)
const bytes = Buffer.alloc(sampleSize)
const result = await fh.read(bytes, 0, sampleSize, 0)
if (result.bytesRead === 0) return false
let nonPrintableCount = 0
for (let i = 0; i < result.bytesRead; i++) {
if (bytes[i] === 0) return true
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
nonPrintableCount++
}
}
// If >30% non-printable characters, consider it binary
return nonPrintableCount / result.bytesRead > 0.3
} finally {
await fh.close()
}
}
+75 -52
View File
@@ -3,10 +3,10 @@ import { pathToFileURL } from "url"
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { EffectLogger } from "@/effect"
import { Ripgrep } from "../file/ripgrep"
import { Skill } from "../skill"
import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt"
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
@@ -18,59 +18,82 @@ export const SkillTool = Tool.define(
const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((item) => item.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
return () =>
Effect.gen(function* () {
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
description,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((item) => item.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie),
}
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie),
}
})
}),
)
-5
View File
@@ -1,5 +0,0 @@
Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
The skill name must match one of the skills listed in your system prompt.
+4 -2
View File
@@ -4,7 +4,6 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import * as Tool from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { isImageAttachment } from "@/util/media"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -105,7 +104,10 @@ export const WebFetchTool = Tool.define(
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
const title = `${params.url} (${contentType})`
if (isImageAttachment(mime)) {
// Check if response is an image
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
if (isImage) {
const base64Content = Buffer.from(arrayBuffer).toString("base64")
return {
title,
+11 -167
View File
@@ -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
@@ -77,16 +40,9 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
// Declarations fall through to body(), not encoded(). User-level
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
// nodes, where we do apply the transform.
//
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
// on the inner Zod rather than a transform wrapper — so optional ASTs whose
// encoding resolves a default from Option.none() route through body()/opt().
const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
const hasTransform = ast.encoding?.length && ast._tag !== "Declaration"
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 out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast)
const described = desc ? out.describe(desc) : out
@@ -119,12 +75,9 @@ function decode(transformation: SchemaAST.Link["transformation"], value: unknown
return Option.getOrElse(exit.value, () => value)
}
// Flatten FilterGroups and any nested variants into a linear list of Filters.
// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are
// translated into native Zod methods so their JSON Schema output includes
// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …).
// Anything else falls back to a single .superRefine layer — runtime-only,
// emits no JSON Schema constraint.
// Flatten FilterGroups and any nested variants into a linear list of Filters
// so we can run all of them inside a single Zod .superRefine wrapper instead
// of stacking N wrapper layers (one per check).
function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
const filters: SchemaAST.Filter<unknown>[] = []
const collect = (c: SchemaAST.Check<unknown>) => {
@@ -132,19 +85,8 @@ function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST
else filters.push(c)
}
checks.forEach(collect)
const unhandled: SchemaAST.Filter<unknown>[] = []
const translated = filters.reduce<z.ZodTypeAny>((acc, filter) => {
const next = translateFilter(acc, filter)
if (next) return next
unhandled.push(filter)
return acc
}, out)
if (unhandled.length === 0) return translated
return translated.superRefine((value, ctx) => {
for (const filter of unhandled) {
return out.superRefine((value, ctx) => {
for (const filter of filters) {
const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
if (!issue) continue
const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
@@ -153,71 +95,6 @@ function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST
})
}
// Translate a well-known Effect Schema filter into a native Zod method call on
// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every
// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at
// construction time. Returns `undefined` for unrecognised filters so the
// caller can fall back to the generic .superRefine path.
function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter<unknown>): z.ZodTypeAny | undefined {
const meta = (filter.annotations as { meta?: Record<string, unknown> } | undefined)?.meta
if (!meta || typeof meta._tag !== "string") return undefined
switch (meta._tag) {
case "isInt":
return call(out, "int")
case "isFinite":
return call(out, "finite")
case "isGreaterThan":
return call(out, "gt", meta.exclusiveMinimum)
case "isGreaterThanOrEqualTo":
return call(out, "gte", meta.minimum)
case "isLessThan":
return call(out, "lt", meta.exclusiveMaximum)
case "isLessThanOrEqualTo":
return call(out, "lte", meta.maximum)
case "isBetween": {
const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum)
if (!lo) return undefined
return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum)
}
case "isMultipleOf":
return call(out, "multipleOf", meta.divisor)
case "isMinLength":
return call(out, "min", meta.minLength)
case "isMaxLength":
return call(out, "max", meta.maxLength)
case "isLengthBetween": {
const lo = call(out, "min", meta.minimum)
if (!lo) return undefined
return call(lo, "max", meta.maximum)
}
case "isPattern":
return call(out, "regex", meta.regExp)
case "isStartsWith":
return call(out, "startsWith", meta.startsWith)
case "isEndsWith":
return call(out, "endsWith", meta.endsWith)
case "isIncludes":
return call(out, "includes", meta.includes)
case "isUUID":
return call(out, "uuid")
case "isULID":
return call(out, "ulid")
case "isBase64":
return call(out, "base64")
case "isBase64Url":
return call(out, "base64url")
}
return undefined
}
// Invoke a named Zod method on `target` if it exists, otherwise return
// undefined so the caller can fall back. Using this helper instead of a
// typed cast keeps `translateFilter` free of per-case narrowing noise.
function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined {
const fn = (target as unknown as Record<string, ((...a: unknown[]) => z.ZodTypeAny) | undefined>)[method]
return typeof fn === "function" ? fn.apply(target, args) : undefined
}
function issueMessage(issue: any): string | undefined {
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
if (typeof issue?.message === "string") return issue.message
@@ -261,43 +138,10 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
if (ast._tag !== "Union") return fail(ast)
const items = ast.types.filter((item) => item._tag !== "Undefined")
const inner =
items.length === 1
? walk(items[0])
: items.length > 1
? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
: z.undefined()
// Schema.withDecodingDefault attaches an encoding `Link` whose transformation
// decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
// it to extract the default and emit `.default(...)` instead of `.optional()`.
const fallback = extractDefault(ast)
if (fallback !== undefined) return inner.default(fallback.value)
return inner.optional()
}
type DecodeLink = {
readonly transformation: {
readonly decode: {
readonly run: (
input: Option.Option<unknown>,
options: SchemaAST.ParseOptions,
) => Effect.Effect<Option.Option<unknown>, unknown>
}
}
}
function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).encoding
if (!encoding?.length) return undefined
// Walk the chain of encoding Links in order; the first Getter that produces
// a value from Option.none wins. withDecodingDefault always puts its
// defaulting Link adjacent to the optional Union.
for (const link of encoding) {
const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {}))
if (probe._tag !== "Success") continue
if (Option.isSome(probe.value)) return { value: probe.value.value }
}
return undefined
if (items.length === 1) return walk(items[0]).optional()
if (items.length > 1)
return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
return z.undefined().optional()
}
function union(ast: SchemaAST.Union): z.ZodTypeAny {
-26
View File
@@ -1,26 +0,0 @@
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
export function isPdfAttachment(mime: string) {
return mime === "application/pdf"
}
export function isMedia(mime: string) {
return mime.startsWith("image/") || isPdfAttachment(mime)
}
export function isImageAttachment(mime: string) {
return mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
}
export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp"
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) {
return "image/webp"
}
return fallback
}
@@ -1,261 +0,0 @@
import { produce, type WritableDraft } from "immer"
import { SessionEvent } from "./session-event"
import { SessionEntry } from "./session-entry"
export type MemoryState = {
entries: SessionEntry.Entry[]
pending: SessionEntry.Entry[]
}
export interface Adapter<Result> {
readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined
readonly updateAssistant: (assistant: SessionEntry.Assistant) => void
readonly appendEntry: (entry: SessionEntry.Entry) => void
readonly appendPending: (entry: SessionEntry.Entry) => void
readonly finish: () => Result
}
export function memory(state: MemoryState): Adapter<MemoryState> {
const activeAssistantIndex = () =>
state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
return {
getCurrentAssistant() {
const index = activeAssistantIndex()
if (index < 0) return
const assistant = state.entries[index]
return assistant?.type === "assistant" ? assistant : undefined
},
updateAssistant(assistant) {
const index = activeAssistantIndex()
if (index < 0) return
const current = state.entries[index]
if (current?.type !== "assistant") return
state.entries[index] = assistant
},
appendEntry(entry) {
state.entries.push(entry)
},
appendPending(entry) {
state.pending.push(entry)
},
finish() {
return state
},
}
}
export function stepWith<Result>(adapter: Adapter<Result>, event: SessionEvent.Event): Result {
const currentAssistant = adapter.getCurrentAssistant()
type DraftAssistant = WritableDraft<SessionEntry.Assistant>
type DraftTool = WritableDraft<SessionEntry.AssistantTool>
type DraftText = WritableDraft<SessionEntry.AssistantText>
type DraftReasoning = WritableDraft<SessionEntry.AssistantReasoning>
const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
assistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID),
)
const latestText = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text")
const latestReasoning = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning")
SessionEvent.Event.match(event, {
prompt: (event) => {
const entry = SessionEntry.User.fromEvent(event)
if (currentAssistant) {
adapter.appendPending(entry)
return
}
adapter.appendEntry(entry)
},
synthetic: (event) => {
adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event))
},
"step.started": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.timestamp
}),
)
}
adapter.appendEntry(SessionEntry.Assistant.fromEvent(event))
},
"step.ended": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.timestamp
draft.cost = event.cost
draft.tokens = event.tokens
}),
)
}
},
"text.started": () => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push({
type: "text",
text: "",
})
}),
)
}
},
"text.delta": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestText(draft)
if (match) match.text += event.delta
}),
)
}
},
"text.ended": () => {},
"tool.input.started": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push({
type: "tool",
callID: event.callID,
name: event.name,
time: {
created: event.timestamp,
},
state: {
status: "pending",
input: "",
},
})
}),
)
}
},
"tool.input.delta": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.callID)
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
if (match && match.state.status === "pending") match.state.input += event.delta
}),
)
}
},
"tool.input.ended": () => {},
"tool.called": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.callID)
if (match) {
match.time.ran = event.timestamp
match.state = {
status: "running",
input: event.input,
}
}
}),
)
}
},
"tool.success": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.callID)
if (match && match.state.status === "running") {
match.state = {
status: "completed",
input: match.state.input,
output: event.output ?? "",
title: event.title,
metadata: event.metadata ?? {},
attachments: [...(event.attachments ?? [])],
}
}
}),
)
}
},
"tool.error": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.callID)
if (match && match.state.status === "running") {
match.state = {
status: "error",
error: event.error,
input: match.state.input,
metadata: event.metadata ?? {},
}
}
}),
)
}
},
"reasoning.started": () => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push({
type: "reasoning",
text: "",
})
}),
)
}
},
"reasoning.delta": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestReasoning(draft)
if (match) match.text += event.delta
}),
)
}
},
"reasoning.ended": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestReasoning(draft)
if (match) match.text = event.text
}),
)
}
},
retried: (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)]
}),
)
}
},
compacted: (event) => {
adapter.appendEntry(SessionEntry.Compaction.fromEvent(event))
},
})
return adapter.finish()
}
export function step(old: MemoryState, event: SessionEvent.Event): MemoryState {
return produce(old, (draft) => {
stepWith(memory(draft as MemoryState), event)
})
}
export * as SessionEntryStepper from "./session-entry-stepper"
+149 -49
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { SessionEvent } from "./session-event"
import { castDraft, produce } from "immer"
export const ID = SessionEvent.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -39,14 +40,7 @@ export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic"
...SessionEvent.Synthetic.fields,
...Base,
type: Schema.Literal("synthetic"),
}) {
static fromEvent(event: SessionEvent.Synthetic) {
return new Synthetic({
...event,
time: { created: event.timestamp },
})
}
}
}) {}
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
status: Schema.Literal("pending"),
@@ -104,24 +98,6 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
text: Schema.String,
}) {}
export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
attempt: Schema.Number,
error: SessionEvent.RetryError,
time: Schema.Struct({
created: Schema.DateTimeUtc,
}),
}) {
static fromEvent(event: SessionEvent.Retried) {
return new AssistantRetry({
attempt: event.attempt,
error: event.error,
time: {
created: event.timestamp,
},
})
}
}
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
Schema.toTaggedUnion("type"),
)
@@ -131,7 +107,6 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
...Base,
type: Schema.Literal("assistant"),
content: AssistantContent.pipe(Schema.Array),
retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
cost: Schema.Number.pipe(Schema.optional),
tokens: Schema.Struct({
input: Schema.Number,
@@ -147,33 +122,13 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
created: Schema.DateTimeUtc,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
}) {
static fromEvent(event: SessionEvent.Step.Started) {
return new Assistant({
id: event.id,
type: "assistant",
time: {
created: event.timestamp,
},
content: [],
retries: [],
})
}
}
}) {}
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
...SessionEvent.Compacted.fields,
type: Schema.Literal("compaction"),
...Base,
}) {
static fromEvent(event: SessionEvent.Compacted) {
return new Compaction({
...event,
type: "compaction",
time: { created: event.timestamp },
})
}
}
}) {}
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type"))
@@ -181,6 +136,151 @@ export type Entry = Schema.Schema.Type<typeof Entry>
export type Type = Entry["type"]
export type History = {
entries: Entry[]
pending: Entry[]
}
export function step(old: History, event: SessionEvent.Event): History {
return produce(old, (draft) => {
const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
type DraftContent = NonNullable<typeof pendingAssistant>["content"][number]
type DraftTool = Extract<DraftContent, { type: "tool" }>
const latestTool = (callID?: string) =>
pendingAssistant?.content.findLast(
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID),
)
const latestText = () => pendingAssistant?.content.findLast((item) => item.type === "text")
const latestReasoning = () => pendingAssistant?.content.findLast((item) => item.type === "reasoning")
SessionEvent.Event.match(event, {
prompt: (event) => {
const entry = User.fromEvent(event)
if (pendingAssistant) {
draft.pending.push(castDraft(entry))
return
}
draft.entries.push(castDraft(entry))
},
synthetic: (event) => {
draft.entries.push(new Synthetic({ ...event, time: { created: event.timestamp } }))
},
"step.started": (event) => {
if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
draft.entries.push({
id: event.id,
type: "assistant",
time: {
created: event.timestamp,
},
content: [],
})
},
"step.ended": (event) => {
if (!pendingAssistant) return
pendingAssistant.time.completed = event.timestamp
pendingAssistant.cost = event.cost
pendingAssistant.tokens = event.tokens
},
"text.started": () => {
if (!pendingAssistant) return
pendingAssistant.content.push({
type: "text",
text: "",
})
},
"text.delta": (event) => {
if (!pendingAssistant) return
const match = latestText()
if (match) match.text += event.delta
},
"text.ended": () => {},
"tool.input.started": (event) => {
if (!pendingAssistant) return
pendingAssistant.content.push({
type: "tool",
callID: event.callID,
name: event.name,
time: {
created: event.timestamp,
},
state: {
status: "pending",
input: "",
},
})
},
"tool.input.delta": (event) => {
if (!pendingAssistant) return
const match = latestTool(event.callID)
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
if (match) match.state.input += event.delta
},
"tool.input.ended": () => {},
"tool.called": (event) => {
if (!pendingAssistant) return
const match = latestTool(event.callID)
if (match) {
match.time.ran = event.timestamp
match.state = {
status: "running",
input: event.input,
}
}
},
"tool.success": (event) => {
if (!pendingAssistant) return
const match = latestTool(event.callID)
if (match && match.state.status === "running") {
match.state = {
status: "completed",
input: match.state.input,
output: event.output ?? "",
title: event.title,
metadata: event.metadata ?? {},
attachments: [...(event.attachments ?? [])],
}
}
},
"tool.error": (event) => {
if (!pendingAssistant) return
const match = latestTool(event.callID)
if (match && match.state.status === "running") {
match.state = {
status: "error",
error: event.error,
input: match.state.input,
metadata: event.metadata ?? {},
}
}
},
"reasoning.started": () => {
if (!pendingAssistant) return
pendingAssistant.content.push({
type: "reasoning",
text: "",
})
},
"reasoning.delta": (event) => {
if (!pendingAssistant) return
const match = latestReasoning()
if (match) match.text += event.delta
},
"reasoning.ended": (event) => {
if (!pendingAssistant) return
const match = latestReasoning()
if (match) match.text = event.text
},
retried: () => {},
compacted: (event) => {
draft.entries.push(new Compaction({ ...event, type: "compaction", time: { created: event.timestamp } }))
},
})
})
}
/*
export interface Interface {
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
+2 -13
View File
@@ -53,15 +53,6 @@ export namespace SessionEvent {
source: Source.pipe(Schema.optional),
}) {}
export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
message: Schema.String,
statusCode: Schema.Number.pipe(Schema.optional),
isRetryable: Schema.Boolean,
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
responseBody: Schema.String.pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
}) {}
export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
...Base,
type: Schema.Literal("prompt"),
@@ -395,16 +386,14 @@ export namespace SessionEvent {
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
...Base,
type: Schema.Literal("retried"),
attempt: Schema.Number,
error: RetryError,
error: Schema.String,
}) {
static create(input: BaseInput & { attempt: number; error: RetryError }) {
static create(input: BaseInput & { error: string }) {
return new Retried({
id: input.id ?? ID.create(),
type: "retried",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
attempt: input.attempt,
error: input.error,
})
}
+11 -13
View File
@@ -365,14 +365,13 @@ export const layer: Layer.Layer<
}
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = yield* canonical(input.directory)
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.code !== 0) {
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
}
@@ -390,9 +389,9 @@ export const layer: Layer.Layer<
}
yield* stopFsmonitor(entry.path)
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: ctx.worktree })
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
if (removed.code !== 0) {
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (next.code !== 0) {
throw new RemoveFailedError({
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
@@ -409,7 +408,7 @@ export const layer: Layer.Layer<
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree })
const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
if (deleted.code !== 0) {
throw new RemoveFailedError({
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
@@ -499,18 +498,17 @@ export const layer: Layer.Layer<
})
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = yield* canonical(input.directory)
const primary = yield* canonical(ctx.worktree)
const primary = yield* canonical(Instance.worktree)
if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.code !== 0) {
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
}
@@ -522,7 +520,7 @@ export const layer: Layer.Layer<
const worktreePath = entry.path
const base = yield* gitSvc.defaultBranch(ctx.worktree)
const base = yield* gitSvc.defaultBranch(Instance.worktree)
if (!base) {
throw new ResetFailedError({ message: "Default branch not found" })
}
@@ -533,7 +531,7 @@ export const layer: Layer.Layer<
const branch = base.ref.slice(sep + 1)
yield* gitExpect(
["fetch", remote, branch],
{ cwd: ctx.worktree },
{ cwd: Instance.worktree },
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
)
}
@@ -576,7 +574,7 @@ export const layer: Layer.Layer<
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
}
yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe(
yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
Effect.forkIn(scope),
)
@@ -1,46 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { resource } from "../../src/effect/observability"
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
const opencodeClient = process.env.OPENCODE_CLIENT
afterEach(() => {
if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
if (opencodeClient === undefined) delete process.env.OPENCODE_CLIENT
else process.env.OPENCODE_CLIENT = opencodeClient
})
describe("resource", () => {
test("parses and decodes OTEL resource attributes", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES =
"service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
expect(resource().attributes).toMatchObject({
"service.namespace": "anomalyco",
team: "platform,observability",
label: "hello=world",
"key/name": "value here",
})
})
test("drops OTEL resource attributes when any entry is invalid", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
expect(resource().attributes["service.namespace"]).toBeUndefined()
expect(resource().attributes["opencode.client"]).toBeDefined()
})
test("keeps built-in attributes when env values conflict", () => {
process.env.OPENCODE_CLIENT = "cli"
process.env.OTEL_RESOURCE_ATTRIBUTES =
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
expect(resource().attributes).toMatchObject({
"opencode.client": "cli",
"service.namespace": "anomalyco",
})
expect(resource().attributes["service.instance.id"]).not.toBe("override")
})
})
@@ -31,7 +31,6 @@ describe("LSPClient interop", () => {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
}),
})
@@ -56,7 +55,6 @@ describe("LSPClient interop", () => {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
}),
})
@@ -81,7 +79,6 @@ describe("LSPClient interop", () => {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
}),
})
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import { APICallError } from "ai"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderTransform } from "../../src/provider"
import type { Provider } from "../../src/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
@@ -360,89 +359,6 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("preserves jpeg tool-result media for anthropic models", async () => {
const anthropicModel: Provider.Model = {
...model,
id: ModelID.make("anthropic/claude-opus-4-7"),
providerID: ProviderID.make("anthropic"),
api: {
id: "claude-opus-4-7-20250805",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
capabilities: {
...model.capabilities,
attachment: true,
input: {
...model.capabilities.input,
image: true,
pdf: true,
},
},
}
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]).toString(
"base64",
)
const userID = "m-user-anthropic"
const assistantID = "m-assistant-anthropic"
const input: MessageV2.WithParts[] = [
{
info: userInfo(userID),
parts: [
{
...basePart(userID, "u1-anthropic"),
type: "text",
text: "run tool",
},
] as MessageV2.Part[],
},
{
info: assistantInfo(assistantID, userID),
parts: [
{
...basePart(assistantID, "a1-anthropic"),
type: "tool",
callID: "call-anthropic-1",
tool: "read",
state: {
status: "completed",
input: { filePath: "/tmp/rails-demo.png" },
output: "Image read successfully",
title: "Read",
metadata: {},
time: { start: 0, end: 1 },
attachments: [
{
...basePart(assistantID, "file-anthropic-1"),
type: "file",
mime: "image/jpeg",
filename: "rails-demo.png",
url: `data:image/jpeg;base64,${jpeg}`,
},
],
},
},
] as MessageV2.Part[],
},
]
const result = ProviderTransform.message(await MessageV2.toModelMessages(input, anthropicModel), anthropicModel, {})
expect(result).toHaveLength(3)
expect(result[2].role).toBe("tool")
expect(result[2].content[0]).toMatchObject({
type: "tool-result",
toolCallId: "call-anthropic-1",
toolName: "read",
output: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/jpeg", data: jpeg },
],
},
})
})
test("omits provider metadata when assistant model differs", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
@@ -1,916 +0,0 @@
import { describe, expect, test } from "bun:test"
import * as DateTime from "effect/DateTime"
import * as FastCheck from "effect/testing/FastCheck"
import { SessionEntry } from "../../src/v2/session-entry"
import { SessionEntryStepper } from "../../src/v2/session-entry-stepper"
import { SessionEvent } from "../../src/v2/session-event"
const time = (n: number) => DateTime.makeUnsafe(n)
const word = FastCheck.string({ minLength: 1, maxLength: 8 })
const text = FastCheck.string({ maxLength: 16 })
const texts = FastCheck.array(text, { maxLength: 8 })
const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
const files = FastCheck.array(
word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
{ maxLength: 2 },
)
function maybe<A>(arb: FastCheck.Arbitrary<A>) {
return FastCheck.oneof(FastCheck.constant(undefined), arb)
}
function assistant() {
return new SessionEntry.Assistant({
id: SessionEvent.ID.create(),
type: "assistant",
time: { created: time(0) },
content: [],
retries: [],
})
}
function retryError(message: string) {
return new SessionEvent.RetryError({
message,
isRetryable: true,
})
}
function retry(attempt: number, message: string, created: number) {
return new SessionEntry.AssistantRetry({
attempt,
error: retryError(message),
time: {
created: time(created),
},
})
}
function memoryState() {
const state: SessionEntryStepper.MemoryState = {
entries: [],
pending: [],
}
return state
}
function active() {
const state: SessionEntryStepper.MemoryState = {
entries: [assistant()],
pending: [],
}
return state
}
function run(events: SessionEvent.Event[], state = memoryState()) {
return events.reduce<SessionEntryStepper.MemoryState>((state, event) => SessionEntryStepper.step(state, event), state)
}
function last(state: SessionEntryStepper.MemoryState) {
const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
expect(entry?.type).toBe("assistant")
return entry?.type === "assistant" ? entry : undefined
}
function texts_of(state: SessionEntryStepper.MemoryState) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
}
function reasons(state: SessionEntryStepper.MemoryState) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
}
function tools(state: SessionEntryStepper.MemoryState) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
}
function tool(state: SessionEntryStepper.MemoryState, callID: string) {
return tools(state).find((x) => x.callID === callID)
}
function retriesOf(state: SessionEntryStepper.MemoryState) {
const entry = last(state)
if (!entry) return []
return entry.retries ?? []
}
function adapterStore() {
return {
committed: [] as SessionEntry.Entry[],
deferred: [] as SessionEntry.Entry[],
}
}
function adapterFor(store: ReturnType<typeof adapterStore>): SessionEntryStepper.Adapter<typeof store> {
const activeAssistantIndex = () =>
store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
const getCurrentAssistant = () => {
const index = activeAssistantIndex()
if (index < 0) return
const assistant = store.committed[index]
return assistant?.type === "assistant" ? assistant : undefined
}
return {
getCurrentAssistant,
updateAssistant(assistant) {
const index = activeAssistantIndex()
if (index < 0) return
const current = store.committed[index]
if (current?.type !== "assistant") return
store.committed[index] = assistant
},
appendEntry(entry) {
store.committed.push(entry)
},
appendPending(entry) {
store.deferred.push(entry)
},
finish() {
return store
},
}
}
describe("session-entry-stepper", () => {
describe("stepWith", () => {
test("reduces through a custom adapter", () => {
const store = adapterStore()
store.committed.push(assistant())
SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) }))
SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) }))
SessionEntryStepper.stepWith(
adapterFor(store),
SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }),
)
SessionEntryStepper.stepWith(
adapterFor(store),
SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }),
)
SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) }))
SessionEntryStepper.stepWith(
adapterFor(store),
SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }),
)
SessionEntryStepper.stepWith(
adapterFor(store),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(7),
}),
)
expect(store.deferred).toHaveLength(1)
expect(store.deferred[0]?.type).toBe("user")
expect(store.committed).toHaveLength(1)
expect(store.committed[0]?.type).toBe("assistant")
if (store.committed[0]?.type !== "assistant") return
expect(store.committed[0].content).toEqual([
{ type: "reasoning", text: "thought" },
{ type: "text", text: "world" },
])
expect(store.committed[0].time.completed).toEqual(time(7))
})
test("aggregates retry events onto the current assistant", () => {
const store = adapterStore()
store.committed.push(assistant())
SessionEntryStepper.stepWith(
adapterFor(store),
SessionEvent.Retried.create({
attempt: 1,
error: retryError("rate limited"),
timestamp: time(1),
}),
)
SessionEntryStepper.stepWith(
adapterFor(store),
SessionEvent.Retried.create({
attempt: 2,
error: retryError("provider overloaded"),
timestamp: time(2),
}),
)
expect(store.committed[0]?.type).toBe("assistant")
if (store.committed[0]?.type !== "assistant") return
expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)])
})
})
describe("memory", () => {
test("tracks and replaces the current assistant", () => {
const state = active()
const adapter = SessionEntryStepper.memory(state)
const current = adapter.getCurrentAssistant()
expect(current?.type).toBe("assistant")
if (!current) return
adapter.updateAssistant(
new SessionEntry.Assistant({
...current,
content: [new SessionEntry.AssistantText({ type: "text", text: "done" })],
time: {
...current.time,
completed: time(1),
},
}),
)
expect(adapter.getCurrentAssistant()).toBeUndefined()
expect(state.entries[0]?.type).toBe("assistant")
if (state.entries[0]?.type !== "assistant") return
expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }])
expect(state.entries[0].time.completed).toEqual(time(1))
})
test("appends committed and pending entries", () => {
const state = memoryState()
const adapter = SessionEntryStepper.memory(state)
const committed = SessionEntry.User.fromEvent(
SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }),
)
const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) }))
adapter.appendEntry(committed)
adapter.appendPending(pending)
expect(state.entries).toEqual([committed])
expect(state.pending).toEqual([pending])
})
test("stepWith through memory records reasoning", () => {
const state = active()
SessionEntryStepper.stepWith(
SessionEntryStepper.memory(state),
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
)
SessionEntryStepper.stepWith(
SessionEntryStepper.memory(state),
SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }),
)
SessionEntryStepper.stepWith(
SessionEntryStepper.memory(state),
SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }),
)
expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }])
})
test("stepWith through memory records retries", () => {
const state = active()
SessionEntryStepper.stepWith(
SessionEntryStepper.memory(state),
SessionEvent.Retried.create({
attempt: 1,
error: retryError("rate limited"),
timestamp: time(1),
}),
)
expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)])
})
})
describe("step", () => {
describe("seeded pending assistant", () => {
test("stores prompts in entries when no assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntryStepper.step(
memoryState(),
SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
)
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("user")
if (next.entries[0]?.type !== "user") return
expect(next.entries[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("stores prompts in pending when an assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntryStepper.step(
active(),
SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
)
expect(next.pending).toHaveLength(1)
expect(next.pending[0]?.type).toBe("user")
if (next.pending[0]?.type !== "user") return
expect(next.pending[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("accumulates text deltas on the latest text part", () => {
FastCheck.assert(
FastCheck.property(texts, (parts) => {
const next = parts.reduce(
(state, part, i) =>
SessionEntryStepper.step(
state,
SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }),
),
SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
)
expect(texts_of(next)).toEqual([
{
type: "text",
text: parts.join(""),
},
])
}),
{ numRuns: 100 },
)
})
test("routes later text deltas to the latest text segment", () => {
FastCheck.assert(
FastCheck.property(texts, texts, (a, b) => {
const next = run(
[
SessionEvent.Text.Started.create({ timestamp: time(1) }),
...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
],
active(),
)
expect(texts_of(next)).toEqual([
{ type: "text", text: a.join("") },
{ type: "text", text: b.join("") },
])
}),
{ numRuns: 50 },
)
})
test("reasoning.ended replaces buffered reasoning text", () => {
FastCheck.assert(
FastCheck.property(texts, text, (parts, end) => {
const next = run(
[
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
],
active(),
)
expect(reasons(next)).toEqual([
{
type: "reasoning",
text: end,
},
])
}),
{ numRuns: 100 },
)
})
test("tool.success completes the latest running tool", () => {
FastCheck.assert(
FastCheck.property(
word,
word,
dict,
maybe(text),
maybe(dict),
maybe(files),
texts,
(callID, title, input, output, metadata, attachments, parts) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
...parts.map((x, i) =>
SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(parts.length + 2),
}),
SessionEvent.Tool.Success.create({
callID,
title,
output,
metadata,
attachments,
provider: { executed: true },
timestamp: time(parts.length + 3),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state.status).toBe("completed")
if (match?.state.status !== "completed") return
expect(match.time.ran).toEqual(time(parts.length + 2))
expect(match.state.input).toEqual(input)
expect(match.state.output).toBe(output ?? "")
expect(match.state.title).toBe(title)
expect(match.state.metadata).toEqual(metadata ?? {})
expect(match.state.attachments).toEqual(attachments ?? [])
},
),
{ numRuns: 50 },
)
})
test("tool.error completes the latest running tool with an error", () => {
FastCheck.assert(
FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(2),
}),
SessionEvent.Tool.Error.create({
callID,
error,
metadata,
provider: { executed: true },
timestamp: time(3),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state.status).toBe("error")
if (match?.state.status !== "error") return
expect(match.time.ran).toEqual(time(2))
expect(match.state.input).toEqual(input)
expect(match.state.error).toBe(error)
expect(match.state.metadata).toEqual(metadata ?? {})
}),
{ numRuns: 50 },
)
})
test("tool.success is ignored before tool.called promotes the tool to running", () => {
FastCheck.assert(
FastCheck.property(word, word, (callID, title) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Success.create({
callID,
title,
provider: { executed: true },
timestamp: time(2),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state).toEqual({
status: "pending",
input: "",
})
}),
{ numRuns: 50 },
)
})
test("step.ended copies completion fields onto the pending assistant", () => {
FastCheck.assert(
FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
const event = SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(n),
})
const next = SessionEntryStepper.step(active(), event)
const entry = last(next)
expect(entry).toBeDefined()
if (!entry) return
expect(entry.time.completed).toEqual(event.timestamp)
expect(entry.cost).toBe(event.cost)
expect(entry.tokens).toEqual(event.tokens)
}),
{ numRuns: 50 },
)
})
})
describe("known reducer gaps", () => {
test("prompt appends immutably when no assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const old = memoryState()
const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(old).not.toBe(next)
expect(old.entries).toHaveLength(0)
expect(next.entries).toHaveLength(1)
}),
{ numRuns: 50 },
)
})
test("prompt appends immutably when an assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const old = active()
const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(old).not.toBe(next)
expect(old.pending).toHaveLength(0)
expect(next.pending).toHaveLength(1)
}),
{ numRuns: 50 },
)
})
test("step.started creates an assistant consumed by follow-up events", () => {
FastCheck.assert(
FastCheck.property(texts, (parts) => {
const next = run([
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Text.Started.create({ timestamp: time(2) }),
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(parts.length + 3),
}),
])
const entry = last(next)
expect(entry).toBeDefined()
if (!entry) return
expect(entry.content).toEqual([
{
type: "text",
text: parts.join(""),
},
])
expect(entry.time.completed).toEqual(time(parts.length + 3))
}),
{ numRuns: 100 },
)
})
test("replays prompt -> step -> text -> step.ended", () => {
FastCheck.assert(
FastCheck.property(word, texts, (body, parts) => {
const next = run([
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Text.Started.create({ timestamp: time(2) }),
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(parts.length + 3),
}),
])
expect(next.entries).toHaveLength(2)
expect(next.entries[0]?.type).toBe("user")
expect(next.entries[1]?.type).toBe("assistant")
if (next.entries[1]?.type !== "assistant") return
expect(next.entries[1].content).toEqual([
{
type: "text",
text: parts.join(""),
},
])
expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
}),
{ numRuns: 50 },
)
})
test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
FastCheck.assert(
FastCheck.property(
word,
texts,
text,
dict,
word,
maybe(text),
maybe(dict),
maybe(files),
(body, reason, end, input, title, output, metadata, attachments) => {
const callID = "call"
const next = run([
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(reason.length + 5),
}),
SessionEvent.Tool.Success.create({
callID,
title,
output,
metadata,
attachments,
provider: { executed: true },
timestamp: time(reason.length + 6),
}),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(reason.length + 7),
}),
])
expect(next.entries.at(-1)?.type).toBe("assistant")
const entry = next.entries.at(-1)
if (entry?.type !== "assistant") return
expect(entry.content).toHaveLength(2)
expect(entry.content[0]).toEqual({
type: "reasoning",
text: end,
})
expect(entry.content[1]?.type).toBe("tool")
if (entry.content[1]?.type !== "tool") return
expect(entry.content[1].state.status).toBe("completed")
expect(entry.time.completed).toEqual(time(reason.length + 7))
},
),
{ numRuns: 50 },
)
})
test("starting a new step completes the old assistant and appends a new active assistant", () => {
const next = run(
[
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
],
active(),
)
expect(next.entries).toHaveLength(2)
expect(next.entries[0]?.type).toBe("assistant")
expect(next.entries[1]?.type).toBe("assistant")
if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
expect(next.entries[0].time.completed).toEqual(time(1))
expect(next.entries[1].time.created).toEqual(time(1))
expect(next.entries[1].time.completed).toBeUndefined()
})
test("handles sequential tools independently", () => {
FastCheck.assert(
FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
input: a,
provider: { executed: true },
timestamp: time(2),
}),
SessionEvent.Tool.Success.create({
callID: "a",
title,
output: "done",
provider: { executed: true },
timestamp: time(3),
}),
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "b",
tool: "bash",
input: b,
provider: { executed: true },
timestamp: time(5),
}),
SessionEvent.Tool.Error.create({
callID: "b",
error,
provider: { executed: true },
timestamp: time(6),
}),
],
active(),
)
const first = tool(next, "a")
const second = tool(next, "b")
expect(first?.state.status).toBe("completed")
if (first?.state.status !== "completed") return
expect(first.state.input).toEqual(a)
expect(first.state.output).toBe("done")
expect(first.state.title).toBe(title)
expect(second?.state.status).toBe("error")
if (second?.state.status !== "error") return
expect(second.state.input).toEqual(b)
expect(second.state.error).toBe(error)
}),
{ numRuns: 50 },
)
})
test("routes tool events by callID when tool streams interleave", () => {
FastCheck.assert(
FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
input: a,
provider: { executed: true },
timestamp: time(5),
}),
SessionEvent.Tool.Called.create({
callID: "b",
tool: "grep",
input: b,
provider: { executed: true },
timestamp: time(6),
}),
SessionEvent.Tool.Success.create({
callID: "a",
title: titleA,
output: "done-a",
provider: { executed: true },
timestamp: time(7),
}),
SessionEvent.Tool.Success.create({
callID: "b",
title: titleB,
output: "done-b",
provider: { executed: true },
timestamp: time(8),
}),
],
active(),
)
const first = tool(next, "a")
const second = tool(next, "b")
expect(first?.state.status).toBe("completed")
expect(second?.state.status).toBe("completed")
if (first?.state.status !== "completed" || second?.state.status !== "completed") return
expect(first.state.input).toEqual(a)
expect(second.state.input).toEqual(b)
expect(first.state.title).toBe(titleA)
expect(second.state.title).toBe(titleB)
}),
{ numRuns: 50 },
)
})
test("records synthetic events", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntryStepper.step(
memoryState(),
SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }),
)
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("synthetic")
if (next.entries[0]?.type !== "synthetic") return
expect(next.entries[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("records compaction events", () => {
FastCheck.assert(
FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
const next = SessionEntryStepper.step(
memoryState(),
SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
)
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("compaction")
if (next.entries[0]?.type !== "compaction") return
expect(next.entries[0].auto).toBe(auto)
expect(next.entries[0].overflow).toBe(overflow)
}),
{ numRuns: 50 },
)
})
})
})
})
@@ -0,0 +1,681 @@
import { describe, expect, test } from "bun:test"
import * as DateTime from "effect/DateTime"
import * as FastCheck from "effect/testing/FastCheck"
import { SessionEntry } from "../../src/v2/session-entry"
import { SessionEvent } from "../../src/v2/session-event"
const time = (n: number) => DateTime.makeUnsafe(n)
const word = FastCheck.string({ minLength: 1, maxLength: 8 })
const text = FastCheck.string({ maxLength: 16 })
const texts = FastCheck.array(text, { maxLength: 8 })
const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
const files = FastCheck.array(
word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
{ maxLength: 2 },
)
function maybe<A>(arb: FastCheck.Arbitrary<A>) {
return FastCheck.oneof(FastCheck.constant(undefined), arb)
}
function assistant() {
return new SessionEntry.Assistant({
id: SessionEvent.ID.create(),
type: "assistant",
time: { created: time(0) },
content: [],
})
}
function history() {
const state: SessionEntry.History = {
entries: [],
pending: [],
}
return state
}
function active() {
const state: SessionEntry.History = {
entries: [assistant()],
pending: [],
}
return state
}
function run(events: SessionEvent.Event[], state = history()) {
return events.reduce<SessionEntry.History>((state, event) => SessionEntry.step(state, event), state)
}
function last(state: SessionEntry.History) {
const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
expect(entry?.type).toBe("assistant")
return entry?.type === "assistant" ? entry : undefined
}
function texts_of(state: SessionEntry.History) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
}
function reasons(state: SessionEntry.History) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
}
function tools(state: SessionEntry.History) {
const entry = last(state)
if (!entry) return []
return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
}
function tool(state: SessionEntry.History, callID: string) {
return tools(state).find((x) => x.callID === callID)
}
describe("session-entry step", () => {
describe("seeded pending assistant", () => {
test("stores prompts in entries when no assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(history(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("user")
if (next.entries[0]?.type !== "user") return
expect(next.entries[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("stores prompts in pending when an assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(next.pending).toHaveLength(1)
expect(next.pending[0]?.type).toBe("user")
if (next.pending[0]?.type !== "user") return
expect(next.pending[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("accumulates text deltas on the latest text part", () => {
FastCheck.assert(
FastCheck.property(texts, (parts) => {
const next = parts.reduce(
(state, part, i) =>
SessionEntry.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })),
SessionEntry.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
)
expect(texts_of(next)).toEqual([
{
type: "text",
text: parts.join(""),
},
])
}),
{ numRuns: 100 },
)
})
test("routes later text deltas to the latest text segment", () => {
FastCheck.assert(
FastCheck.property(texts, texts, (a, b) => {
const next = run(
[
SessionEvent.Text.Started.create({ timestamp: time(1) }),
...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
],
active(),
)
expect(texts_of(next)).toEqual([
{ type: "text", text: a.join("") },
{ type: "text", text: b.join("") },
])
}),
{ numRuns: 50 },
)
})
test("reasoning.ended replaces buffered reasoning text", () => {
FastCheck.assert(
FastCheck.property(texts, text, (parts, end) => {
const next = run(
[
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
],
active(),
)
expect(reasons(next)).toEqual([
{
type: "reasoning",
text: end,
},
])
}),
{ numRuns: 100 },
)
})
test("tool.success completes the latest running tool", () => {
FastCheck.assert(
FastCheck.property(
word,
word,
dict,
maybe(text),
maybe(dict),
maybe(files),
texts,
(callID, title, input, output, metadata, attachments, parts) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
...parts.map((x, i) =>
SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(parts.length + 2),
}),
SessionEvent.Tool.Success.create({
callID,
title,
output,
metadata,
attachments,
provider: { executed: true },
timestamp: time(parts.length + 3),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state.status).toBe("completed")
if (match?.state.status !== "completed") return
expect(match.time.ran).toEqual(time(parts.length + 2))
expect(match.state.input).toEqual(input)
expect(match.state.output).toBe(output ?? "")
expect(match.state.title).toBe(title)
expect(match.state.metadata).toEqual(metadata ?? {})
expect(match.state.attachments).toEqual(attachments ?? [])
},
),
{ numRuns: 50 },
)
})
test("tool.error completes the latest running tool with an error", () => {
FastCheck.assert(
FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(2),
}),
SessionEvent.Tool.Error.create({
callID,
error,
metadata,
provider: { executed: true },
timestamp: time(3),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state.status).toBe("error")
if (match?.state.status !== "error") return
expect(match.time.ran).toEqual(time(2))
expect(match.state.input).toEqual(input)
expect(match.state.error).toBe(error)
expect(match.state.metadata).toEqual(metadata ?? {})
}),
{ numRuns: 50 },
)
})
test("tool.success is ignored before tool.called promotes the tool to running", () => {
FastCheck.assert(
FastCheck.property(word, word, (callID, title) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Success.create({
callID,
title,
provider: { executed: true },
timestamp: time(2),
}),
],
active(),
)
const match = tool(next, callID)
expect(match?.state).toEqual({
status: "pending",
input: "",
})
}),
{ numRuns: 50 },
)
})
test("step.ended copies completion fields onto the pending assistant", () => {
FastCheck.assert(
FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
const event = SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(n),
})
const next = SessionEntry.step(active(), event)
const entry = last(next)
expect(entry).toBeDefined()
if (!entry) return
expect(entry.time.completed).toEqual(event.timestamp)
expect(entry.cost).toBe(event.cost)
expect(entry.tokens).toEqual(event.tokens)
}),
{ numRuns: 50 },
)
})
})
describe("known reducer gaps", () => {
test("prompt appends immutably when no assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const old = history()
const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(old).not.toBe(next)
expect(old.entries).toHaveLength(0)
expect(next.entries).toHaveLength(1)
}),
{ numRuns: 50 },
)
})
test("prompt appends immutably when an assistant is pending", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const old = active()
const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
expect(old).not.toBe(next)
expect(old.pending).toHaveLength(0)
expect(next.pending).toHaveLength(1)
}),
{ numRuns: 50 },
)
})
test("step.started creates an assistant consumed by follow-up events", () => {
FastCheck.assert(
FastCheck.property(texts, (parts) => {
const next = run([
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Text.Started.create({ timestamp: time(2) }),
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(parts.length + 3),
}),
])
const entry = last(next)
expect(entry).toBeDefined()
if (!entry) return
expect(entry.content).toEqual([
{
type: "text",
text: parts.join(""),
},
])
expect(entry.time.completed).toEqual(time(parts.length + 3))
}),
{ numRuns: 100 },
)
})
test("replays prompt -> step -> text -> step.ended", () => {
FastCheck.assert(
FastCheck.property(word, texts, (body, parts) => {
const next = run([
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Text.Started.create({ timestamp: time(2) }),
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(parts.length + 3),
}),
])
expect(next.entries).toHaveLength(2)
expect(next.entries[0]?.type).toBe("user")
expect(next.entries[1]?.type).toBe("assistant")
if (next.entries[1]?.type !== "assistant") return
expect(next.entries[1].content).toEqual([
{
type: "text",
text: parts.join(""),
},
])
expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
}),
{ numRuns: 50 },
)
})
test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
FastCheck.assert(
FastCheck.property(
word,
texts,
text,
dict,
word,
maybe(text),
maybe(dict),
maybe(files),
(body, reason, end, input, title, output, metadata, attachments) => {
const callID = "call"
const next = run([
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
SessionEvent.Tool.Called.create({
callID,
tool: "bash",
input,
provider: { executed: true },
timestamp: time(reason.length + 5),
}),
SessionEvent.Tool.Success.create({
callID,
title,
output,
metadata,
attachments,
provider: { executed: true },
timestamp: time(reason.length + 6),
}),
SessionEvent.Step.Ended.create({
reason: "stop",
cost: 1,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: {
read: 4,
write: 5,
},
},
timestamp: time(reason.length + 7),
}),
])
expect(next.entries.at(-1)?.type).toBe("assistant")
const entry = next.entries.at(-1)
if (entry?.type !== "assistant") return
expect(entry.content).toHaveLength(2)
expect(entry.content[0]).toEqual({
type: "reasoning",
text: end,
})
expect(entry.content[1]?.type).toBe("tool")
if (entry.content[1]?.type !== "tool") return
expect(entry.content[1].state.status).toBe("completed")
expect(entry.time.completed).toEqual(time(reason.length + 7))
},
),
{ numRuns: 50 },
)
})
test("starting a new step completes the old assistant and appends a new active assistant", () => {
const next = run(
[
SessionEvent.Step.Started.create({
model: {
id: "model",
providerID: "provider",
},
timestamp: time(1),
}),
],
active(),
)
expect(next.entries).toHaveLength(2)
expect(next.entries[0]?.type).toBe("assistant")
expect(next.entries[1]?.type).toBe("assistant")
if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
expect(next.entries[0].time.completed).toEqual(time(1))
expect(next.entries[1].time.created).toEqual(time(1))
expect(next.entries[1].time.completed).toBeUndefined()
})
test("handles sequential tools independently", () => {
FastCheck.assert(
FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
input: a,
provider: { executed: true },
timestamp: time(2),
}),
SessionEvent.Tool.Success.create({
callID: "a",
title,
output: "done",
provider: { executed: true },
timestamp: time(3),
}),
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "b",
tool: "bash",
input: b,
provider: { executed: true },
timestamp: time(5),
}),
SessionEvent.Tool.Error.create({
callID: "b",
error,
provider: { executed: true },
timestamp: time(6),
}),
],
active(),
)
const first = tool(next, "a")
const second = tool(next, "b")
expect(first?.state.status).toBe("completed")
if (first?.state.status !== "completed") return
expect(first.state.input).toEqual(a)
expect(first.state.output).toBe("done")
expect(first.state.title).toBe(title)
expect(second?.state.status).toBe("error")
if (second?.state.status !== "error") return
expect(second.state.input).toEqual(b)
expect(second.state.error).toBe(error)
}),
{ numRuns: 50 },
)
})
test("routes tool events by callID when tool streams interleave", () => {
FastCheck.assert(
FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
const next = run(
[
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
SessionEvent.Tool.Called.create({
callID: "a",
tool: "bash",
input: a,
provider: { executed: true },
timestamp: time(5),
}),
SessionEvent.Tool.Called.create({
callID: "b",
tool: "grep",
input: b,
provider: { executed: true },
timestamp: time(6),
}),
SessionEvent.Tool.Success.create({
callID: "a",
title: titleA,
output: "done-a",
provider: { executed: true },
timestamp: time(7),
}),
SessionEvent.Tool.Success.create({
callID: "b",
title: titleB,
output: "done-b",
provider: { executed: true },
timestamp: time(8),
}),
],
active(),
)
const first = tool(next, "a")
const second = tool(next, "b")
expect(first?.state.status).toBe("completed")
expect(second?.state.status).toBe("completed")
if (first?.state.status !== "completed" || second?.state.status !== "completed") return
expect(first.state.input).toEqual(a)
expect(second.state.input).toEqual(b)
expect(first.state.title).toBe(titleA)
expect(second.state.title).toBe(titleB)
}),
{ numRuns: 50 },
)
})
test("records synthetic events", () => {
FastCheck.assert(
FastCheck.property(word, (body) => {
const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("synthetic")
if (next.entries[0]?.type !== "synthetic") return
expect(next.entries[0].text).toBe(body)
}),
{ numRuns: 50 },
)
})
test("records compaction events", () => {
FastCheck.assert(
FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
const next = SessionEntry.step(
history(),
SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
)
expect(next.entries).toHaveLength(1)
expect(next.entries[0]?.type).toBe("compaction")
if (next.entries[0]?.type !== "compaction") return
expect(next.entries[0].auto).toBe(auto)
expect(next.entries[0].overflow).toBe(overflow)
}),
{ numRuns: 50 },
)
})
})
})
-13
View File
@@ -394,19 +394,6 @@ describe("tool.read truncation", () => {
}),
)
it.live("detects attachment media from file contents", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01])
yield* put(path.join(dir, "image.bin"), jpeg)
const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") })
expect(result.output).toBe("Image read successfully")
expect(result.attachments?.[0].mime).toBe("image/jpeg")
expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true)
}),
)
it.live("large image files are properly attached without error", () =>
Effect.gen(function* () {
const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
+96
View File
@@ -31,6 +31,102 @@ const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
describe("tool.skill", () => {
it.live("description lists skill location URL", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),
`---
name: tool-skill
description: Skill for tool tests.
---
# Tool Skill
`,
),
)
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
const registry = yield* ToolRegistry.Service
const desc =
(yield* registry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent: { name: "build", mode: "primary", permission: [], options: {} },
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
expect(desc).toContain("**tool-skill**: Skill for tool tests.")
}),
{ git: true },
),
)
it.live("description sorts skills by name and is stable across calls", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
for (const [name, description] of [
["zeta-skill", "Zeta skill."],
["alpha-skill", "Alpha skill."],
["middle-skill", "Middle skill."],
]) {
const skill = path.join(dir, ".opencode", "skill", name)
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}
`,
),
)
}
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const registry = yield* ToolRegistry.Service
const load = Effect.fnUntraced(function* () {
return (
(yield* registry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
)
})
const first = yield* load()
const second = yield* load()
expect(first).toBe(second)
const alpha = first.indexOf("**alpha-skill**: Alpha skill.")
const middle = first.indexOf("**middle-skill**: Middle skill.")
const zeta = first.indexOf("**zeta-skill**: Zeta skill.")
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)
expect(zeta).toBeGreaterThan(middle)
}),
{ git: true },
),
)
it.live("execute returns skill content block with files", () =>
provideTmpdirInstance(
(dir) =>
+2 -390
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema, SchemaGetter } from "effect"
import { Schema, SchemaGetter } from "effect"
import z from "zod"
import { zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod"
import { zod, ZodOverride } from "../../src/util/effect-zod"
function json(schema: z.ZodTypeAny) {
const { $schema: _, ...rest } = z.toJSONSchema(schema)
@@ -478,392 +478,4 @@ describe("util.effect-zod", () => {
expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
})
})
describe("well-known refinement translation", () => {
test("Schema.isInt emits type: integer in JSON Schema", () => {
const schema = zod(Schema.Number.check(Schema.isInt()))
const native = json(z.number().int())
expect(json(schema)).toEqual(native)
expect(schema.parse(3)).toBe(3)
expect(schema.safeParse(1.5).success).toBe(false)
})
test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => {
const schema = zod(Schema.Number.check(Schema.isGreaterThan(0)))
expect((json(schema) as any).exclusiveMinimum).toBe(0)
expect(schema.parse(1)).toBe(1)
expect(schema.safeParse(0).success).toBe(false)
expect(schema.safeParse(-1).success).toBe(false)
})
test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => {
const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)))
expect((json(schema) as any).minimum).toBe(0)
expect(schema.parse(0)).toBe(0)
expect(schema.safeParse(-1).success).toBe(false)
})
test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => {
const schema = zod(Schema.Number.check(Schema.isLessThan(10)))
expect((json(schema) as any).exclusiveMaximum).toBe(10)
expect(schema.parse(9)).toBe(9)
expect(schema.safeParse(10).success).toBe(false)
})
test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => {
const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10)))
expect((json(schema) as any).maximum).toBe(10)
expect(schema.parse(10)).toBe(10)
expect(schema.safeParse(11).success).toBe(false)
})
test("Schema.isMultipleOf(5) emits multipleOf: 5", () => {
const schema = zod(Schema.Number.check(Schema.isMultipleOf(5)))
expect((json(schema) as any).multipleOf).toBe(5)
expect(schema.parse(10)).toBe(10)
expect(schema.safeParse(7).success).toBe(false)
})
test("Schema.isFinite validates at runtime", () => {
const schema = zod(Schema.Number.check(Schema.isFinite()))
expect(schema.parse(1)).toBe(1)
expect(schema.safeParse(Infinity).success).toBe(false)
expect(schema.safeParse(NaN).success).toBe(false)
})
test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => {
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
const native = json(z.number().int().positive())
expect(json(schema)).toEqual(native)
expect(schema.parse(3)).toBe(3)
expect(schema.safeParse(0).success).toBe(false)
expect(schema.safeParse(1.5).success).toBe(false)
})
test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => {
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)))
const native = json(z.number().int().min(0))
expect(json(schema)).toEqual(native)
expect(schema.parse(0)).toBe(0)
expect(schema.safeParse(-1).success).toBe(false)
})
test("Schema.isBetween emits both bounds", () => {
const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 })))
const shape = json(schema) as any
expect(shape.minimum).toBe(1)
expect(shape.maximum).toBe(10)
expect(schema.parse(5)).toBe(5)
expect(schema.safeParse(11).success).toBe(false)
expect(schema.safeParse(0).success).toBe(false)
})
test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => {
const schema = zod(
Schema.Number.check(
Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }),
),
)
const shape = json(schema) as any
expect(shape.exclusiveMinimum).toBe(1)
expect(shape.exclusiveMaximum).toBe(10)
expect(schema.parse(5)).toBe(5)
expect(schema.safeParse(1).success).toBe(false)
expect(schema.safeParse(10).success).toBe(false)
})
test("Schema.isInt32 (FilterGroup) produces integer bounds", () => {
const schema = zod(Schema.Number.check(Schema.isInt32()))
const shape = json(schema) as any
expect(shape.type).toBe("integer")
expect(shape.minimum).toBe(-2147483648)
expect(shape.maximum).toBe(2147483647)
expect(schema.parse(42)).toBe(42)
expect(schema.safeParse(1.5).success).toBe(false)
expect(schema.safeParse(2147483648).success).toBe(false)
})
test("Schema.isMinLength on string emits minLength", () => {
const schema = zod(Schema.String.check(Schema.isMinLength(3)))
expect((json(schema) as any).minLength).toBe(3)
expect(schema.parse("abc")).toBe("abc")
expect(schema.safeParse("ab").success).toBe(false)
})
test("Schema.isMaxLength on string emits maxLength", () => {
const schema = zod(Schema.String.check(Schema.isMaxLength(5)))
expect((json(schema) as any).maxLength).toBe(5)
expect(schema.parse("abcde")).toBe("abcde")
expect(schema.safeParse("abcdef").success).toBe(false)
})
test("Schema.isLengthBetween on string emits both bounds", () => {
const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4)))
const shape = json(schema) as any
expect(shape.minLength).toBe(2)
expect(shape.maxLength).toBe(4)
expect(schema.parse("abc")).toBe("abc")
expect(schema.safeParse("a").success).toBe(false)
expect(schema.safeParse("abcde").success).toBe(false)
})
test("Schema.isMinLength on array emits minItems", () => {
const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1)))
expect((json(schema) as any).minItems).toBe(1)
expect(schema.parse(["x"])).toEqual(["x"])
expect(schema.safeParse([]).success).toBe(false)
})
test("Schema.isPattern emits pattern", () => {
const schema = zod(Schema.String.check(Schema.isPattern(/^per/)))
expect((json(schema) as any).pattern).toBe("^per")
expect(schema.parse("per_abc")).toBe("per_abc")
expect(schema.safeParse("abc").success).toBe(false)
})
test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => {
const schema = zod(Schema.String.check(Schema.isStartsWith("per")))
const native = json(z.string().startsWith("per"))
expect(json(schema)).toEqual(native)
expect(schema.parse("per_abc")).toBe("per_abc")
expect(schema.safeParse("abc").success).toBe(false)
})
test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => {
const schema = zod(Schema.String.check(Schema.isEndsWith(".json")))
const native = json(z.string().endsWith(".json"))
expect(json(schema)).toEqual(native)
expect(schema.parse("a.json")).toBe("a.json")
expect(schema.safeParse("a.txt").success).toBe(false)
})
test("Schema.isUUID emits format: uuid", () => {
const schema = zod(Schema.String.check(Schema.isUUID()))
expect((json(schema) as any).format).toBe("uuid")
})
test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => {
// isInt is well-known (translates to .int()); the anonymous filter falls
// back to superRefine.
const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed"))
const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven))
const shape = json(schema) as any
// Well-known translation is preserved — type is integer, not plain number
expect(shape.type).toBe("integer")
// Runtime: both constraints fire
expect(schema.parse(3)).toBe(3)
expect(schema.safeParse(1.5).success).toBe(false)
const seven = schema.safeParse(7)
expect(seven.success).toBe(false)
expect(seven.error!.issues[0].message).toBe("no sevens allowed")
})
test("inside a struct field, well-known refinements propagate through", () => {
// Mirrors config.ts port: z.number().int().positive().optional()
const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
const schema = zod(Schema.Struct({ port: Port }))
const shape = json(schema) as any
expect(shape.properties.port.type).toBe("integer")
expect(shape.properties.port.exclusiveMinimum).toBe(0)
})
})
describe("Schema.optionalWith defaults", () => {
test("parsing undefined returns the default value", () => {
const schema = zod(
Schema.Struct({
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
}),
)
expect(schema.parse({})).toEqual({ mode: "ctrl-x" })
expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" })
})
test("parsing a real value returns that value (default does not fire)", () => {
const schema = zod(
Schema.Struct({
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
}),
)
expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" })
})
test("default on a number field", () => {
const schema = zod(
Schema.Struct({
count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))),
}),
)
expect(schema.parse({})).toEqual({ count: 42 })
expect(schema.parse({ count: 7 })).toEqual({ count: 7 })
})
test("multiple defaulted fields inside a struct", () => {
const schema = zod(
Schema.Struct({
leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))),
inner: Schema.String,
}),
)
expect(schema.parse({ inner: "hi" })).toEqual({
leader: "ctrl-x",
quit: "ctrl-c",
inner: "hi",
})
expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({
leader: "a",
quit: "b",
inner: "c",
})
})
test("JSON Schema output includes the default key", () => {
const schema = zod(
Schema.Struct({
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
}),
)
const shape = json(schema) as any
expect(shape.properties.mode.default).toBe("ctrl-x")
})
test("default referencing a computed value resolves when evaluated", () => {
// Simulates `keybinds.ts` style of per-platform defaults: the default is
// produced by an Effect that computes a value at decode time.
const platform = "darwin"
const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k"
const schema = zod(
Schema.Struct({
command_palette: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.sync(() => fallback))),
}),
)
expect(schema.parse({})).toEqual({ command_palette: "cmd-k" })
const shape = json(schema) as any
expect(shape.properties.command_palette.default).toBe("cmd-k")
})
test("plain Schema.optional (no default) still emits .optional() (regression)", () => {
const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) }))
expect(schema.parse({})).toEqual({})
expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
})
})
describe("ZodPreprocess annotation", () => {
test("preprocess runs on raw input before the inner schema parses", () => {
// Models the permission.ts __originalKeys pattern: capture the original
// insertion order of a user-provided object BEFORE Schema parsing
// canonicalises the keys.
const preprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
}
return val
}
const Inner = Schema.Struct({
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
a: Schema.optional(Schema.String),
b: Schema.optional(Schema.String),
}).annotate({ [ZodPreprocess]: preprocess })
const schema = zod(Inner)
const parsed = schema.parse({ b: "1", a: "2" }) as {
__keys?: string[]
a?: string
b?: string
}
expect(parsed.__keys).toEqual(["b", "a"])
expect(parsed.a).toBe("2")
expect(parsed.b).toBe("1")
})
test("preprocess does not transform already-shaped input", () => {
// When the user passes an object that already has __keys, preprocess
// returns it unchanged because spreading preserves any existing key.
const preprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !("__keys" in val)) {
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
}
return val
}
const Inner = Schema.Struct({
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
a: Schema.optional(Schema.String),
}).annotate({ [ZodPreprocess]: preprocess })
const schema = zod(Inner)
const parsed = schema.parse({ __keys: ["existing"], a: "hi" }) as {
__keys?: string[]
a?: string
}
expect(parsed.__keys).toEqual(["existing"])
})
test("preprocess composes with a union (either object or string)", () => {
// Mirrors permission.ts exactly: input can be either an object (with
// preprocess injecting metadata) or a plain string action.
const Action = Schema.Literals(["ask", "allow", "deny"])
const Obj = Schema.Struct({
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
read: Schema.optional(Action),
write: Schema.optional(Action),
})
const preprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
}
return val
}
const Inner = Schema.Union([Obj, Action]).annotate({ [ZodPreprocess]: preprocess })
const schema = zod(Inner)
// String branch — passes through preprocess unchanged
expect(schema.parse("allow")).toBe("allow")
// Object branch — __keys injected, preserves order
const parsed = schema.parse({ write: "allow", read: "deny" }) as {
__keys?: string[]
read?: string
write?: string
}
expect(parsed.__keys).toEqual(["write", "read"])
expect(parsed.write).toBe("allow")
expect(parsed.read).toBe("deny")
})
test("JSON Schema output comes from the inner schema — preprocess is runtime-only", () => {
const Inner = Schema.Struct({
a: Schema.optional(Schema.String),
b: Schema.optional(Schema.Number),
}).annotate({ [ZodPreprocess]: (v: unknown) => v })
const shape = json(zod(Inner)) as any
expect(shape.type).toBe("object")
expect(shape.properties.a.type).toBe("string")
expect(shape.properties.b.type).toBe("number")
})
test("identifier + description propagate through the preprocess wrapper", () => {
const Inner = Schema.Struct({
x: Schema.optional(Schema.String),
}).annotate({
identifier: "WithPreproc",
description: "A schema with preprocess",
[ZodPreprocess]: (v: unknown) => v,
})
const schema = zod(Inner)
expect(schema.meta()?.ref).toBe("WithPreproc")
expect(schema.meta()?.description).toBe("A schema with preprocess")
})
test("preprocess inside a struct field applies only to that field", () => {
const Inner = Schema.String.annotate({
[ZodPreprocess]: (v: unknown) => (typeof v === "number" ? String(v) : v),
})
const schema = zod(Schema.Struct({ name: Inner, raw: Schema.Number }))
expect(schema.parse({ name: 42, raw: 7 })).toEqual({ name: "42", raw: 7 })
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.4.14",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
+11 -27
View File
@@ -6,33 +6,17 @@ import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
async function published(name: string, version: string) {
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
}
await $`bun tsc`
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string>
}
if (await published(pkg.name, pkg.version)) {
console.log(`already published ${pkg.name}@${pkg.version}`)
} else {
for (const [key, value] of Object.entries(pkg.exports)) {
const file = value.replace("./src/", "./dist/").replace(".ts", "")
// @ts-ignore
pkg.exports[key] = {
import: file + ".js",
types: file + ".d.ts",
}
}
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
try {
await $`bun pm pack`
await $`npm publish *.tgz --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
const pkg = await import("../package.json").then((m) => m.default)
const original = JSON.parse(JSON.stringify(pkg))
for (const [key, value] of Object.entries(pkg.exports)) {
const file = value.replace("./src/", "./dist/").replace(".ts", "")
// @ts-ignore
pkg.exports[key] = {
import: file + ".js",
types: file + ".d.ts",
}
}
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
await $`bun pm pack && npm publish *.tgz --tag ${Script.channel} --access public`
await Bun.write("package.json", JSON.stringify(original, null, 2))

Some files were not shown because too many files have changed in this diff Show More