diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index d1e3bfc25d0..9859174a2e3 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -1,5 +1,10 @@ name: "Setup Bun" description: "Setup Bun with caching and install dependencies" +inputs: + install-flags: + description: "Additional flags to pass to 'bun install'" + required: false + default: "" runs: using: "composite" steps: @@ -46,8 +51,8 @@ runs: # e.g. ./patches/ for standard-openapi # https://github.com/oven-sh/bun/issues/28147 if [ "$RUNNER_OS" = "Windows" ]; then - bun install --linker hoisted + bun install --linker hoisted ${{ inputs.install-flags }} else - bun install + bun install ${{ inputs.install-flags }} fi shell: bash diff --git a/package.json b/package.json index 8eb5fcbce54..59f07920b51 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.26", + "version": "7.2.25", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 2f36de77acb..25f5742850c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.26", + "version": "7.2.25", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index e493cabaf57..9d7e2394bfe 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -270,7 +270,7 @@ export const PromptInput: Component = (props) => { const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const motion = (value: number) => ({ opacity: value, - transform: `scale(${0.95 + value * 0.05})`, + transform: `scale(${0.98 + value * 0.02})`, filter: `blur(${(1 - value) * 2}px)`, "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const), }) @@ -345,7 +345,7 @@ export const PromptInput: Component = (props) => { promptPlaceholder({ mode: store.mode, commentCount: commentCount(), - example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "", + example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "", suggest: suggest(), t: (key, params) => language.t(key as Parameters[0], params as never), }), @@ -1404,12 +1404,11 @@ export const PromptInput: Component = (props) => { @@ -1455,14 +1454,24 @@ export const PromptInput: Component = (props) => { >
- {language.t("prompt.mode.shell")} -
+ + {language.t("prompt.mode.shell")} +
+
@@ -1569,33 +1578,35 @@ export const PromptInput: Component = (props) => {
-
- 2}> +
- (x === "default" ? language.t("common.default") : x)} + onSelect={(value) => { + local.model.variant.set(value === "default" ? undefined : value) + restoreFocus() + }} + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-model-variant" }} + variant="ghost" + /> + +
+
diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts index 5f6aa59e9a4..d4caead0d2e 100644 --- a/packages/app/src/components/prompt-input/placeholder.test.ts +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -12,7 +12,7 @@ describe("promptPlaceholder", () => { suggest: true, t, }) - expect(value).toBe("prompt.placeholder.shell") + expect(value).toBe("prompt.placeholder.shell:example") }) test("returns summarize placeholders for comment context", () => { diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts index 395fee51b1c..6669f136147 100644 --- a/packages/app/src/components/prompt-input/placeholder.ts +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -7,7 +7,7 @@ type PromptPlaceholderInput = { } export function promptPlaceholder(input: PromptPlaceholderInput) { - if (input.mode === "shell") return input.t("prompt.placeholder.shell") + if (input.mode === "shell") return input.t("prompt.placeholder.shell", { example: input.example }) if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") if (!input.suggest) return input.t("prompt.placeholder.simple") diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 62435251a1f..50c511d0dd9 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -128,27 +128,25 @@ export const SettingsGeneral: Component = () => { return } - const actions = - platform.update && platform.restart - ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() - }, + const actions = platform.updateAndRestart + ? [ + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.updateAndRestart!() }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] - : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] + : [ + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 3bdc46391b6..fd89bf51ba7 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -49,11 +49,11 @@ export type Platform = { /** Storage mechanism, defaults to localStorage */ storage?: (name?: string) => SyncStorage | AsyncStorage - /** Check for updates (Tauri only) */ + /** Check for a downloadable desktop update */ checkUpdate?(): Promise - /** Install updates (Tauri only) */ - update?(): Promise + /** Install the downloaded update using the platform restart flow */ + updateAndRestart?(): Promise /** Fetch override */ fetch?: typeof fetch diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 6a5c8a24a11..47f98dbbfe6 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -209,7 +209,7 @@ export const dict = { "common.saving": "جارٍ الحفظ...", "common.default": "افتراضي", "common.attachment": "مرفق", - "prompt.placeholder.shell": "أدخل أمر shell...", + "prompt.placeholder.shell": "أدخل أمر shell... {{example}}", "prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"', "prompt.placeholder.simple": "اسأل أي شيء...", "prompt.placeholder.summarizeComments": "لخّص التعليقات…", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 23ab983bd65..101eb7ce838 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -210,7 +210,7 @@ export const dict = { "common.saving": "Salvando...", "common.default": "Padrão", "common.attachment": "anexo", - "prompt.placeholder.shell": "Digite comando do shell...", + "prompt.placeholder.shell": "Digite comando do shell... {{example}}", "prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"', "prompt.placeholder.simple": "Pergunte qualquer coisa...", "prompt.placeholder.summarizeComments": "Resumir comentários…", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index e8944d2406b..a52b04c437f 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -228,7 +228,7 @@ export const dict = { "common.default": "Podrazumijevano", "common.attachment": "prilog", - "prompt.placeholder.shell": "Unesi shell naredbu...", + "prompt.placeholder.shell": "Unesi shell naredbu... {{example}}", "prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"', "prompt.placeholder.simple": "Pitaj bilo šta...", "prompt.placeholder.summarizeComments": "Sažmi komentare…", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index a6fb99eeac6..a281c96e428 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -226,7 +226,7 @@ export const dict = { "common.default": "Standard", "common.attachment": "vedhæftning", - "prompt.placeholder.shell": "Indtast shell-kommando...", + "prompt.placeholder.shell": "Indtast shell-kommando... {{example}}", "prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"', "prompt.placeholder.simple": "Spørg om hvad som helst...", "prompt.placeholder.summarizeComments": "Opsummér kommentarer…", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 4b6c2310714..34ce312aea2 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -215,7 +215,7 @@ export const dict = { "common.saving": "Speichert...", "common.default": "Standard", "common.attachment": "Anhang", - "prompt.placeholder.shell": "Shell-Befehl eingeben...", + "prompt.placeholder.shell": "Shell-Befehl eingeben... {{example}}", "prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"', "prompt.placeholder.simple": "Fragen Sie alles...", "prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 11c83dde208..6cde9741882 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -230,7 +230,7 @@ export const dict = { "common.default": "Default", "common.attachment": "attachment", - "prompt.placeholder.shell": "Enter shell command...", + "prompt.placeholder.shell": "Enter shell command... {{example}}", "prompt.placeholder.normal": 'Ask anything... "{{example}}"', "prompt.placeholder.simple": "Ask anything...", "prompt.placeholder.summarizeComments": "Summarize comments…", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index bae6b6e3b18..9ed93e813e4 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -227,7 +227,7 @@ export const dict = { "common.default": "Predeterminado", "common.attachment": "adjunto", - "prompt.placeholder.shell": "Introduce comando de shell...", + "prompt.placeholder.shell": "Introduce comando de shell... {{example}}", "prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"', "prompt.placeholder.simple": "Pregunta cualquier cosa...", "prompt.placeholder.summarizeComments": "Resumir comentarios…", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index e73c54d2128..5337ed482ab 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -210,7 +210,7 @@ export const dict = { "common.saving": "Enregistrement...", "common.default": "Défaut", "common.attachment": "pièce jointe", - "prompt.placeholder.shell": "Entrez une commande shell...", + "prompt.placeholder.shell": "Entrez une commande shell... {{example}}", "prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"', "prompt.placeholder.simple": "Demandez n'importe quoi...", "prompt.placeholder.summarizeComments": "Résumer les commentaires…", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 9d68b8975d4..49b8d652d7e 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -209,7 +209,7 @@ export const dict = { "common.saving": "保存中...", "common.default": "デフォルト", "common.attachment": "添付ファイル", - "prompt.placeholder.shell": "シェルコマンドを入力...", + "prompt.placeholder.shell": "シェルコマンドを入力... {{example}}", "prompt.placeholder.normal": '何でも聞いてください... "{{example}}"', "prompt.placeholder.simple": "何でも聞いてください...", "prompt.placeholder.summarizeComments": "コメントを要約…", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 234980c1038..c1c6f397ccc 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -209,7 +209,7 @@ export const dict = { "common.saving": "저장 중...", "common.default": "기본값", "common.attachment": "첨부 파일", - "prompt.placeholder.shell": "셸 명령어 입력...", + "prompt.placeholder.shell": "셸 명령어 입력... {{example}}", "prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"', "prompt.placeholder.simple": "무엇이든 물어보세요...", "prompt.placeholder.summarizeComments": "댓글 요약…", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0dbf94e347c..1551f956821 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -230,7 +230,7 @@ export const dict = { "common.default": "Standard", "common.attachment": "vedlegg", - "prompt.placeholder.shell": "Skriv inn shell-kommando...", + "prompt.placeholder.shell": "Skriv inn shell-kommando... {{example}}", "prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"', "prompt.placeholder.simple": "Spør om hva som helst...", "prompt.placeholder.summarizeComments": "Oppsummer kommentarer…", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index e0b11074339..49e7e12d992 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -211,7 +211,7 @@ export const dict = { "common.saving": "Zapisywanie...", "common.default": "Domyślny", "common.attachment": "załącznik", - "prompt.placeholder.shell": "Wpisz polecenie terminala...", + "prompt.placeholder.shell": "Wpisz polecenie terminala... {{example}}", "prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"', "prompt.placeholder.simple": "Zapytaj o cokolwiek...", "prompt.placeholder.summarizeComments": "Podsumuj komentarze…", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 7838dcd14be..6436f62c1ff 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -227,7 +227,7 @@ export const dict = { "common.default": "По умолчанию", "common.attachment": "вложение", - "prompt.placeholder.shell": "Введите команду оболочки...", + "prompt.placeholder.shell": "Введите команду оболочки... {{example}}", "prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"', "prompt.placeholder.simple": "Спросите что угодно...", "prompt.placeholder.summarizeComments": "Суммировать комментарии…", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 90f33affd5b..8188f671fb1 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -227,7 +227,7 @@ export const dict = { "common.default": "ค่าเริ่มต้น", "common.attachment": "ไฟล์แนบ", - "prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...", + "prompt.placeholder.shell": "ป้อนคำสั่งเชลล์... {{example}}", "prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"', "prompt.placeholder.simple": "ถามอะไรก็ได้...", "prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 4a2ab351ff3..a5c3f4b47b5 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -231,7 +231,7 @@ export const dict = { "common.default": "Varsayılan", "common.attachment": "ek", - "prompt.placeholder.shell": "Kabuk komutu girin...", + "prompt.placeholder.shell": "Kabuk komutu girin... {{example}}", "prompt.placeholder.normal": 'Bir şeyler sorun... "{{example}}"', "prompt.placeholder.simple": "Bir şeyler sorun...", "prompt.placeholder.summarizeComments": "Yorumları özetle…", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 7c54b6d60bc..52c20f9b5e7 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -249,7 +249,7 @@ export const dict = { "common.default": "默认", "common.attachment": "附件", - "prompt.placeholder.shell": "输入 shell 命令...", + "prompt.placeholder.shell": "输入 shell 命令... {{example}}", "prompt.placeholder.normal": '随便问点什么... "{{example}}"', "prompt.placeholder.simple": "随便问点什么...", "prompt.placeholder.summarizeComments": "总结评论…", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 1cc5cb521f8..61f5059868e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -227,7 +227,7 @@ export const dict = { "common.default": "預設", "common.attachment": "附件", - "prompt.placeholder.shell": "輸入 shell 命令...", + "prompt.placeholder.shell": "輸入 shell 命令... {{example}}", "prompt.placeholder.normal": '隨便問點什麼... "{{example}}"', "prompt.placeholder.simple": "隨便問點什麼...", "prompt.placeholder.summarizeComments": "摘要評論…", diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 9f12aa1cfcd..bae8f44e548 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -244,10 +244,9 @@ export const ErrorPage: Component = (props) => { } async function installUpdate() { - if (!platform.update || !platform.restart) return + if (!platform.updateAndRestart) return await platform - .update() - .then(() => platform.restart!()) + .updateAndRestart() .then(() => setStore("actionError", undefined)) .catch((err) => { setStore("actionError", formatError(err, language.t)) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index ce5e49968e9..c638ec5b203 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -366,7 +366,7 @@ export default function Layout(props: ParentProps) { const useUpdatePolling = () => onMount(() => { - if (!platform.checkUpdate || !platform.update || !platform.restart) return + if (!platform.checkUpdate || !platform.updateAndRestart) return let toastId: number | undefined let interval: ReturnType | undefined @@ -384,8 +384,7 @@ export default function Layout(props: ParentProps) { { label: language.t("toast.update.action.installRestart"), onClick: async () => { - await platform.update!() - await platform.restart!() + await platform.updateAndRestart!() }, }, { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index be4a9209bac..789c44e401b 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.26", + "version": "7.2.25", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 98766e1b80d..f00ba5d5732 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -337,11 +337,16 @@ function setupAutoUpdater() { }) } -let updateReady = false +let downloadedUpdateVersion: string | undefined async function checkUpdate() { if (!UPDATER_ENABLED) return { updateAvailable: false } - updateReady = false + if (downloadedUpdateVersion) { + logger.log("returning cached downloaded update", { + version: downloadedUpdateVersion, + }) + return { updateAvailable: true, version: downloadedUpdateVersion } + } logger.log("checking for updates", { currentVersion: app.getVersion(), channel: autoUpdater.channel, @@ -367,7 +372,7 @@ async function checkUpdate() { logger.log("update available", { version }) await autoUpdater.downloadUpdate() logger.log("update download completed", { version }) - updateReady = true + downloadedUpdateVersion = version return { updateAvailable: true, version } } catch (error) { logger.error("update check failed", error) @@ -376,7 +381,15 @@ async function checkUpdate() { } async function installUpdate() { - if (!updateReady) return + if (!downloadedUpdateVersion) { + logger.log("install update skipped", { + reason: "no downloaded update ready", + }) + return + } + logger.log("installing downloaded update", { + version: downloadedUpdateVersion, + }) killSidecar() autoUpdater.quitAndInstall() } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index a815a717126..a1a11adf198 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -170,7 +170,7 @@ const createPlatform = (): Platform => { return window.api.checkUpdate() }, - update: async () => { + updateAndRestart: async () => { const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })) if (!config.updaterEnabled) return await window.api.installUpdate() diff --git a/packages/desktop/package.json b/packages/desktop/package.json index c9fa8db9514..976652c7a8d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.26", + "version": "7.2.25", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 3fd80fb1ade..2717cd1e418 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -297,10 +297,15 @@ const createPlatform = (): Platform => { return { updateAvailable: true, version: next.version } }, - update: async () => { + updateAndRestart: async () => { if (!UPDATER_ENABLED || !update) return if (ostype() === "windows") await commands.killSidecar().catch(() => undefined) - await update.install().catch(() => undefined) + const installed = await update + .install() + .then(() => true) + .catch(() => false) + if (!installed) return + await relaunch() }, restart: async () => { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 38120bb7f11..e53db34a639 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.2.26" +version = "1.14.23" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.23/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.23/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.23/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.23/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.23/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a28ebedba43..275f972651e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.26", + "version": "7.2.25", "name": "@kilocode/cli", "type": "module", "license": "MIT", @@ -103,25 +103,21 @@ "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", - "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/node-server": "1.19.13", "@hono/node-ws": "1.3.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", - "@kilocode/kilo-gateway": "workspace:*", - "@kilocode/kilo-telemetry": "workspace:*", - "@kilocode/plugin": "workspace:*", - "@kilocode/sdk": "workspace:*", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", - "@morphllm/morphsdk": "0.2.166", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@kilocode/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", + "@kilocode/sdk": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -155,10 +151,8 @@ "gray-matter": "4.0.3", "hono": "catalog:", "hono-openapi": "catalog:", - "iconv-lite": "0.7.2", "ignore": "7.0.5", "immer": "11.1.4", - "jschardet": "3.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", @@ -169,12 +163,8 @@ "opentui-spinner": "0.0.6", "partial-json": "0.1.7", "remeda": "catalog:", - "ripgrep": "0.3.1", - "rotating-file-stream": "3.2.9", "semver": "^7.6.3", - "simple-git": "3.35.2", "solid-js": "catalog:", - "stream-chat": "9.38.0", "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", @@ -187,7 +177,17 @@ "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5" + "zod-to-json-schema": "3.24.5", + "@gitlab/gitlab-ai-provider": "3.6.0", + "@kilocode/kilo-gateway": "workspace:*", + "@kilocode/kilo-telemetry": "workspace:*", + "@morphllm/morphsdk": "0.2.166", + "iconv-lite": "0.7.2", + "jschardet": "3.1.4", + "ripgrep": "0.3.1", + "rotating-file-stream": "3.2.9", + "simple-git": "3.35.2", + "stream-chat": "9.38.0" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 438f90796c9..01ee2fcb191 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -409,7 +409,7 @@ Current instance route inventory: - `project` - `bridged` (partial) bridged endpoints: `GET /project`, `GET /project/current` defer git-init mutation first -- `workspace` - `next` +- `workspace` - `bridged` best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status` defer create/remove mutations first - `file` - `later` @@ -448,7 +448,7 @@ Recommended near-term sequence: - [x] port `config` providers read endpoint - [x] port `project` read endpoints (`GET /project`, `GET /project/current`) - [x] port `GET /config` full read endpoint -- [ ] port `workspace` read endpoints +- [x] port `workspace` read endpoints - [ ] port `file` JSON read endpoints - [ ] decide when to remove the flag and make Effect routes the default diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 9ff6859cee1..0319df4a0ed 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -147,6 +147,17 @@ import `z` do so only for local `ZodOverride` bridges or for `z.ZodType` type annotations — the `export const ` values are all Effect Schema at source. +A file is considered "done" when: + +- its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.) + are authored as Effect Schema +- any remaining zod is either a derived compat bridge (via `zod()` / + `zodObject()`), a `z.ZodType` type annotation, or a documented + `ZodOverride` escape hatch — never a hand-written parallel source of truth + +Files that meet this bar but still carry a compat bridge are checked off +with an inline note describing the bridge and what unblocks its removal. + - [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider - [x] server, layout - [x] keybinds @@ -159,6 +170,7 @@ Schema at source. These are the highest-priority next targets. Each is a small, self-contained schema module with a clear domain. +- [x] `src/account/schema.ts` - [x] `src/control-plane/schema.ts` - [x] `src/permission/schema.ts` - [x] `src/project/schema.ts` @@ -166,8 +178,10 @@ schema module with a clear domain. - [x] `src/pty/schema.ts` - [x] `src/question/schema.ts` - [x] `src/session/schema.ts` +- [x] `src/storage/schema.ts` - [x] `src/sync/schema.ts` - [x] `src/tool/schema.ts` +- [x] `src/util/schema.ts` ### Session domain @@ -240,29 +254,29 @@ Working rule for this cluster: 5. Errors and event payloads last - `NamedError.create(...)` shapes can stay temporarily if converting them to `Schema.TaggedErrorClass` would force unrelated churn - - `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can keep using - derived `.zod` until the sync/bus layers are migrated + - `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can use + derived `.zod` at remaining zod-based HTTP/OpenAPI boundaries Possible later tightening after the Schema-first migration is stable: - promote repeated opaque strings and timestamp numbers into branded/newtype leaf schemas where that adds domain value without changing the wire format -- [ ] `src/session/compaction.ts` -- [ ] `src/session/message-v2.ts` -- [ ] `src/session/message.ts` -- [ ] `src/session/prompt.ts` -- [ ] `src/session/revert.ts` -- [ ] `src/session/session.ts` -- [ ] `src/session/status.ts` -- [ ] `src/session/summary.ts` -- [ ] `src/session/todo.ts` +- [x] `src/session/compaction.ts` +- [x] `src/session/message-v2.ts` +- [x] `src/session/message.ts` +- [x] `src/session/prompt.ts` +- [x] `src/session/revert.ts` +- [x] `src/session/session.ts` +- [x] `src/session/status.ts` +- [x] `src/session/summary.ts` +- [x] `src/session/todo.ts` ### Provider domain -- [ ] `src/provider/auth.ts` -- [ ] `src/provider/models.ts` -- [ ] `src/provider/provider.ts` +- [x] `src/provider/auth.ts` +- [x] `src/provider/models.ts` +- [x] `src/provider/provider.ts` ### Tool schemas @@ -270,25 +284,25 @@ 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/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` +- [x] `src/tool/apply_patch.ts` +- [x] `src/tool/bash.ts` +- [x] `src/tool/codesearch.ts` +- [x] `src/tool/edit.ts` +- [x] `src/tool/glob.ts` +- [x] `src/tool/grep.ts` +- [x] `src/tool/invalid.ts` +- [x] `src/tool/lsp.ts` +- [x] `src/tool/plan.ts` +- [x] `src/tool/question.ts` +- [x] `src/tool/read.ts` +- [x] `src/tool/registry.ts` +- [x] `src/tool/skill.ts` +- [x] `src/tool/task.ts` +- [x] `src/tool/todo.ts` +- [x] `src/tool/tool.ts` +- [x] `src/tool/webfetch.ts` +- [x] `src/tool/websearch.ts` +- [x] `src/tool/write.ts` ### HTTP route boundaries @@ -299,8 +313,8 @@ 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` +- [x] `src/server/event.ts` +- [x] `src/server/projectors.ts` - [ ] `src/server/routes/control/index.ts` - [ ] `src/server/routes/control/workspace.ts` - [ ] `src/server/routes/global.ts` @@ -332,7 +346,7 @@ piecewise. - [ ] `src/acp/agent.ts` - [ ] `src/agent/agent.ts` -- [ ] `src/bus/bus-event.ts` +- [x] `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` @@ -340,9 +354,9 @@ piecewise. - [ ] `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` +- [x] `src/control-plane/adaptors/worktree.ts` +- [x] `src/control-plane/types.ts` +- [x] `src/control-plane/workspace.ts` - [ ] `src/file/index.ts` - [ ] `src/file/ripgrep.ts` - [ ] `src/file/watcher.ts` @@ -362,7 +376,7 @@ piecewise. - [ ] `src/snapshot/index.ts` - [ ] `src/storage/db.ts` - [ ] `src/storage/storage.ts` -- [ ] `src/sync/index.ts` +- [x] `src/sync/index.ts` — public API (`SyncEvent.define`) is Schema-first; `payloads()` still derives zod for the remaining HTTP/OpenAPI boundary - [ ] `src/util/fn.ts` - [ ] `src/util/log.ts` - [ ] `src/util/update-schema.ts` diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 162e92c3a9c..11124d1de8b 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -374,7 +374,7 @@ export class Agent implements ACPAgent { } if (part.tool === "todowrite") { - const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) + const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output)) if (parsedTodos.success) { await this.connection .sessionUpdate({ @@ -905,7 +905,7 @@ export class Agent implements ACPAgent { } if (part.tool === "todowrite") { - const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) + const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output)) if (parsedTodos.success) { await this.connection .sessionUpdate({ diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index efaed944066..f27d2633544 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,15 +1,19 @@ import z from "zod" -import type { ZodType } from "zod" +import { Schema } from "effect" +import { zodObject } from "@/util/effect-zod" -export type Definition = ReturnType +export type Definition = { + type: Type + properties: Properties +} const registry = new Map() -export function define(type: Type, properties: Properties) { - const result = { - type, - properties, - } +export function define( + type: Type, + properties: Properties, +): Definition { + const result = { type, properties } registry.set(type, result) return result } @@ -21,7 +25,7 @@ export function payloads() { return z .object({ type: z.literal(type), - properties: def.properties, + properties: zodObject(def.properties), }) .meta({ ref: `Event.${def.type}`, diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 8a9579b599e..12251f26c7d 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" +import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "effect" import { EffectBridge } from "@/effect" import { Log } from "../util" import { BusEvent } from "./bus-event" @@ -9,16 +8,18 @@ import { makeRuntime } from "@/effect/run-service" const log = Log.create({ service: "bus" }) +type BusProperties> = Schema.Schema.Type + export const InstanceDisposed = BusEvent.define( "server.instance.disposed", - z.object({ - directory: z.string(), + Schema.Struct({ + directory: Schema.String, }), ) type Payload = { type: D["type"] - properties: z.infer + properties: BusProperties } type State = { @@ -27,10 +28,7 @@ type State = { } export interface Interface { - readonly publish: ( - def: D, - properties: z.output, - ) => Effect.Effect + readonly publish: (def: D, properties: BusProperties) => Effect.Effect readonly subscribe: (def: D) => Stream.Stream> readonly subscribeAll: () => Stream.Stream readonly subscribeCallback: ( @@ -79,7 +77,7 @@ export const layer = Layer.effect( }) } - function publish(def: D, properties: z.output) { + function publish(def: D, properties: BusProperties) { return Effect.gen(function* () { const s = yield* InstanceState.get(state) const payload: Payload = { type: def.type, properties } @@ -175,14 +173,11 @@ const { runPromise, runSync } = makeRuntime(Service, layer) // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. -export async function publish(def: D, properties: z.output) { +export async function publish(def: D, properties: BusProperties) { return runPromise((svc) => svc.publish(def, properties)) } -export function subscribe( - def: D, - callback: (event: { type: D["type"]; properties: z.infer }) => unknown, -) { +export function subscribe(def: D, callback: (event: Payload) => unknown) { return runSync((svc) => svc.subscribeCallback(def, callback)) } diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index e9a435096d6..1b93d83f1a2 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -10,9 +10,10 @@ import { Instance } from "../../project/instance" import { EOL } from "os" import { Filesystem } from "../../util" import { AppRuntime } from "@/effect/app-runtime" -import { Log } from "../../util" +import { Schema } from "effect" +import { Log } from "../../util" // kilocode_change -const log = Log.create({ service: "import" }) +const log = Log.create({ service: "import" }) // kilocode_change /** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ export type ShareData = @@ -183,10 +184,10 @@ export const ImportCommand = cmd({ return } - const info = Session.Info.parse({ + const info = Schema.decodeUnknownSync(Session.Info)({ ...exportData.info, projectID: Instance.project.id, - }) + }) as Session.Info const row = Session.toRow(info) Database.use((db) => db diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index b43f24588ee..0ac4479e5b6 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -24,6 +24,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov import { InstallationVersion } from "@/installation/version" // kilocode_change import { PluginRouteMissing } from "@tui/component/plugin-route-missing" import { ProjectProvider } from "@tui/context/project" +import { EditorContextProvider } from "@tui/context/editor" import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" @@ -186,7 +187,9 @@ export function tui(input: { - + + + diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index e949d3f28fa..4edc3d6b148 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,9 +1,11 @@ import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" +import path from "path" import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" import { createStore } from "solid-js/store" +import { useEditorContext } from "@tui/context/editor" import { useSDK } from "@tui/context/sdk" import { useSync } from "@tui/context/sync" import { getScrollAcceleration } from "../../util/scroll" @@ -81,6 +83,7 @@ export function Autocomplete(props: { agentStyleId: number promptPartTypeId: () => number }) { + const editor = useEditorContext() const sdk = useSDK() const sync = useSync() const command = useCommandDialog() @@ -225,6 +228,70 @@ export function Autocomplete(props: { } } + function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { + const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") + const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) + const urlObj = pathToFileURL(fullPath) + const filename = + lineRange && !item.endsWith("/") + ? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + : item + + if (lineRange && !item.endsWith("/")) { + urlObj.searchParams.set("start", String(lineRange.startLine)) + if (lineRange.endLine !== undefined) { + urlObj.searchParams.set("end", String(lineRange.endLine)) + } + } + + return { + filename, + url: urlObj.href, + part: { + type: "file" as const, + mime: "text/plain", + filename, + url: urlObj.href, + source: { + type: "file" as const, + text: { + start: 0, + end: 0, + value: "", + }, + path: item, + }, + }, + } + } + + function normalizeMentionPath(filePath: string) { + const baseDir = sync.path.directory || process.cwd() + const absolute = path.resolve(filePath) + const relative = path.relative(baseDir, absolute) + + if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { + return relative.split(path.sep).join("/") + } + + return absolute.split(path.sep).join("/") + } + + function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) { + const item = normalizeMentionPath(input.filePath) + const lineRange = { + startLine: input.lineStart, + endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined, + } + const { filename, part } = createFilePart(item, lineRange) + const index = store.visible === "@" ? store.index : props.input().cursorOffset + + command.keybinds(true) + setStore("visible", false) + setStore("index", index) + insertPart(filename, part) + } + const [files] = createResource( () => search(), async (query) => { @@ -254,18 +321,7 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...sortedFiles.map((item): AutocompleteOption => { - const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") - const fullPath = `${baseDir}/${item}` - const urlObj = pathToFileURL(fullPath) - let filename = item - if (lineRange && !item.endsWith("/")) { - filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` - urlObj.searchParams.set("start", String(lineRange.startLine)) - if (lineRange.endLine !== undefined) { - urlObj.searchParams.set("end", String(lineRange.endLine)) - } - } - const url = urlObj.href + const { filename, url, part } = createFilePart(item, lineRange) const isDir = item.endsWith("/") return { @@ -274,21 +330,7 @@ export function Autocomplete(props: { isDirectory: isDir, path: item, onSelect: () => { - insertPart(filename, { - type: "file", - mime: "text/plain", - filename, - url, - source: { - type: "file", - text: { - start: 0, - end: 0, - value: "", - }, - path: item, - }, - }) + insertPart(filename, part) }, } }), @@ -513,6 +555,14 @@ export function Autocomplete(props: { // kilocode_change end onMount(() => { + const unsubscribeMention = editor.onMention((mention) => { + insertFileMention(mention) + }) + + onCleanup(() => { + unsubscribeMention() + }) + props.ref({ get visible() { return store.visible diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f0fae2cecbd..6ac64fadade 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -12,6 +12,7 @@ import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" +import { useEditorContext } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -21,7 +22,7 @@ import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" -import { useRenderer, type JSX } from "@opentui/solid" +import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import * as Editor from "@tui/util/editor" import { useExit } from "../../context/exit" import * as Clipboard from "../../util/clipboard" @@ -94,6 +95,7 @@ export function Prompt(props: PromptProps) { const local = useLocal() const args = useArgs() const sdk = useSDK() + const editor = useEditorContext() const route = useRoute() const project = useProject() const sync = useSync() @@ -104,11 +106,34 @@ export function Prompt(props: PromptProps) { const stash = usePromptStash() const command = useCommandDialog() const renderer = useRenderer() + const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() const kv = useKV() const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) + const editorPath = createMemo(() => editor.selection()?.filePath) + const editorSelectionLabel = createMemo(() => { + const selection = editor.selection()?.selection + if (!selection) return + if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return + if (selection.start.line === selection.end.line) return `#${selection.start.line}` + return `#${selection.start.line}-${selection.end.line}` + }) + const editorFileLabel = createMemo(() => { + const value = editorPath() + if (!value) return + const filename = path.basename(value) + const file = /^index\.[^./]+$/.test(filename) + ? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/") + : filename + return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}` + }) + const editorFileLabelDisplay = createMemo(() => { + const file = editorFileLabel() + if (!file) return + return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) + }) const [auto, setAuto] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) @@ -731,6 +756,27 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode const variant = local.model.variant.current() + const editorSelection = editor.selection() + const editorParts = editorSelection + ? [ + { + id: PartID.ascending(), + type: "text" as const, + text: (() => { + const start = editorSelection.selection.start + const end = editorSelection.selection.end + if (start.line === end.line && start.character === end.character) { + return `Note: The user opened the file "${editorSelection.filePath}".` + } + if (start.line === end.line) { + return `Note: The user selected line ${start.line} from "${editorSelection.filePath}": ${editorSelection.text}` + } + return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}` + })(), + synthetic: true, + }, + ] + : [] if (store.mode === "shell") { void sdk.client.session.shell({ @@ -783,6 +829,7 @@ export function Prompt(props: PromptProps) { model: selectedModel, variant, parts: [ + ...editorParts, { id: PartID.ascending(), type: "text", @@ -1376,6 +1423,7 @@ export function Prompt(props: PromptProps) { {/* kilocode_change end */} + {(file) => {file()}} diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts new file mode 100644 index 00000000000..6e4b5b08b09 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -0,0 +1,319 @@ +import { readdirSync, readFileSync, statSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import z from "zod" +import { createSimpleContext } from "./helper" + +const MCP_PROTOCOL_VERSION = "2025-11-25" + +const JsonRpcMessageSchema = z.object({ + id: z.union([z.number(), z.string(), z.null()]).optional(), + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + error: z + .object({ + code: z.number().optional(), + message: z.string().optional(), + }) + .optional(), +}) + +const PositionSchema = z.object({ + line: z.number(), + character: z.number(), +}) + +const EditorSelectionSchema = z.object({ + text: z.string(), + filePath: z.string(), + selection: z.object({ + start: PositionSchema, + end: PositionSchema, + }), +}) + +const EditorMentionSchema = z.object({ + filePath: z.string(), + lineStart: z.number(), + lineEnd: z.number(), +}) + +const EditorServerInfoSchema = z.object({ + protocolVersion: z.string().optional(), + serverInfo: z + .object({ + name: z.string().optional(), + version: z.string().optional(), + }) + .optional(), +}) + +type JsonRpcMessage = z.infer +export type EditorSelection = z.infer +export type EditorMention = z.infer +type EditorServerInfo = z.infer + +type EditorConnection = { + url: string + authToken?: string + source: string +} + +type EditorLockFile = { + port: number + authToken?: string + transport?: string + workspaceFolders: string[] + mtimeMs: number +} + +export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({ + name: "EditorContext", + init: () => { + const mentionListeners = new Set<(mention: EditorMention) => void>() + const [store, setStore] = createStore<{ + status: "disabled" | "connecting" | "connected" + selection: EditorSelection | undefined + server: EditorServerInfo | undefined + }>({ + status: "disabled", + selection: undefined, + server: undefined, + }) + + onMount(() => { + let socket: WebSocket | undefined + let closed = false + let reconnect: ReturnType | undefined + let attempt = 0 + let requestID = 0 + const pending = new Map() + + const send = (payload: JsonRpcMessage) => { + if (!socket || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload })) + } + + const request = (method: string, params?: unknown) => { + requestID += 1 + pending.set(requestID, method) + send({ id: requestID, method, params }) + } + + const scheduleReconnect = (delay: number) => { + if (closed) return + if (reconnect) clearTimeout(reconnect) + reconnect = setTimeout(connect, delay) + } + + const connect = () => { + if (closed) return + + const connection = resolveEditorConnection() + if (!connection) { + setStore("status", "disabled") + scheduleReconnect(1000) + return + } + + setStore("status", "connecting") + const current = openEditorSocket(connection) + socket = current + + current.addEventListener("open", () => { + if (socket !== current) { + current.close() + return + } + + attempt = 0 + setStore("status", "connected") + request("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "opencode", version: "0.0.0" }, + }) + }) + + current.addEventListener("message", (event) => { + const message = parseMessage(event.data) + if (!message) return + + const selection = + message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined + if (selection?.success) { + setStore("selection", selection.data) + return + } + + const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined + if (mention?.success) { + mentionListeners.forEach((listener) => listener(mention.data)) + return + } + + if (typeof message.id !== "number") return + + const method = pending.get(message.id) + if (!method) return + + pending.delete(message.id) + if (message.error) return + + const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined + if (initialize?.success) { + setStore("server", initialize.data) + send({ method: "notifications/initialized" }) + return + } + }) + + current.addEventListener("close", () => { + if (socket !== current) return + + socket = undefined + pending.clear() + if (closed) return + + setStore("status", "connecting") + attempt += 1 + const delay = Math.min(1000 * 2 ** (attempt - 1), 30000) + scheduleReconnect(delay) + }) + } + + scheduleReconnect(0) + + onCleanup(() => { + closed = true + if (reconnect) clearTimeout(reconnect) + socket?.close() + }) + }) + + return { + enabled() { + return Boolean(resolveEditorConnection()) + }, + connected() { + return store.status === "connected" + }, + selection() { + return store.selection + }, + onMention(listener: (mention: EditorMention) => void) { + mentionListeners.add(listener) + return () => mentionListeners.delete(listener) + }, + server() { + return store.server + }, + } + }, +}) + +function parsePort(value: string | undefined) { + if (!value) return + + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return + return parsed +} + +function resolveEditorConnection(): EditorConnection | undefined { + const lock = resolveEditorLockFile() + if (lock) { + return { + url: `ws://127.0.0.1:${lock.port}`, + authToken: lock.authToken, + source: `lock:${lock.port}`, + } + } + + const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.KILO_EDITOR_SSE_PORT) + if (!port) return + return { + url: `ws://127.0.0.1:${port}`, + source: `env:${port}`, + } +} + +function resolveEditorLockFile() { + const directory = path.join(os.homedir(), ".claude", "ide") + let entries: string[] + + try { + entries = readdirSync(directory) + } catch { + return + } + + const cwd = process.cwd() + const locks = entries + .filter((entry) => entry.endsWith(".lock")) + .map((entry) => readEditorLockFile(path.join(directory, entry))) + .filter((entry): entry is EditorLockFile => Boolean(entry)) + .sort((left, right) => scoreEditorLock(right, cwd) - scoreEditorLock(left, cwd)) + + return locks[0] +} + +function readEditorLockFile(filePath: string): EditorLockFile | undefined { + const port = parsePort(path.basename(filePath, ".lock")) + if (!port) return + + try { + const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown + if (!isRecord(parsed)) return + if (parsed.transport !== undefined && parsed.transport !== "ws") return + + return { + port, + authToken: typeof parsed.authToken === "string" ? parsed.authToken : undefined, + transport: typeof parsed.transport === "string" ? parsed.transport : undefined, + workspaceFolders: Array.isArray(parsed.workspaceFolders) + ? parsed.workspaceFolders.filter((value): value is string => typeof value === "string") + : [], + mtimeMs: statSync(filePath).mtimeMs, + } + } catch { + return + } +} + +function scoreEditorLock(lock: EditorLockFile, cwd: string) { + const workspaceMatch = lock.workspaceFolders.some((folder) => pathContains(folder, cwd)) ? 1 : 0 + return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs +} + +function pathContains(parent: string, child: string) { + const relative = path.relative(path.resolve(parent), path.resolve(child)) + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) +} + +function openEditorSocket(connection: EditorConnection) { + if (!connection.authToken) return new WebSocket(connection.url) + + return new WebSocket(connection.url, { + headers: { + "x-claude-code-ide-authorization": connection.authToken, + }, + } as any) +} + +function parseMessage(value: unknown) { + if (typeof value !== "string") return + + try { + return JsonRpcMessageSchema.parse(JSON.parse(value)) + } catch { + return + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index fa164d53e83..ab85b1e6459 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,14 +1,14 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" -import z from "zod" +import { Schema } from "effect" export const TuiEvent = { - PromptAppend: BusEvent.define("tui.prompt.append", z.object({ text: z.string() })), + PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })), CommandExecute: BusEvent.define( "tui.command.execute", - z.object({ - command: z.union([ - z.enum([ + Schema.Struct({ + command: Schema.Union([ + Schema.Literals([ "session.list", "session.new", "session.share", @@ -26,23 +26,23 @@ export const TuiEvent = { "prompt.submit", "agent.cycle", ]), - z.string(), + Schema.String, ]), }), ), ToastShow: BusEvent.define( "tui.toast.show", - z.object({ - title: z.string().optional(), - message: z.string(), - variant: z.enum(["info", "success", "warning", "error"]), - duration: z.number().default(5000).optional().describe("Duration in milliseconds"), + Schema.Struct({ + title: Schema.optional(Schema.String), + message: Schema.String, + variant: Schema.Literals(["info", "success", "warning", "error"]), + duration: Schema.optional(Schema.Number).annotate({ description: "Duration in milliseconds" }), }), ), SessionSelect: BusEvent.define( "tui.session.select", - z.object({ - sessionID: SessionID.zod.describe("Session ID to navigate to"), + Schema.Struct({ + sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), }), ), } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index e074880165c..365859691b3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1363,7 +1363,17 @@ function UserMessage(props: { }) { const ctx = use() const local = useLocal() - const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0]) + const text = createMemo(() => { + const texts = props.parts + .map((x) => { + if (x.type === "text" && !x.synthetic) { + return x.text + } + return null + }) + .filter(Boolean) + return texts.join("\n\n") + }) const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) const { theme } = useTheme() const [hover, setHover] = createSignal(false) @@ -1398,7 +1408,7 @@ function UserMessage(props: { backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - {text()?.text} + {text()} diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 398d5c4a300..2557767a5e1 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -4,10 +4,10 @@ import { useTheme } from "@tui/context/theme" import { useTerminalDimensions } from "@opentui/solid" import { SplitBorder } from "../component/border" import { TextAttributes } from "@opentui/core" -import z from "zod" +import { Schema } from "effect" import { type TuiEvent } from "../event" -export type ToastOptions = z.infer +export type ToastOptions = Schema.Schema.Type export function Toast() { const toast = useToast() diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 23038966413..6b877e1c132 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -3,7 +3,7 @@ import { InstanceState } from "@/effect" import { EffectBridge } from "@/effect" import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema } from "effect" import z from "zod" import { Config } from "../config" import { MCP } from "../mcp" @@ -20,11 +20,11 @@ type State = { export const Event = { Executed: BusEvent.define( "command.executed", - z.object({ - name: z.string(), - sessionID: SessionID.zod, - arguments: z.string(), - messageID: MessageID.zod, + Schema.Struct({ + name: Schema.String, + sessionID: SessionID, + arguments: Schema.String, + messageID: MessageID, }), ), } diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 8ae4d3f818f..5e1d41a210b 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -4,6 +4,7 @@ import { Schema } from "effect" import z from "zod" import { Bus } from "@/bus" import { zod } from "@/util/effect-zod" +import { PositiveInt } from "@/util/schema" import { Log } from "../util" import { NamedError } from "@opencode-ai/shared/util/error" import { Glob } from "@opencode-ai/shared/util/glob" @@ -18,8 +19,6 @@ import type { Warning } from "./config" const log = Log.create({ service: "config" }) -const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) - const Color = Schema.Union([ Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 63d1126e152..cbb4c1191a9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -25,7 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { InstanceRef } from "@/effect/instance-ref" import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" @@ -104,9 +104,6 @@ export type Layout = ConfigLayout.Layout const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.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)) - // The Effect Schema is the canonical source of truth. The `.zod` compatibility // surface is derived so existing Hono validators keep working without a parallel // Zod definition. @@ -236,6 +233,19 @@ export const Info = Schema.Struct({ }), ), commit_message: KilocodeConfig.CommitMessageSchema, // kilocode_change + tool_output: Schema.optional( + Schema.Struct({ + max_lines: Schema.optional(PositiveInt).annotate({ + description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)", + }), + max_bytes: Schema.optional(PositiveInt).annotate({ + description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)", + }), + }), + ).annotate({ + description: + "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", + }), compaction: Schema.optional( Schema.Struct({ auto: Schema.optional(Schema.Boolean).annotate({ @@ -287,26 +297,9 @@ export const Info = Schema.Struct({ })), ) -// 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` -// 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 extends readonly [unknown, ...unknown[]] - ? { -readonly [K in keyof T]: DeepMutable } - : T extends readonly (infer U)[] - ? DeepMutable[] - : T extends object - ? { -readonly [K in keyof T]: DeepMutable } - : T - +// Uses the shared `DeepMutable` from `@/util/schema`. See the definition +// there for why the local variant is needed over `Types.DeepMutable` from +// effect-smol (the upstream version collapses `unknown` to `{}`). export type Info = DeepMutable> & { // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together // with the file and scope it came from so later runtime code can make location-sensitive decisions. diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index e6305449787..440904d7ce9 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,8 +1,6 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" - -const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) +import { PositiveInt, withStatics } from "@/util/schema" export const Model = Schema.Struct({ id: Schema.optional(Schema.String), diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 8ba60c3e585..b0116728a6c 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,9 +1,9 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { PositiveInt, withStatics } from "@/util/schema" export const Server = Schema.Struct({ - port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({ + port: Schema.optional(PositiveInt).annotate({ description: "Port to listen on", }), hostname: Schema.optional(Schema.String).annotate({ description: "Hostname to listen on" }), diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts index 291e392eabb..651d09cc211 100644 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ b/packages/opencode/src/control-plane/adaptors/index.ts @@ -1,12 +1,6 @@ import { lazy } from "@/util/lazy" import type { ProjectID } from "@/project/schema" -import type { WorkspaceAdaptor } from "../types" - -export type WorkspaceAdaptorEntry = { - type: string - name: string - description: string -} +import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types" const BUILTIN: Record Promise> = { worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 2bfb7debaaa..8d421b9a336 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,13 +1,15 @@ -import z from "zod" +import { Schema } from "effect" import { AppRuntime } from "@/effect/app-runtime" import { Worktree } from "@/worktree" import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -const WorktreeConfig = z.object({ - name: WorkspaceInfo.shape.name, - branch: WorkspaceInfo.shape.branch.unwrap(), - directory: WorkspaceInfo.shape.directory.unwrap(), -}) +const WorktreeConfig = Schema.Struct({ + name: WorkspaceInfo.fields.name, + branch: Schema.String, + directory: Schema.String, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) export const WorktreeAdaptor: WorkspaceAdaptor = { name: "Worktree", @@ -22,7 +24,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = { } }, async create(info) { - const config = WorktreeConfig.parse(info) + const config = WorktreeConfig.zod.parse(info) await AppRuntime.runPromise( Worktree.Service.use((svc) => svc.createFromInfo({ @@ -34,11 +36,11 @@ export const WorktreeAdaptor: WorkspaceAdaptor = { ) }, async remove(info) { - const config = WorktreeConfig.parse(info) + const config = WorktreeConfig.zod.parse(info) await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory }))) }, target(info) { - const config = WorktreeConfig.parse(info) + const config = WorktreeConfig.zod.parse(info) return { type: "local", directory: config.directory, diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 07acd5ce58d..af16c04902c 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,17 +1,28 @@ -import z from "zod" +import { Schema } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" +import { zod } from "@/util/effect-zod" +import { type DeepMutable, withStatics } from "@/util/schema" -export const WorkspaceInfo = z.object({ - id: WorkspaceID.zod, - type: z.string(), - name: z.string(), - branch: z.string().nullable(), - directory: z.string().nullable(), - extra: z.unknown().nullable(), - projectID: ProjectID.zod, +export const WorkspaceInfo = Schema.Struct({ + id: WorkspaceID, + type: Schema.String, + name: Schema.String, + branch: Schema.NullOr(Schema.String), + directory: Schema.NullOr(Schema.String), + extra: Schema.NullOr(Schema.Unknown), + projectID: ProjectID, }) -export type WorkspaceInfo = z.infer + .annotate({ identifier: "Workspace" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type WorkspaceInfo = DeepMutable> + +export const WorkspaceAdaptorEntry = Schema.Struct({ + type: Schema.String, + name: Schema.String, + description: Schema.String, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type WorkspaceAdaptorEntry = Schema.Schema.Type export type Target = | { diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index d472d897ed6..d380f4a28cb 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import { setTimeout as sleep } from "node:timers/promises" import { fn } from "@/util/fn" import { Database, asc, eq, inArray } from "@/storage" @@ -15,7 +15,7 @@ import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/shared/util/slug" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" -import { WorkspaceInfo } from "./types" +import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" import { Session } from "@/session" @@ -25,36 +25,36 @@ import { errorData } from "@/util/error" import { AppRuntime } from "@/effect/app-runtime" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" +import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod as effectZod, zodObject } from "@/util/effect-zod" -export const Info = WorkspaceInfo.meta({ - ref: "Workspace", +export const Info = WorkspaceInfoSchema +export type Info = WorkspaceInfo + +export const ConnectionStatus = Schema.Struct({ + workspaceID: WorkspaceID, + status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), }) -export type Info = z.infer +export type ConnectionStatus = Schema.Schema.Type -export const ConnectionStatus = z.object({ - workspaceID: WorkspaceID.zod, - status: z.enum(["connected", "connecting", "disconnected", "error"]), -}) -export type ConnectionStatus = z.infer - -const Restore = z.object({ - workspaceID: WorkspaceID.zod, - sessionID: SessionID.zod, - total: z.number().int().min(0), - step: z.number().int().min(0), +const Restore = Schema.Struct({ + workspaceID: WorkspaceID, + sessionID: SessionID, + total: NonNegativeInt, + step: NonNegativeInt, }) export const Event = { Ready: BusEvent.define( "workspace.ready", - z.object({ - name: z.string(), + Schema.Struct({ + name: Schema.String, }), ), Failed: BusEvent.define( "workspace.failed", - z.object({ - message: z.string(), + Schema.Struct({ + message: Schema.String, }), ), Restore: BusEvent.define("workspace.restore", Restore), @@ -73,15 +73,16 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { } } -const CreateInput = z.object({ - id: WorkspaceID.zod.optional(), - type: Info.shape.type, - branch: Info.shape.branch, - projectID: ProjectID.zod, - extra: Info.shape.extra, -}) +export const CreateInput = Schema.Struct({ + id: Schema.optional(WorkspaceID), + type: Info.fields.type, + branch: Info.fields.branch, + projectID: ProjectID, + extra: Info.fields.extra, +}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) +export type CreateInput = Schema.Schema.Type -export const create = fn(CreateInput, async (input) => { +export const create = fn(CreateInput.zod, async (input) => { const id = WorkspaceID.ascending(input.id) const adaptor = await getAdaptor(input.projectID, input.type) @@ -137,12 +138,13 @@ export const create = fn(CreateInput, async (input) => { return info }) -const SessionRestoreInput = z.object({ - workspaceID: WorkspaceID.zod, - sessionID: SessionID.zod, -}) +export const SessionRestoreInput = Schema.Struct({ + workspaceID: WorkspaceID, + sessionID: SessionID, +}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) +export type SessionRestoreInput = Schema.Schema.Type -export const sessionRestore = fn(SessionRestoreInput, async (input) => { +export const sessionRestore = fn(SessionRestoreInput.zod, async (input) => { log.info("session restore requested", { workspaceID: input.workspaceID, sessionID: input.sessionID, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 1b2fd96d025..890110e96b3 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -3,7 +3,7 @@ import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Git } from "@/git" -import { Effect, Layer, Context, Scope } from "effect" +import { Effect, Layer, Context, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { formatPatch, structuredPatch } from "diff" import { DiffFull } from "@/kilocode/snapshot/diff-full" // kilocode_change @@ -77,8 +77,8 @@ export type Content = z.infer export const Event = { Edited: BusEvent.define( "file.edited", - z.object({ - file: z.string(), + Schema.Struct({ + file: Schema.String, }), ), } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 7fa2752d73e..ef05fe1af48 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, Context } from "effect" +import { Cause, Effect, Layer, Context, Schema } from "effect" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" @@ -25,9 +25,9 @@ const SUBSCRIBE_TIMEOUT_MS = 10_000 export const Event = { Updated: BusEvent.define( "file.watcher.updated", - z.object({ - file: z.string(), - event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]), + Schema.Struct({ + file: Schema.String, + event: Schema.Literals(["add", "change", "unlink"]), }), ), } diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index 45cb51742e0..27e1d31e9c5 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,5 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import z from "zod" +import { Schema } from "effect" import { NamedError } from "@opencode-ai/shared/util/error" const SUPPORTED_IDES = [ @@ -13,8 +14,8 @@ const SUPPORTED_IDES = [ export const Event = { Installed: BusEvent.define( "ide.installed", - z.object({ - ide: z.string(), + Schema.Struct({ + ide: Schema.String, }), ), } diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d0fb8785188..81d7c1e478f 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -21,14 +21,14 @@ export type ReleaseType = "patch" | "minor" | "major" export const Event = { Updated: BusEvent.define( "installation.updated", - z.object({ - version: z.string(), + Schema.Struct({ + version: Schema.String, }), ), UpdateAvailable: BusEvent.define( "installation.update-available", - z.object({ - version: z.string(), + Schema.Struct({ + version: Schema.String, }), ), } @@ -132,6 +132,17 @@ export const layer: Layer.Layer Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })), ) + const viewVersion = Effect.fnUntraced(function* (method: "npm" | "pnpm" | "bun", spec: string) { + const args = method === "bun" ? ["pm", "view", spec, "version", "--json"] : ["view", spec, "version", "--json"] + const result = yield* run([method, ...args]) + if (result.code !== 0 || !result.stdout.trim()) { + return yield* new UpgradeFailedError({ + stderr: result.stderr || result.stdout || `Failed to resolve ${spec}`, + }) + } + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(result.stdout) + }) + const getBrewFormula = Effect.fnUntraced(function* () { // kilocode_change start const tapFormula = yield* text(["brew", "list", "--formula", "Kilo-Org/tap/kilo"]) @@ -222,15 +233,7 @@ export const layer: Layer.Layer export const ToolsChanged = BusEvent.define( "mcp.tools.changed", - z.object({ - server: z.string(), + Schema.Struct({ + server: Schema.String, }), ) export const BrowserOpenFailed = BusEvent.define( "mcp.browser.open.failed", - z.object({ - mcpName: z.string(), - url: z.string(), + Schema.Struct({ + mcpName: Schema.String, + url: Schema.String, }), ) diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index d6322d5488b..4b1f8070709 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -6,12 +6,14 @@ import npa from "npm-package-arg" import semver from "semver" import Config from "@npmcli/config" import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js" -import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" +import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Global } from "@opencode-ai/shared/global" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import * as CrossSpawnSpawner from "../effect/cross-spawn-spawner" import { makeRuntime } from "../effect/runtime" export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { @@ -106,7 +108,33 @@ export const layer = Layer.effect( const global = yield* Global.Service const fs = yield* FileSystem.FileSystem const flock = yield* EffectFlock.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) + const runView = Effect.fnUntraced(function* (cmd: string[]) { + const handle = yield* spawner.spawn( + ChildProcess.make(cmd[0], cmd.slice(1), { + extendEnv: true, + }), + ) + const [stdout, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + if (code !== 0 || !stdout.trim()) { + return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`) + } + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout) + }, Effect.scoped) + const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) { + return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe( + Effect.catch(() => + runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe( + Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])), + ), + ), + ) + }) const reify = (input: { dir: string; add?: string[] }) => Effect.gen(function* () { yield* flock.acquire(`npm-install:${input.dir}`) @@ -143,29 +171,15 @@ export const layer = Layer.effect( ) const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { - const response = yield* Effect.tryPromise({ - try: () => fetch(`https://registry.npmjs.org/${pkg}`), - catch: () => undefined, - }).pipe(Effect.orElseSucceed(() => undefined)) - - if (!response || !response.ok) { - return false - } - - const data = yield* Effect.tryPromise({ - try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>, - catch: () => undefined, - }).pipe(Effect.orElseSucceed(() => undefined)) - - const latestVersion = data?.["dist-tags"]?.latest - if (!latestVersion) { + const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option) + if (Option.isNone(latestVersion)) { return false } const range = /[\s^~*xX<>|=]/.test(cachedVersion) - if (range) return !semver.satisfies(latestVersion, cachedVersion) + if (range) return !semver.satisfies(latestVersion.value, cachedVersion) - return semver.lt(cachedVersion, latestVersion) + return semver.lt(cachedVersion, latestVersion.value) }) const add = Effect.fn("Npm.add")(function* (pkg: string) { @@ -304,6 +318,7 @@ export const defaultLayer = layer.pipe( Layer.provide(AppFileSystem.layer), Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 1fa06397799..1801ab9c7a7 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -79,16 +79,14 @@ export class Approval extends Schema.Class("PermissionApproval")({ } export const Event = { - Asked: BusEvent.define("permission.asked", Request.zod), + Asked: BusEvent.define("permission.asked", Request), Replied: BusEvent.define( "permission.replied", - zod( - Schema.Struct({ - sessionID: SessionID, - requestID: PermissionID, - reply: Reply, - }), - ), + Schema.Struct({ + sessionID: SessionID, + requestID: PermissionID, + reply: Reply, + }), ), } diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 23e8a32f8ff..7f4aed01358 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -54,7 +54,7 @@ export const Info = Schema.Struct({ export type Info = Types.DeepMutable> export const Event = { - Updated: BusEvent.define("project.updated", Info.zod), + Updated: BusEvent.define("project.updated", Info), } type Row = typeof ProjectTable.$inferSelect diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 289bf92050e..a84da1aa1b8 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Context, Stream, Scope } from "effect" +import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" import path from "path" import { Bus } from "@/bus" @@ -108,8 +108,8 @@ export type Mode = z.infer export const Event = { BranchUpdated: BusEvent.define( "vcs.branch.updated", - z.object({ - branch: z.string().optional(), + Schema.Struct({ + branch: Schema.optional(Schema.String), }), ), } diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 1f9f6bf62f4..c52472d2af1 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,13 +1,12 @@ import type { AuthOAuthResult, Hooks } from "@kilocode/plugin" -import { NamedError } from "@opencode-ai/shared/util/error" import { Auth } from "@/auth" import { InstanceState } from "@/effect" import { zod } from "@/util/effect-zod" +import { namedSchemaError } from "@/util/named-schema-error" import { withStatics } from "@/util/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" -import z from "zod" // kilocode_change start import { Telemetry } from "@kilocode/kilo-telemetry" @@ -76,22 +75,16 @@ export const CallbackInput = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type CallbackInput = Schema.Schema.Type -export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) +export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID }) -export const OauthCodeMissing = NamedError.create( - "ProviderAuthOauthCodeMissing", - z.object({ providerID: ProviderID.zod }), -) +export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID }) -export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) +export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {}) -export const ValidationFailed = NamedError.create( - "ProviderAuthValidationFailed", - z.object({ - field: z.string(), - message: z.string(), - }), -) +export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", { + field: Schema.String, + message: Schema.String, +}) export type Error = | Auth.AuthError diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index e61ef740975..2e5a27bf483 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -116,12 +116,13 @@ export type ParsedStreamError = | { type: "api_error" message: string - isRetryable: false + isRetryable: boolean responseBody: string } export function parseStreamError(input: unknown): ParsedStreamError | undefined { - const body = json(input) + const raw = json(input) + const body = typeof raw?.message === "string" ? (json(raw.message) ?? raw) : raw if (!body) return const responseBody = JSON.stringify(body) @@ -155,6 +156,13 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined isRetryable: false, responseBody, } + case "server_error": + return { + type: "api_error", + message: typeof body?.error?.message === "string" ? body?.error?.message : "Server error.", + isRetryable: true, + responseBody, + } } } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index b3c3359194b..786ad259c6d 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,7 +1,7 @@ import { Global } from "../global" import { Log } from "../util" import path from "path" -import z from "zod" +import { Schema } from "effect" import { Installation } from "../installation" import { Flag } from "../flag/flag" import { lazy } from "@/util/lazy" @@ -32,10 +32,6 @@ const normalizeKiloBaseURL = (baseURL: string | undefined, orgId: string | undef if (trimmed.endsWith("/api")) return `${trimmed}/openrouter` return `${trimmed}/api/openrouter` } - -export const Prompt = z.enum(PROMPTS) - -export const AiSdkProvider = z.enum(AI_SDK_PROVIDERS) // kilocode_change end const log = Log.create({ service: "models.dev" }) @@ -46,99 +42,91 @@ const filepath = path.join( ) const ttl = 5 * 60 * 1000 -type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] - -const JsonValue: z.ZodType = z.lazy(() => - z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]), -) - -const Cost = z.object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), +const Cost = Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache_read: Schema.optional(Schema.Number), + cache_write: Schema.optional(Schema.Number), + context_over_200k: Schema.optional( + Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache_read: Schema.optional(Schema.Number), + cache_write: Schema.optional(Schema.Number), + }), + ), }) -export const Model = z.object({ - id: z.string(), - name: z.string(), - family: z.string().optional(), - release_date: z.string(), - attachment: z.boolean(), - reasoning: z.boolean(), - temperature: z.boolean(), - tool_call: z.boolean(), - interleaved: z - .union([ - z.literal(true), - z - .object({ - field: z.enum(["reasoning_content", "reasoning_details"]), - }) - .strict(), - ]) - .optional(), - cost: Cost.optional(), - limit: z.object({ - context: z.number(), - input: z.number().optional(), - output: z.number(), +export const Model = Schema.Struct({ + id: Schema.String, + name: Schema.String, + family: Schema.optional(Schema.String), + release_date: Schema.String, + attachment: Schema.Boolean, + reasoning: Schema.Boolean, + temperature: Schema.Boolean, + tool_call: Schema.Boolean, + interleaved: Schema.optional( + Schema.Union([ + Schema.Literal(true), + Schema.Struct({ + field: Schema.Literals(["reasoning_content", "reasoning_details"]), + }), + ]), + ), + cost: Schema.optional(Cost), + limit: Schema.Struct({ + context: Schema.Number, + input: Schema.optional(Schema.Number), + output: Schema.Number, }), - modalities: z - .object({ - input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - }) - .optional(), - + modalities: Schema.optional( + Schema.Struct({ + input: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])), + output: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])), + }), + ), // kilocode_change start - recommendedIndex: z.number().optional(), - prompt: Prompt.optional().catch(undefined), - isFree: z.boolean().optional(), - ai_sdk_provider: AiSdkProvider.optional().catch(undefined), + recommendedIndex: Schema.optional(Schema.Number), + prompt: Schema.optional(Schema.Literals(PROMPTS)), + isFree: Schema.optional(Schema.Boolean), + ai_sdk_provider: Schema.optional(Schema.Literals(AI_SDK_PROVIDERS)), // kilocode_change end - - experimental: z - .object({ - modes: z - .record( - z.string(), - z.object({ - cost: Cost.optional(), - provider: z - .object({ - body: z.record(z.string(), JsonValue).optional(), - headers: z.record(z.string(), z.string()).optional(), - }) - .optional(), + experimental: Schema.optional( + Schema.Struct({ + modes: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + cost: Schema.optional(Cost), + provider: Schema.optional( + Schema.Struct({ + body: Schema.optional(Schema.Record(Schema.String, Schema.MutableJson)), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }), + ), }), - ) - .optional(), - }) - .optional(), - status: z.enum(["alpha", "beta", "deprecated"]).optional(), - provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), + ), + ), + }), + ), + status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + provider: Schema.optional( + Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), + ), }) -export type Model = z.infer +export type Model = Schema.Schema.Type -export const Provider = z.object({ - api: z.string().optional(), - name: z.string(), - env: z.array(z.string()), - id: z.string(), - npm: z.string().optional(), - models: z.record(z.string(), Model), +export const Provider = Schema.Struct({ + api: Schema.optional(Schema.String), + name: Schema.String, + env: Schema.Array(Schema.String), + id: Schema.String, + npm: Schema.optional(Schema.String), + models: Schema.Record(Schema.String, Model), }) -export type Provider = z.infer +export type Provider = Schema.Schema.Type function url() { return Flag.KILO_MODELS_URL || "https://models.dev" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 857c02416ea..ce00fb97a1f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,4 +1,3 @@ -import z from "zod" import os from "os" import fuzzysort from "fuzzysort" import { Config } from "../config" @@ -9,7 +8,6 @@ import { Npm } from "../npm" import { Hash } from "@opencode-ai/shared/util/hash" import { Plugin } from "../plugin" import { makeRuntime } from "@/effect/run-service" // kilocode_change -import { NamedError } from "@opencode-ai/shared/util/error" import { type LanguageModelV3 } from "@ai-sdk/provider" import * as ModelsDev from "./models" import { Auth } from "../auth" @@ -17,6 +15,7 @@ import { Env } from "../env" import { InstallationVersion } from "../installation/version" import { Flag } from "../flag/flag" import { zod } from "@/util/effect-zod" +import { namedSchemaError } from "@/util/named-schema-error" import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" @@ -1064,7 +1063,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { id: ProviderID.make(provider.id), source: "custom", name: provider.name, - env: provider.env ?? [], + env: [...(provider.env ?? [])], options: {}, models, } @@ -1782,18 +1781,12 @@ export function parseModel(model: string) { } } -export const ModelNotFoundError = NamedError.create( - "ProviderModelNotFoundError", - z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - suggestions: z.array(z.string()).optional(), - }), -) +export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", { + providerID: ProviderID, + modelID: ModelID, + suggestions: Schema.optional(Schema.Array(Schema.String)), +}) -export const InitError = NamedError.create( - "ProviderInitError", - z.object({ - providerID: ProviderID.zod, - }), -) +export const InitError = namedSchemaError("ProviderInitError", { + providerID: ProviderID, +}) diff --git a/packages/opencode/src/provider/sdk/copilot/AGENTS.md b/packages/opencode/src/provider/sdk/copilot/AGENTS.md new file mode 120000 index 00000000000..42061c01a1c --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/opencode/src/provider/sdk/copilot/README.md b/packages/opencode/src/provider/sdk/copilot/README.md index 8ce03d61407..d1051a4da04 100644 --- a/packages/opencode/src/provider/sdk/copilot/README.md +++ b/packages/opencode/src/provider/sdk/copilot/README.md @@ -1,5 +1,5 @@ This is a temporary package used primarily for GitHub Copilot compatibility. -Avoid making changes to these files unless you only want to affect the Copilot provider. +These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!! -Also, this should ONLY be used for the Copilot provider. +Avoid making edits to these files diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 8f2c7184d95..b4194ca140c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -220,8 +220,6 @@ function normalizeMessages( // Filter out reasoning parts from content const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") - // kilocode_change start - cherry-picked from anomalyco/opencode#24146; - // will be reverted on the next wholesale upstream merge. // Include reasoning_content | reasoning_details directly on the message for all assistant messages. // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty // reasoning_content which still needs to be sent back in subsequent requests. @@ -236,7 +234,6 @@ function normalizeMessages( }, }, } - // kilocode_change end } return msg @@ -452,12 +449,10 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) - // kilocode_change end case "@ai-sdk/azure": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index f3502a40b1d..be296b421c6 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -3,13 +3,14 @@ import { Bus } from "@/bus" import { InstanceState } from "@/effect" import { Instance } from "@/project/instance" import type { Proc } from "#pty" -import z from "zod" import { Log } from "../util" import { lazy } from "@opencode-ai/shared/util/lazy" import { Shell } from "@/shell/shell" import { Plugin } from "@/plugin" import { PtyID } from "./schema" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema, Types } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import { EffectBridge } from "@/effect" const log = Log.create({ service: "pty" }) @@ -53,47 +54,47 @@ const meta = (cursor: number) => { const pty = lazy(() => import("#pty")) -export const Info = z - .object({ - id: PtyID.zod, - title: z.string(), - command: z.string(), - args: z.array(z.string()), - cwd: z.string(), - status: z.enum(["running", "exited"]), - pid: z.number(), - }) - .meta({ ref: "Pty" }) - -export type Info = z.infer - -export const CreateInput = z.object({ - command: z.string().optional(), - args: z.array(z.string()).optional(), - cwd: z.string().optional(), - title: z.string().optional(), - env: z.record(z.string(), z.string()).optional(), +export const Info = Schema.Struct({ + id: PtyID, + title: Schema.String, + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.String, + status: Schema.Literals(["running", "exited"]), + pid: Schema.Number, }) + .annotate({ identifier: "Pty" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type CreateInput = z.infer +export type Info = Types.DeepMutable> -export const UpdateInput = z.object({ - title: z.string().optional(), - size: z - .object({ - rows: z.number(), - cols: z.number(), - }) - .optional(), -}) +export const CreateInput = Schema.Struct({ + command: Schema.optional(Schema.String), + args: Schema.optional(Schema.Array(Schema.String)), + cwd: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type UpdateInput = z.infer +export type CreateInput = Types.DeepMutable> + +export const UpdateInput = Schema.Struct({ + title: Schema.optional(Schema.String), + size: Schema.optional( + Schema.Struct({ + rows: Schema.Number, + cols: Schema.Number, + }), + ), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) + +export type UpdateInput = Types.DeepMutable> export const Event = { - Created: BusEvent.define("pty.created", z.object({ info: Info })), - Updated: BusEvent.define("pty.updated", z.object({ info: Info })), - Exited: BusEvent.define("pty.exited", z.object({ id: PtyID.zod, exitCode: z.number() })), - Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })), + Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })), + Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })), + Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: Schema.Number })), + Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })), } export interface Interface { diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 4fd6501e90c..96709f9befb 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -117,9 +117,9 @@ class Rejected extends Schema.Class("QuestionRejected")({ }) {} export const Event = { - Asked: BusEvent.define("question.asked", Request.zod), - Replied: BusEvent.define("question.replied", zod(Replied)), - Rejected: BusEvent.define("question.rejected", zod(Rejected)), + Asked: BusEvent.define("question.asked", Request), + Replied: BusEvent.define("question.replied", Replied), + Rejected: BusEvent.define("question.rejected", Rejected), } export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { @@ -226,7 +226,7 @@ export const layer = Layer.effect( yield* bus.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, - answers: input.answers, + answers: input.answers.map((a) => [...a]), }) yield* Deferred.succeed(existing.deferred, input.answers) }) diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts index 48d40ef6a55..bd7f0eed65a 100644 --- a/packages/opencode/src/server/event.ts +++ b/packages/opencode/src/server/event.ts @@ -1,10 +1,10 @@ import { BusEvent } from "@/bus/bus-event" -import z from "zod" +import { Schema } from "effect" export const Event = { - Connected: BusEvent.define("server.connected", z.object({})), - Disposed: BusEvent.define("global.disposed", z.object({})), + Connected: BusEvent.define("server.connected", Schema.Struct({})), + Disposed: BusEvent.define("global.disposed", Schema.Struct({})), // kilocode_change start — emitted when config is updated without a full dispose - ConfigUpdated: BusEvent.define("global.config.updated", z.object({})), + ConfigUpdated: BusEvent.define("global.config.updated", Schema.Struct({})), // kilocode_change end } diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index cfecce52659..18c273d587b 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -1,4 +1,3 @@ -import z from "zod" import sessionProjectors from "../session/projectors" import { SyncEvent } from "@/sync" import { Session } from "@/session" @@ -10,7 +9,7 @@ export function initProjectors() { projectors: sessionProjectors, convertEvent: (type, data) => { if (type === "session.updated") { - const id = (data as z.infer).sessionID + const id = (data as SyncEvent.Event["data"]).sessionID const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) if (!row) return data diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 9ff747b68a5..bf5584347d3 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -3,6 +3,8 @@ import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" import { listAdaptors } from "@/control-plane/adaptors" import { Workspace } from "@/control-plane/workspace" +import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" import { errors } from "../../error" import { lazy } from "@/util/lazy" @@ -24,15 +26,7 @@ export const WorkspaceRoutes = lazy(() => description: "Workspace adaptors", content: { "application/json": { - schema: resolver( - z.array( - z.object({ - type: z.string(), - name: z.string(), - description: z.string(), - }), - ), - ), + schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))), }, }, }, @@ -53,7 +47,7 @@ export const WorkspaceRoutes = lazy(() => description: "Workspace created", content: { "application/json": { - schema: resolver(Workspace.Info), + schema: resolver(Workspace.Info.zod), }, }, }, @@ -62,12 +56,12 @@ export const WorkspaceRoutes = lazy(() => }), validator( "json", - Workspace.create.schema.omit({ + Workspace.CreateInput.zodObject.omit({ projectID: true, }), ), async (c) => { - const body = c.req.valid("json") + const body = c.req.valid("json") as Omit const workspace = await Workspace.create({ projectID: Instance.project.id, ...body, @@ -86,7 +80,7 @@ export const WorkspaceRoutes = lazy(() => description: "Workspaces", content: { "application/json": { - schema: resolver(z.array(Workspace.Info)), + schema: resolver(z.array(Workspace.Info.zod)), }, }, }, @@ -107,7 +101,7 @@ export const WorkspaceRoutes = lazy(() => description: "Workspace status", content: { "application/json": { - schema: resolver(z.array(Workspace.ConnectionStatus)), + schema: resolver(z.array(zodObject(Workspace.ConnectionStatus))), }, }, }, @@ -129,7 +123,7 @@ export const WorkspaceRoutes = lazy(() => description: "Workspace removed", content: { "application/json": { - schema: resolver(Workspace.Info.optional()), + schema: resolver(Workspace.Info.zod.optional()), }, }, }, @@ -139,7 +133,7 @@ export const WorkspaceRoutes = lazy(() => validator( "param", z.object({ - id: Workspace.Info.shape.id, + id: zodObject(Workspace.Info).shape.id, }), ), async (c) => { @@ -169,11 +163,11 @@ export const WorkspaceRoutes = lazy(() => ...errors(400), }, }), - validator("param", z.object({ id: Workspace.Info.shape.id })), - validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })), + validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })), + validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })), async (c) => { const { id } = c.req.valid("param") - const body = c.req.valid("json") + const body = c.req.valid("json") as Omit log.info("session restore route requested", { workspaceID: id, sessionID: body.sessionID, diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index d10f2cd522f..fc123c2fd6f 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -1,7 +1,7 @@ import { Hono, type Context } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import { streamSSE } from "hono/streaming" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" @@ -18,7 +18,7 @@ import { errors } from "../error" const log = Log.create({ service: "server" }) -export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({})) +export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({})) async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { return streamSSE(c, async (stream) => { diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index 8c8c1a507b9..ef607ac3209 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -1,6 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" +import * as EffectZod from "@/util/effect-zod" import { ProviderID, ModelID } from "@/provider/schema" import { ToolRegistry } from "@/tool" import { Worktree } from "@/worktree" @@ -220,7 +221,7 @@ export const ExperimentalRoutes = lazy(() => tools.map((t) => ({ id: t.id, description: t.description, - parameters: z.toJSONSchema(t.parameters), + parameters: EffectZod.toJsonSchema(t.parameters), })), ) }, @@ -456,7 +457,7 @@ export const ExperimentalRoutes = lazy(() => description: "List of sessions", content: { "application/json": { - schema: resolver(Session.GlobalInfo.array()), + schema: resolver(Session.GlobalInfo.zod.array()), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 1f2bd886940..4610f25e3e4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -14,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission" import { ProjectApi, projectHandlers } from "./project" import { ProviderApi, providerHandlers } from "./provider" import { QuestionApi, questionHandlers } from "./question" +import { WorkspaceApi, workspaceHandlers } from "./workspace" import { memoMap } from "@/effect/memo-map" const Query = Schema.Struct({ @@ -112,6 +113,7 @@ const PermissionSecured = PermissionApi.middleware(Authorization) const ProjectSecured = ProjectApi.middleware(Authorization) const ProviderSecured = ProviderApi.middleware(Authorization) const ConfigSecured = ConfigApi.middleware(Authorization) +const WorkspaceSecured = WorkspaceApi.middleware(Authorization) export const routes = Layer.mergeAll( HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)), @@ -119,6 +121,7 @@ export const routes = Layer.mergeAll( HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)), HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)), HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)), + HttpApiBuilder.layer(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)), ).pipe( Layer.provide(auth), Layer.provide(normalize), diff --git a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts new file mode 100644 index 00000000000..596545073e1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts @@ -0,0 +1,82 @@ +import { listAdaptors } from "@/control-plane/adaptors" +import { Workspace } from "@/control-plane/workspace" +import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import * as InstanceState from "@/effect/instance-state" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/experimental/workspace" +export const WorkspacePaths = { + adaptors: `${root}/adaptor`, + list: root, + status: `${root}/status`, +} as const + +export const WorkspaceApi = HttpApi.make("workspace") + .add( + HttpApiGroup.make("workspace") + .add( + HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { + success: Schema.Array(WorkspaceAdaptorEntry), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.adaptor.list", + summary: "List workspace adaptors", + description: "List all available workspace adaptors for the current project.", + }), + ), + HttpApiEndpoint.get("list", WorkspacePaths.list, { + success: Schema.Array(Workspace.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.list", + summary: "List workspaces", + description: "List all workspaces.", + }), + ), + HttpApiEndpoint.get("status", WorkspacePaths.status, { + success: Schema.Array(Workspace.ConnectionStatus), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.status", + summary: "Workspace status", + description: "Get connection status for workspaces in the current project.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "workspace", + description: "Experimental HttpApi workspace routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const workspaceHandlers = Layer.unwrap( + Effect.gen(function* () { + const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + const ctx = yield* InstanceState.context + return yield* Effect.promise(() => listAdaptors(ctx.project.id)) + }) + + const list = Effect.fn("WorkspaceHttpApi.list")(function* () { + return Workspace.list((yield* InstanceState.context).project) + }) + + const status = Effect.fn("WorkspaceHttpApi.status")(function* () { + const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) + return Workspace.status().filter((item) => ids.has(item.workspaceID)) + }) + + return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) => + handlers.handle("adaptors", adaptors).handle("list", list).handle("status", status), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts index a25b66e9ffd..51c46992416 100644 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -23,7 +23,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { description: "List of sessions", content: { "application/json": { - schema: resolver(Pty.Info.array()), + schema: resolver(Pty.Info.zod.array()), }, }, }, @@ -46,18 +46,18 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { description: "Created session", content: { "application/json": { - schema: resolver(Pty.Info), + schema: resolver(Pty.Info.zod), }, }, }, ...errors(400), }, }), - validator("json", Pty.CreateInput), + validator("json", Pty.CreateInput.zod), async (c) => jsonRequest("PtyRoutes.create", c, function* () { const pty = yield* Pty.Service - return yield* pty.create(c.req.valid("json")) + return yield* pty.create(c.req.valid("json") as Pty.CreateInput) }), ) .get( @@ -71,7 +71,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { description: "Session info", content: { "application/json": { - schema: resolver(Pty.Info), + schema: resolver(Pty.Info.zod), }, }, }, @@ -105,7 +105,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { description: "Updated session", content: { "application/json": { - schema: resolver(Pty.Info), + schema: resolver(Pty.Info.zod), }, }, }, @@ -113,11 +113,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }), validator("param", z.object({ ptyID: PtyID.zod })), - validator("json", Pty.UpdateInput), + validator("json", Pty.UpdateInput.zod), async (c) => jsonRequest("PtyRoutes.update", c, function* () { const pty = yield* Pty.Service - return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json")) + return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json") as Pty.UpdateInput) }), ) .delete( diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 7404a5f4b08..c42d9c6b26c 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -23,6 +23,7 @@ import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" +import { zodObject } from "@/util/effect-zod" import { Bus } from "@/bus" import { NamedError } from "@opencode-ai/shared/util/error" import { jsonRequest, runRequest } from "./trace" @@ -42,7 +43,7 @@ export const SessionRoutes = lazy(() => description: "List of sessions", content: { "application/json": { - schema: resolver(Session.Info.array()), + schema: resolver(Session.Info.zod.array()), }, }, }, @@ -87,7 +88,7 @@ export const SessionRoutes = lazy(() => description: "Get session status", content: { "application/json": { - schema: resolver(z.record(z.string(), SessionStatus.Info)), + schema: resolver(z.record(z.string(), SessionStatus.Info.zod)), }, }, }, @@ -112,7 +113,7 @@ export const SessionRoutes = lazy(() => description: "Get session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -122,7 +123,7 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.GetInput, + sessionID: Session.GetInput.zod, }), ), async (c) => { @@ -145,7 +146,7 @@ export const SessionRoutes = lazy(() => description: "List of children", content: { "application/json": { - schema: resolver(Session.Info.array()), + schema: resolver(Session.Info.zod.array()), }, }, }, @@ -155,7 +156,7 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.ChildrenInput, + sessionID: Session.ChildrenInput.zod, }), ), async (c) => { @@ -177,7 +178,7 @@ export const SessionRoutes = lazy(() => description: "Todo list", content: { "application/json": { - schema: resolver(Todo.Info.array()), + schema: resolver(Todo.Info.zod.array()), }, }, }, @@ -210,13 +211,13 @@ export const SessionRoutes = lazy(() => description: "Successfully created session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, }, }), - validator("json", Session.CreateInput), + validator("json", Session.CreateInput.zod), async (c) => jsonRequest("SessionRoutes.create", c, function* () { const body = c.req.valid("json") ?? {} @@ -245,7 +246,7 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.RemoveInput, + sessionID: Session.RemoveInput.zod, }), ), async (c) => @@ -267,7 +268,7 @@ export const SessionRoutes = lazy(() => description: "Successfully updated session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -375,7 +376,7 @@ export const SessionRoutes = lazy(() => description: "200", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -384,14 +385,14 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.ForkInput.shape.sessionID, + sessionID: SessionID.zod, }), ), - validator("json", Session.ForkInput.omit({ sessionID: true })), + validator("json", zodObject(Session.ForkInput).omit({ sessionID: true })), async (c) => jsonRequest("SessionRoutes.fork", c, function* () { const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") + const body = c.req.valid("json") as { messageID?: MessageID } const svc = yield* Session.Service return yield* svc.fork({ ...body, sessionID }) }), @@ -438,7 +439,7 @@ export const SessionRoutes = lazy(() => description: "Successfully shared session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -480,18 +481,13 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: SessionSummary.DiffInput.shape.sessionID, - }), - ), - validator( - "query", - z.object({ - messageID: SessionSummary.DiffInput.shape.messageID, + sessionID: SessionID.zod, }), ), + validator("query", zodObject(SessionSummary.DiffInput).omit({ sessionID: true })), async (c) => jsonRequest("SessionRoutes.diff", c, function* () { - const query = c.req.valid("query") + const query = c.req.valid("query") as Omit const params = c.req.valid("param") const summary = yield* SessionSummary.Service return yield* summary.diff({ @@ -511,7 +507,7 @@ export const SessionRoutes = lazy(() => description: "Successfully unshared session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -872,7 +868,7 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), async (c) => { c.status(200) c.header("Content-Type", "application/json") @@ -910,7 +906,7 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") @@ -960,11 +956,11 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), + validator("json", zodObject(SessionPrompt.CommandInput).omit({ sessionID: true })), async (c) => jsonRequest("SessionRoutes.command", c, function* () { const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") + const body = c.req.valid("json") as Omit const svc = yield* SessionPrompt.Service return yield* svc.command({ ...body, sessionID }) }), @@ -993,11 +989,11 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), + validator("json", zodObject(SessionPrompt.ShellInput).omit({ sessionID: true })), async (c) => jsonRequest("SessionRoutes.shell", c, function* () { const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") + const body = c.req.valid("json") as Omit const svc = yield* SessionPrompt.Service return yield* svc.shell({ ...body, sessionID }) }), @@ -1013,7 +1009,7 @@ export const SessionRoutes = lazy(() => description: "Updated session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, @@ -1026,16 +1022,14 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), + validator("json", zodObject(SessionRevert.RevertInput).omit({ sessionID: true })), async (c) => { const sessionID = c.req.valid("param").sessionID - log.info("revert", c.req.valid("json")) + const body = c.req.valid("json") as Omit + log.info("revert", body) return jsonRequest("SessionRoutes.revert", c, function* () { const svc = yield* SessionRevert.Service - return yield* svc.revert({ - sessionID, - ...c.req.valid("json"), - }) + return yield* svc.revert({ sessionID, ...body }) }) }, ) @@ -1050,7 +1044,7 @@ export const SessionRoutes = lazy(() => description: "Updated session", content: { "application/json": { - schema: resolver(Session.Info), + schema: resolver(Session.Info.zod), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index d6add67b974..932cf509eb7 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -1,9 +1,12 @@ import { Hono, type Context } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" +import { Schema } from "effect" import z from "zod" import { Bus } from "@/bus" import { Session } from "@/session" +import type { SessionID } from "@/session/schema" import { TuiEvent } from "@/cli/cmd/tui/event" +import { zodObject } from "@/util/effect-zod" import { AsyncQueue } from "@/util/queue" import { errors } from "../../error" import { lazy } from "@/util/lazy" @@ -96,9 +99,9 @@ export const TuiRoutes = lazy(() => ...errors(400), }, }), - validator("json", TuiEvent.PromptAppend.properties), + validator("json", zodObject(TuiEvent.PromptAppend.properties)), async (c) => { - await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json")) + await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json") as { text: string }) return c.json(true) }, ) @@ -305,9 +308,12 @@ export const TuiRoutes = lazy(() => }, }, }), - validator("json", TuiEvent.ToastShow.properties), + validator("json", zodObject(TuiEvent.ToastShow.properties)), async (c) => { - await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) + await Bus.publish( + TuiEvent.ToastShow, + c.req.valid("json") as Schema.Schema.Type, + ) return c.json(true) }, ) @@ -336,7 +342,7 @@ export const TuiRoutes = lazy(() => return z .object({ type: z.literal(def.type), - properties: def.properties, + properties: zodObject(def.properties), }) .meta({ ref: `Event.${def.type}`, @@ -345,8 +351,9 @@ export const TuiRoutes = lazy(() => ), ), async (c) => { - const evt = c.req.valid("json") - await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties) + const evt = c.req.valid("json") as { type: string; properties: Record } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)! as any, evt.properties as any) return c.json(true) }, ) @@ -368,9 +375,9 @@ export const TuiRoutes = lazy(() => ...errors(400, 404), }, }), - validator("json", TuiEvent.SessionSelect.properties), + validator("json", zodObject(TuiEvent.SessionSelect.properties)), async (c) => { - const { sessionID } = c.req.valid("json") + const { sessionID } = c.req.valid("json") as { sessionID: SessionID } await runRequest( "TuiRoutes.sessionSelect", c, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 70969b953f9..63c5ca75b5b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -17,6 +17,9 @@ import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import * as KiloServer from "@/kilocode/server/server" // kilocode_change +import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" +import { WorkspacePaths } from "./routes/instance/httpapi/workspace" +import { Context } from "effect" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -55,16 +58,24 @@ function create(opts: { cors?: string[] }) { } } + const workspaceApp = new Hono() + const workspaceLegacyApp = new Hono() + .use(InstanceMiddleware()) + .route("/experimental/workspace", WorkspaceRoutes()) + .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) + if (Flag.KILO_EXPERIMENTAL_HTTPAPI) { + const handler = ExperimentalHttpApiServer.webHandler().handler + const context = Context.empty() as Context.Context + workspaceApp.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context)) + workspaceApp.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + workspaceApp.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) + } + workspaceApp.route("/", workspaceLegacyApp) + return { app: app .route("/", ControlPlaneRoutes()) - .route( - "/", - new Hono() - .use(InstanceMiddleware()) - .route("/experimental/workspace", WorkspaceRoutes()) - .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)), - ) + .route("/", workspaceApp) .route("/", InstanceRoutes(runtime.upgradeWebSocket)) .route("/", UIRoutes()), runtime, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index defdb870d7d..dc126e6837d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -13,7 +13,7 @@ import { Plugin } from "@/plugin" import { Config } from "@/config" import { NotFoundError } from "@/storage" import { ModelID, ProviderID } from "@/provider/schema" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" @@ -24,8 +24,8 @@ const log = Log.create({ service: "session.compaction" }) export const Event = { Compacted: BusEvent.define( "session.compacted", - z.object({ - sessionID: SessionID.zod, + Schema.Struct({ + sessionID: SessionID, }), ), } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 0687f166cc8..acf80147474 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -18,7 +18,7 @@ import { ModelID, ProviderID } from "@/provider/schema" import { SessionNetwork } from "./network" // kilocode_change import { Effect, Schema, Types } from "effect" import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { namedSchemaError } from "@/util/named-schema-error" import { EffectLogger } from "@/effect" @@ -65,9 +65,7 @@ export class OutputFormatText extends Schema.Class("OutputForm export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ type: Schema.Literal("json_schema"), schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), - retryCount: Schema.Number.check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(0)) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), + retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), }) { static readonly zod = zod(this) } @@ -139,8 +137,8 @@ export type ReasoningPart = Types.DeepMutable ({ zod: zod(s) }))) @@ -197,8 +195,8 @@ export const AgentPart = Schema.Struct({ source: Schema.optional( Schema.Struct({ value: Schema.String, - start: Schema.Number.check(Schema.isInt()), - end: Schema.Number.check(Schema.isInt()), + start: Schema.Int, + end: Schema.Int, }), ), }) @@ -512,8 +510,8 @@ export const AgentPartInput = Schema.Struct({ source: Schema.optional( Schema.Struct({ value: Schema.String, - start: Schema.Number.check(Schema.isInt()), - end: Schema.Number.check(Schema.isInt()), + start: Schema.Int, + end: Schema.Int, }), ), }) @@ -587,54 +585,62 @@ export const Info = Object.assign(_Info, { }) export type Info = User | Assistant +const UpdatedEventSchema = Schema.Struct({ + sessionID: SessionID, + info: _Info, +}) + +const RemovedEventSchema = Schema.Struct({ + sessionID: SessionID, + messageID: MessageID, +}) + +const PartUpdatedEventSchema = Schema.Struct({ + sessionID: SessionID, + part: _Part, + time: Schema.Number, +}) + +const PartRemovedEventSchema = Schema.Struct({ + sessionID: SessionID, + messageID: MessageID, + partID: PartID, +}) + export const Event = { Updated: SyncEvent.define({ type: "message.updated", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: Info.zod, - }), + schema: UpdatedEventSchema, }), Removed: SyncEvent.define({ type: "message.removed", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), + schema: RemovedEventSchema, }), PartUpdated: SyncEvent.define({ type: "message.part.updated", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - part: Part.zod, - time: z.number(), - }), + schema: PartUpdatedEventSchema, }), PartDelta: BusEvent.define( "message.part.delta", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - field: z.string(), - delta: z.string(), + Schema.Struct({ + sessionID: SessionID, + messageID: MessageID, + partID: PartID, + field: Schema.String, + delta: Schema.String, }), ), PartRemoved: SyncEvent.define({ type: "message.part.removed", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), + schema: PartRemovedEventSchema, }), } diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index ced04b8e9db..b1b24534316 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -1,191 +1,192 @@ -import z from "zod" +import { Schema } from "effect" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" -import { NamedError } from "@opencode-ai/shared/util/error" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" +import { namedSchemaError } from "@/util/named-schema-error" -export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) -export const AuthError = NamedError.create( - "ProviderAuthError", - z.object({ - providerID: z.string(), - message: z.string(), - }), -) - -export const ToolCall = z - .object({ - state: z.literal("call"), - step: z.number().optional(), - toolCallId: z.string(), - toolName: z.string(), - args: z.custom>(), - }) - .meta({ - ref: "ToolCall", - }) -export type ToolCall = z.infer - -export const ToolPartialCall = z - .object({ - state: z.literal("partial-call"), - step: z.number().optional(), - toolCallId: z.string(), - toolName: z.string(), - args: z.custom>(), - }) - .meta({ - ref: "ToolPartialCall", - }) -export type ToolPartialCall = z.infer - -export const ToolResult = z - .object({ - state: z.literal("result"), - step: z.number().optional(), - toolCallId: z.string(), - toolName: z.string(), - args: z.custom>(), - result: z.string(), - }) - .meta({ - ref: "ToolResult", - }) -export type ToolResult = z.infer - -export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({ - ref: "ToolInvocation", +export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) +export const AuthError = namedSchemaError("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, }) -export type ToolInvocation = z.infer -export const TextPart = z - .object({ - type: z.literal("text"), - text: z.string(), - }) - .meta({ - ref: "TextPart", - }) -export type TextPart = z.infer +const AuthErrorEffect = Schema.Struct({ + name: Schema.Literal("ProviderAuthError"), + data: Schema.Struct({ + providerID: Schema.String, + message: Schema.String, + }), +}) -export const ReasoningPart = z - .object({ - type: z.literal("reasoning"), - text: z.string(), - providerMetadata: z.record(z.string(), z.any()).optional(), - }) - .meta({ - ref: "ReasoningPart", - }) -export type ReasoningPart = z.infer +const OutputLengthErrorEffect = Schema.Struct({ + name: Schema.Literal("MessageOutputLengthError"), + data: Schema.Struct({}), +}) -export const ToolInvocationPart = z - .object({ - type: z.literal("tool-invocation"), - toolInvocation: ToolInvocation, - }) - .meta({ - ref: "ToolInvocationPart", - }) -export type ToolInvocationPart = z.infer +const UnknownErrorEffect = Schema.Struct({ + name: Schema.Literal("UnknownError"), + data: Schema.Struct({ + message: Schema.String, + }), +}) -export const SourceUrlPart = z - .object({ - type: z.literal("source-url"), - sourceId: z.string(), - url: z.string(), - title: z.string().optional(), - providerMetadata: z.record(z.string(), z.any()).optional(), - }) - .meta({ - ref: "SourceUrlPart", - }) -export type SourceUrlPart = z.infer +export const ToolCall = Schema.Struct({ + state: Schema.Literal("call"), + step: Schema.optional(Schema.Number), + toolCallId: Schema.String, + toolName: Schema.String, + args: Schema.Unknown, +}) + .annotate({ identifier: "ToolCall" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolCall = Schema.Schema.Type -export const FilePart = z - .object({ - type: z.literal("file"), - mediaType: z.string(), - filename: z.string().optional(), - url: z.string(), - }) - .meta({ - ref: "FilePart", - }) -export type FilePart = z.infer +export const ToolPartialCall = Schema.Struct({ + state: Schema.Literal("partial-call"), + step: Schema.optional(Schema.Number), + toolCallId: Schema.String, + toolName: Schema.String, + args: Schema.Unknown, +}) + .annotate({ identifier: "ToolPartialCall" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolPartialCall = Schema.Schema.Type -export const StepStartPart = z - .object({ - type: z.literal("step-start"), - }) - .meta({ - ref: "StepStartPart", - }) -export type StepStartPart = z.infer +export const ToolResult = Schema.Struct({ + state: Schema.Literal("result"), + step: Schema.optional(Schema.Number), + toolCallId: Schema.String, + toolName: Schema.String, + args: Schema.Unknown, + result: Schema.String, +}) + .annotate({ identifier: "ToolResult" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolResult = Schema.Schema.Type -export const MessagePart = z - .discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart]) - .meta({ - ref: "MessagePart", - }) -export type MessagePart = z.infer +export const ToolInvocation = Schema.Union([ToolCall, ToolPartialCall, ToolResult]) + .annotate({ identifier: "ToolInvocation", discriminator: "state" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolInvocation = Schema.Schema.Type -export const Info = z - .object({ - id: z.string(), - role: z.enum(["user", "assistant"]), - parts: z.array(MessagePart), - metadata: z - .object({ - time: z.object({ - created: z.number(), - completed: z.number().optional(), +export const TextPart = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, +}) + .annotate({ identifier: "TextPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type TextPart = Schema.Schema.Type + +export const ReasoningPart = Schema.Struct({ + type: Schema.Literal("reasoning"), + text: Schema.String, + providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) + .annotate({ identifier: "ReasoningPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ReasoningPart = Schema.Schema.Type + +export const ToolInvocationPart = Schema.Struct({ + type: Schema.Literal("tool-invocation"), + toolInvocation: ToolInvocation, +}) + .annotate({ identifier: "ToolInvocationPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolInvocationPart = Schema.Schema.Type + +export const SourceUrlPart = Schema.Struct({ + type: Schema.Literal("source-url"), + sourceId: Schema.String, + url: Schema.String, + title: Schema.optional(Schema.String), + providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) + .annotate({ identifier: "SourceUrlPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type SourceUrlPart = Schema.Schema.Type + +export const FilePart = Schema.Struct({ + type: Schema.Literal("file"), + mediaType: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, +}) + .annotate({ identifier: "FilePart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FilePart = Schema.Schema.Type + +export const StepStartPart = Schema.Struct({ + type: Schema.Literal("step-start"), +}) + .annotate({ identifier: "StepStartPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type StepStartPart = Schema.Schema.Type + +export const MessagePart = Schema.Union([ + TextPart, + ReasoningPart, + ToolInvocationPart, + SourceUrlPart, + FilePart, + StepStartPart, +]) + .annotate({ identifier: "MessagePart", discriminator: "type" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type MessagePart = Schema.Schema.Type + +export const Info = Schema.Struct({ + id: Schema.String, + role: Schema.Literals(["user", "assistant"]), + parts: Schema.Array(MessagePart), + metadata: Schema.Struct({ + time: Schema.Struct({ + created: Schema.Number, + completed: Schema.optional(Schema.Number), + }), + error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])), + sessionID: SessionID, + tool: Schema.Record( + Schema.String, + Schema.StructWithRest( + Schema.Struct({ + title: Schema.String, + snapshot: Schema.optional(Schema.String), + time: Schema.Struct({ + start: Schema.Number, + end: Schema.Number, + }), }), - error: z - .discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema]) - .optional(), - sessionID: SessionID.zod, - tool: z.record( - z.string(), - z - .object({ - title: z.string(), - snapshot: z.string().optional(), - time: z.object({ - start: z.number(), - end: z.number(), - }), - }) - .catchall(z.any()), - ), - assistant: z - .object({ - system: z.string().array(), - modelID: ModelID.zod, - providerID: ProviderID.zod, - path: z.object({ - cwd: z.string(), - root: z.string(), - }), - cost: z.number(), - summary: z.boolean().optional(), - tokens: z.object({ - input: z.number(), - output: z.number(), - reasoning: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), - }), - }) - .optional(), - snapshot: z.string().optional(), - }) - .meta({ ref: "MessageMetadata" }), - }) - .meta({ - ref: "Message", - }) -export type Info = z.infer + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), + assistant: Schema.optional( + Schema.Struct({ + system: Schema.Array(Schema.String), + modelID: ModelID, + providerID: ProviderID, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, + }), + cost: Schema.Number, + summary: Schema.optional(Schema.Boolean), + tokens: Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + reasoning: Schema.Number, + cache: Schema.Struct({ + read: Schema.Number, + write: Schema.Number, + }), + }), + }), + ), + snapshot: Schema.optional(Schema.String), + }).annotate({ identifier: "MessageMetadata" }), +}) + .annotate({ identifier: "Message" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type export * as Message from "./message" diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index fb8354dda11..3a5fd0d8c9b 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -62,14 +62,16 @@ export function toPartialRow(info: DeepPartial) { export default [ SyncEvent.project(Session.Event.Created, (db, data) => { - db.insert(SessionTable).values(Session.toRow(data.info)).run() + db.insert(SessionTable) + .values(Session.toRow(data.info as Session.Info)) + .run() }), SyncEvent.project(Session.Event.Updated, (db, data) => { const info = data.info const row = db .update(SessionTable) - .set(toPartialRow(info)) + .set(toPartialRow(info as Session.Patch)) .where(eq(SessionTable.id, data.sessionID)) .returning() .get() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e43cac7a26b..40c0b6e3ca1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -8,6 +8,7 @@ import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kil import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { Question } from "@/question" // kilocode_change import z from "zod" +import * as EffectZod from "@/util/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import { Log } from "../util" @@ -50,7 +51,9 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Truncate } from "@/tool" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util" -import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect" +import { Cause, Effect, Exit, Layer, Option, Scope, Context, Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" import { TaskTool, type TaskPromptOps } from "@/tool/task" @@ -79,7 +82,7 @@ const elog = EffectLogger.create({ service: "session.prompt" }) export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect - readonly loop: (input: z.infer) => Effect.Effect + readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect @@ -411,7 +414,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: input.model.providerID, agent: input.agent, })) { - const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) + const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters)) tools[item.id] = tool({ description: item.description, inputSchema: jsonSchema(schema), @@ -1653,9 +1656,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, ) - const loop: (input: z.infer) => Effect.Effect = Effect.fn( - "SessionPrompt.loop", - )(function* (input: z.infer) { + const loop: (input: LoopInput) => Effect.Effect = Effect.fn("SessionPrompt.loop")(function* ( + input: LoopInput, + ) { // kilocode_change start yield* bus.publish(KiloSession.Event.TurnOpen, { sessionID: input.sessionID }) return yield* Effect.onExit( @@ -1837,101 +1840,98 @@ export const defaultLayer = Layer.suspend(() => ), ), ) -export const PromptInput = z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod.optional(), - model: z - .object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }) - .optional(), - agent: z.string().optional(), - noReply: z.boolean().optional(), - tools: z - .record(z.string(), z.boolean()) - .optional() - .describe("@deprecated tools and permissions have been merged, you can set permissions on the session itself now"), - format: MessageV2.Format.zod.optional(), - system: z.string().optional(), - variant: z.string().optional(), - // kilocode_change start - editorContext: z - .object({ - visibleFiles: z.array(z.string()).optional(), - openTabs: z.array(z.string()).optional(), - activeFile: z.string().optional(), - shell: z.string().optional(), - }) - .optional(), - // kilocode_change end - parts: z.array( - z.discriminatedUnion("type", [ - MessageV2.TextPartInput.zod as unknown as z.ZodObject, - MessageV2.FilePartInput.zod as unknown as z.ZodObject, - MessageV2.AgentPartInput.zod as unknown as z.ZodObject, - MessageV2.SubtaskPartInput.zod as unknown as z.ZodObject, - ]), - ), +const ModelRef = Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, }) + +export const PromptInput = Schema.Struct({ + sessionID: SessionID, + messageID: Schema.optional(MessageID), + model: Schema.optional(ModelRef), + agent: Schema.optional(Schema.String), + noReply: Schema.optional(Schema.Boolean), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({ + description: + "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", + }), + format: Schema.optional(MessageV2.Format), + system: Schema.optional(Schema.String), + variant: Schema.optional(Schema.String), + // kilocode_change start + editorContext: Schema.optional( + Schema.Struct({ + visibleFiles: Schema.optional(Schema.Array(Schema.String)), + openTabs: Schema.optional(Schema.Array(Schema.String)), + activeFile: Schema.optional(Schema.String), + shell: Schema.optional(Schema.String), + }), + ), + // kilocode_change end + parts: Schema.Array( + Schema.Union([ + MessageV2.TextPartInput, + MessageV2.FilePartInput, + MessageV2.AgentPartInput, + MessageV2.SubtaskPartInput, + ]).annotate({ discriminator: "type" }), + ), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) // `z.discriminatedUnion` erases the discriminated members' shapes back to -// `{}` because the derived `.zod` on each input is typed as an opaque -// `z.ZodType`. Restore the precise `parts` type from the exported Schema -// input types so callers see a proper tagged union. +// `{}` when walked from the generic `z.ZodType` input. Restore the precise +// `parts` type from the exported Schema input types so callers see a proper +// tagged union. type PartInputUnion = | MessageV2.TextPartInput | MessageV2.FilePartInput | MessageV2.AgentPartInput | MessageV2.SubtaskPartInput -export type PromptInput = Omit, "parts"> & { +export type PromptInput = Omit, "parts"> & { parts: PartInputUnion[] } -export const LoopInput = z.object({ - sessionID: SessionID.zod, -}) +export class LoopInput extends Schema.Class("SessionPrompt.LoopInput")({ + sessionID: SessionID, +}) { + static readonly zod = zod(this) +} -export const ShellInput = z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod.optional(), - agent: z.string(), - model: z - .object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }) - .optional(), - command: z.string(), -}) -export type ShellInput = z.infer +export const ShellInput = Schema.Struct({ + sessionID: SessionID, + messageID: Schema.optional(MessageID), + agent: Schema.String, + model: Schema.optional(ModelRef), + command: Schema.String, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ShellInput = Schema.Schema.Type -export const CommandInput = z.object({ - messageID: MessageID.zod.optional(), - sessionID: SessionID.zod, - agent: z.string().optional(), - model: z.string().optional(), - arguments: z.string(), - command: z.string(), - variant: z.string().optional(), - // Inlined (no `.meta({ ref })`) to keep the original SDK output — the +export const CommandInput = Schema.Struct({ + messageID: Schema.optional(MessageID), + sessionID: SessionID, + agent: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + arguments: Schema.String, + command: Schema.String, + variant: Schema.optional(Schema.String), + // Inlined (no identifier annotation) to keep the original SDK output — the // PromptInput call site below references FilePartInput by ref via the // Schema export in message-v2.ts. - parts: z - .array( - z.discriminatedUnion("type", [ - z.object({ - id: PartID.zod.optional(), - type: z.literal("file"), - mime: z.string(), - filename: z.string().optional(), - url: z.string(), - source: MessageV2.FilePartSource.zod.optional(), + parts: Schema.optional( + Schema.Array( + Schema.Union([ + Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(MessageV2.FilePartSource), }), - ]), - ) - .optional(), -}) -export type CommandInput = z.infer + ]).annotate({ discriminator: "type" }), + ), + ), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type CommandInput = Schema.Schema.Type /** @internal Exported for testing */ export function createStructuredOutputTool(input: { diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index bd84ae563dd..19516e8168f 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -1,10 +1,11 @@ -import z from "zod" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema } from "effect" import { Bus } from "../bus" import { Snapshot } from "../snapshot" import { Storage } from "@/storage" import { SyncEvent } from "../sync" import { Log } from "../util" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" @@ -13,12 +14,12 @@ import { SessionSummary } from "./summary" const log = Log.create({ service: "session.revert" }) -export const RevertInput = z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod.optional(), -}) -export type RevertInput = z.infer +export const RevertInput = Schema.Struct({ + sessionID: SessionID, + messageID: MessageID, + partID: Schema.optional(PartID), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type RevertInput = Schema.Schema.Type export interface Interface { readonly revert: (input: RevertInput) => Effect.Effect diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index b57c5660e02..e30d0f60400 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -13,7 +13,6 @@ import { SyncEvent } from "../sync" import { PartTable, SessionTable } from "./session.sql" import { Storage } from "@/storage" import { Log } from "../util" -import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" import { Instance } from "../project/instance" import { InstanceState } from "@/effect" @@ -25,12 +24,14 @@ import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider" import { Permission } from "@/permission" import { Global } from "@/global" -import { Effect, Layer, Option, Context } from "effect" // kilocode_change start - legacy promise helpers + kilocode extensions import { makeRuntime } from "@/effect/run-service" import { KiloSession, kiloSessionFork } from "@/kilocode/session" import { fn } from "@/util/fn" // kilocode_change end +import { Effect, Layer, Option, Context, Schema, Types } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" const log = Log.create({ service: "session" }) @@ -117,142 +118,178 @@ function getForkedTitle(title: string): string { return `${title} (fork #1)` } -export const Info = z - .object({ - id: SessionID.zod, - slug: z.string(), - projectID: ProjectID.zod, - workspaceID: WorkspaceID.zod.optional(), - directory: z.string(), - parentID: SessionID.zod.optional(), - summary: z - .object({ - additions: z.number(), - deletions: z.number(), - files: z.number(), - diffs: Snapshot.SummaryFileDiff.zod.array().optional(), // kilocode_change - }) - .optional(), - share: z - .object({ - url: z.string(), - }) - .optional(), - title: z.string(), - version: z.string(), - time: z.object({ - created: z.number(), - updated: z.number(), - compacting: z.number().optional(), - archived: z.number().optional(), - }), - permission: Permission.Ruleset.zod.optional(), - revert: z - .object({ - messageID: MessageID.zod, - partID: PartID.zod.optional(), - snapshot: z.string().optional(), - diff: z.string().optional(), - }) - .optional(), - }) - .meta({ - ref: "Session", - }) -export type Info = z.output - -export const ProjectInfo = z - .object({ - id: ProjectID.zod, - name: z.string().optional(), - worktree: z.string(), - }) - .meta({ - ref: "ProjectSummary", - }) -export type ProjectInfo = z.output - -export const GlobalInfo = Info.extend({ - project: ProjectInfo.nullable(), - worktreeName: z.string().optional(), // kilocode_change - basename of the specific worktree directory -}).meta({ - ref: "GlobalSession", +const Summary = Schema.Struct({ + additions: Schema.Number, + deletions: Schema.Number, + files: Schema.Number, + diffs: Schema.optional(Schema.Array(Snapshot.SummaryFileDiff)), // kilocode_change }) -export type GlobalInfo = z.output -export const CreateInput = z - .object({ - parentID: SessionID.zod.optional(), - title: z.string().optional(), - permission: Info.shape.permission, - platform: z.string().optional(), // kilocode_change - per-session platform override for telemetry attribution - workspaceID: WorkspaceID.zod.optional(), - }) - .optional() -export type CreateInput = z.output - -export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }) -export const GetInput = SessionID.zod -export const ChildrenInput = SessionID.zod -export const RemoveInput = SessionID.zod -export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() }) -export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() }) -export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod }) -export const SetRevertInput = z.object({ - sessionID: SessionID.zod, - revert: Info.shape.revert, - summary: Info.shape.summary, +const Share = Schema.Struct({ + url: Schema.String, +}) + +const Time = Schema.Struct({ + created: Schema.Number, + updated: Schema.Number, + compacting: Schema.optional(Schema.Number), + archived: Schema.optional(Schema.Number), +}) + +const Revert = Schema.Struct({ + messageID: MessageID, + partID: Schema.optional(PartID), + snapshot: Schema.optional(Schema.String), + diff: Schema.optional(Schema.String), +}) + +export const Info = Schema.Struct({ + id: SessionID, + slug: Schema.String, + projectID: ProjectID, + workspaceID: Schema.optional(WorkspaceID), + directory: Schema.String, + parentID: Schema.optional(SessionID), + summary: Schema.optional(Summary), + share: Schema.optional(Share), + title: Schema.String, + version: Schema.String, + time: Time, + permission: Schema.optional(Permission.Ruleset), + revert: Schema.optional(Revert), +}) + .annotate({ identifier: "Session" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Types.DeepMutable> + +export const ProjectInfo = Schema.Struct({ + id: ProjectID, + name: Schema.optional(Schema.String), + worktree: Schema.String, +}) + .annotate({ identifier: "ProjectSummary" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ProjectInfo = Types.DeepMutable> + +export const GlobalInfo = Schema.Struct({ + ...Info.fields, + project: Schema.NullOr(ProjectInfo), + worktreeName: Schema.optional(Schema.String), // kilocode_change - basename of the specific worktree directory +}) + .annotate({ identifier: "GlobalSession" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type GlobalInfo = Types.DeepMutable> + +export const CreateInput = Schema.optional( + Schema.Struct({ + parentID: Schema.optional(SessionID), + title: Schema.optional(Schema.String), + permission: Schema.optional(Permission.Ruleset), + platform: Schema.optional(Schema.String), // kilocode_change - per-session platform override for telemetry attribution + workspaceID: Schema.optional(WorkspaceID), + }), +).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type CreateInput = Types.DeepMutable> + +export const ForkInput = Schema.Struct({ + sessionID: SessionID, + messageID: Schema.optional(MessageID), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const GetInput = SessionID +export const ChildrenInput = SessionID +export const RemoveInput = SessionID +export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe( + withStatics((s) => ({ zod: zod(s) })), +) +export const SetArchivedInput = Schema.Struct({ + sessionID: SessionID, + time: Schema.optional(Schema.Number), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const SetPermissionInput = Schema.Struct({ + sessionID: SessionID, + permission: Permission.Ruleset, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const SetRevertInput = Schema.Struct({ + sessionID: SessionID, + revert: Schema.optional(Revert), + summary: Schema.optional(Summary), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const MessagesInput = Schema.Struct({ + sessionID: SessionID, + limit: Schema.optional(Schema.Number), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) + +const CreatedEventSchema = Schema.Struct({ + sessionID: SessionID, + info: Info, +}) + +const UpdatedShare = Schema.Struct({ + url: Schema.optional(Schema.NullOr(Schema.String)), +}) + +const UpdatedTime = Schema.Struct({ + created: Schema.optional(Schema.NullOr(Schema.Number)), + updated: Schema.optional(Schema.NullOr(Schema.Number)), + compacting: Schema.optional(Schema.NullOr(Schema.Number)), + archived: Schema.optional(Schema.NullOr(Schema.Number)), +}) + +const UpdatedInfo = Schema.Struct({ + id: Schema.optional(Schema.NullOr(SessionID)), + slug: Schema.optional(Schema.NullOr(Schema.String)), + projectID: Schema.optional(Schema.NullOr(ProjectID)), + workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)), + directory: Schema.optional(Schema.NullOr(Schema.String)), + parentID: Schema.optional(Schema.NullOr(SessionID)), + summary: Schema.optional(Schema.NullOr(Summary)), + share: Schema.optional(UpdatedShare), + title: Schema.optional(Schema.NullOr(Schema.String)), + version: Schema.optional(Schema.NullOr(Schema.String)), + time: Schema.optional(UpdatedTime), + permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), + revert: Schema.optional(Schema.NullOr(Revert)), +}) + +const UpdatedEventSchema = Schema.Struct({ + sessionID: SessionID, + info: UpdatedInfo, }) -export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() }) export const Event = { Created: SyncEvent.define({ type: "session.created", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: Info, - }), + schema: CreatedEventSchema, }), Updated: SyncEvent.define({ type: "session.updated", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: updateSchema(Info).extend({ - share: updateSchema(Info.shape.share.unwrap()).optional(), - time: updateSchema(Info.shape.time).optional(), - }), - }), - busSchema: z.object({ - sessionID: SessionID.zod, - info: Info, - }), + schema: UpdatedEventSchema, + busSchema: CreatedEventSchema, }), Deleted: SyncEvent.define({ type: "session.deleted", version: 1, aggregate: "sessionID", - schema: z.object({ - sessionID: SessionID.zod, - info: Info, - }), + schema: CreatedEventSchema, }), Diff: BusEvent.define( "session.diff", - z.object({ - sessionID: SessionID.zod, - diff: Snapshot.FileDiff.zod.array(), + Schema.Struct({ + sessionID: SessionID, + diff: Schema.Array(Snapshot.FileDiff), }), ), Error: BusEvent.define( "session.error", - z.object({ - sessionID: SessionID.zod.optional(), - // z.lazy defers access to break circular dep: session → message-v2 → provider → plugin → session - error: z.lazy(() => (MessageV2.Assistant.zod as unknown as z.ZodObject).shape.error), + Schema.Struct({ + sessionID: Schema.optional(SessionID), + // Reuses MessageV2.Assistant.fields.error (already Schema.optional) so + // the derived zod keeps the same discriminated-union shape on the bus. + error: MessageV2.Assistant.fields.error, }), ), // kilocode_change start @@ -403,7 +440,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Session") {} -type Patch = z.infer["info"] +export type Patch = Types.DeepMutable["data"]["info"]> const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index ccfb9f4b5ed..826660a1202 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -3,49 +3,49 @@ import { Bus } from "@/bus" import { InstanceState } from "@/effect" import { SessionID } from "./schema" import { makeRuntime } from "@/effect/run-service" // kilocode_change -import { Effect, Layer, Context } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" +import { Effect, Layer, Context, Schema } from "effect" import z from "zod" -export const Info = z - .union([ - z.object({ - type: z.literal("idle"), - }), - z.object({ - type: z.literal("retry"), - attempt: z.number(), - message: z.string(), - next: z.number(), - }), - z.object({ - type: z.literal("busy"), - }), - // kilocode_change start - z.object({ - type: z.literal("offline"), - requestID: z.string(), - message: z.string(), - }), - // kilocode_change end - ]) - .meta({ - ref: "SessionStatus", - }) -export type Info = z.infer +export const Info = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("idle"), + }), + Schema.Struct({ + type: Schema.Literal("retry"), + attempt: Schema.Number, + message: Schema.String, + next: Schema.Number, + }), + Schema.Struct({ + type: Schema.Literal("busy"), + }), + // kilocode_change start + Schema.Struct({ + type: Schema.Literal("offline"), + requestID: Schema.String, + message: Schema.String, + }), + // kilocode_change end +]) + .annotate({ identifier: "SessionStatus" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type export const Event = { Status: BusEvent.define( "session.status", - z.object({ - sessionID: SessionID.zod, + Schema.Struct({ + sessionID: SessionID, status: Info, }), ), // deprecated Idle: BusEvent.define( "session.idle", - z.object({ - sessionID: SessionID.zod, + Schema.Struct({ + sessionID: SessionID, }), ), } diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f96bb9a94d7..09af571910f 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,8 +1,9 @@ -import z from "zod" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema } from "effect" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" @@ -164,10 +165,11 @@ export const defaultLayer = Layer.suspend(() => ), ) -export const DiffInput = z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod.optional(), -}) +export const DiffInput = Schema.Struct({ + sessionID: SessionID, + messageID: Schema.optional(MessageID), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type DiffInput = Schema.Schema.Type // kilocode_change start - legacy promise helpers for Kilo callsites const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 4840f86a3d9..c3a9b106b10 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,26 +1,30 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { SessionID } from "./schema" -import { Effect, Layer, Context } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" +import { Effect, Layer, Context, Schema } from "effect" import z from "zod" import { Database, eq, asc } from "../storage" import { TodoTable } from "./session.sql" -export const Info = z - .object({ - content: z.string().describe("Brief description of the task"), - status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"), - priority: z.string().describe("Priority level of the task: high, medium, low"), - }) - .meta({ ref: "Todo" }) -export type Info = z.infer +export const Info = Schema.Struct({ + content: Schema.String.annotate({ description: "Brief description of the task" }), + status: Schema.String.annotate({ + description: "Current status of the task: pending, in_progress, completed, cancelled", + }), + priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), +}) + .annotate({ identifier: "Todo" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type export const Event = { Updated: BusEvent.define( "todo.updated", - z.object({ - sessionID: SessionID.zod, - todos: z.array(Info), + Schema.Struct({ + sessionID: SessionID, + todos: Schema.Array(Info), }), ), } diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 9e8d1be6f57..186f567113c 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -181,7 +181,7 @@ export const layer = Layer.effect( yield* watch(Session.Event.Updated, (evt) => Effect.gen(function* () { - const info = yield* session.get(evt.properties.sessionID) + const info = evt.properties.info yield* sync(info.id, [{ type: "session", data: info }]) }), ) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 6f5f0f7cbe0..e15608f6e27 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -1,5 +1,4 @@ import z from "zod" -import type { ZodObject } from "zod" import { Database, eq } from "@/storage" import { GlobalBus } from "@/bus/global" import { Bus as ProjectBus } from "@/bus" @@ -9,34 +8,48 @@ import { EventSequenceTable, EventTable } from "./event.sql" import { WorkspaceContext } from "@/control-plane/workspace-context" import { EventID } from "./schema" import { Flag } from "@/flag/flag" +import { Schema as EffectSchema } from "effect" +import { zodObject } from "@/util/effect-zod" +import type { DeepMutable } from "@/util/schema" -export type Definition = { - type: string +// Keep `Event["data"]` mutable because projectors mutate the persisted shape +// when writing to the database. Bus payloads (`Properties`) stay readonly — +// subscribers only read. + +export type Definition< + Type extends string = string, + Schema extends EffectSchema.Top = EffectSchema.Top, + BusSchema extends EffectSchema.Top = Schema, +> = { + type: Type version: number aggregate: string - schema: z.ZodObject - - // This is temporary and only exists for compatibility with bus - // event definitions - properties: z.ZodObject + schema: Schema + // Bus event payload schema. Defaults to `schema` unless `busSchema` was + // passed at definition time (see `session.updated`, whose projector + // expands the persisted data to a `{ sessionID, info }` bus payload). + properties: BusSchema } export type Event = { id: string seq: number aggregateID: string - data: z.infer + data: DeepMutable> } +export type Properties = EffectSchema.Schema.Type + export type SerializedEvent = Event & { type: string } type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void +type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise export const registry = new Map() let projectors: Map | undefined const versions = new Map() let frozen = false -let convertEvent: (type: string, event: Event["data"]) => Promise> | Record +let convertEvent: ConvertEvent export function reset() { frozen = false @@ -44,7 +57,7 @@ export function reset() { convertEvent = (_, data) => data } -export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) { +export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: ConvertEvent }) { projectors = new Map(input.projectors) // Install all the latest event defs to the bus. We only ever emit @@ -54,13 +67,13 @@ export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; co for (let [type, version] of versions.entries()) { let def = registry.get(versionedType(type, version))! - BusEvent.define(def.type, def.properties || def.schema) + BusEvent.define(def.type, def.properties) } // Freeze the system so it clearly errors if events are defined // after `init` which would cause bugs frozen = true - convertEvent = input.convertEvent || ((_, data) => data) + convertEvent = input.convertEvent ?? ((_, data) => data) } export function versionedType(type: A): A @@ -72,9 +85,15 @@ export function versionedType(type: string, version?: number) { export function define< Type extends string, Agg extends string, - Schema extends ZodObject>>, - BusSchema extends ZodObject = Schema, ->(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) { + Schema extends EffectSchema.Top, + BusSchema extends EffectSchema.Top = Schema, +>(input: { + type: Type + version: number + aggregate: Agg + schema: Schema + busSchema?: BusSchema +}): Definition { if (frozen) { throw new Error("Error defining sync event: sync system has been frozen") } @@ -84,7 +103,7 @@ export function define< version: input.version, aggregate: input.aggregate, schema: input.schema, - properties: input.busSchema ? input.busSchema : input.schema, + properties: (input.busSchema ?? input.schema) as BusSchema, } versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0)) @@ -141,12 +160,11 @@ function process(def: Def, event: Event, options: { Database.effect(() => { if (options?.publish) { const result = convertEvent(def.type, event.data) + const publish = (data: unknown) => ProjectBus.publish(def, data as Properties) if (result instanceof Promise) { - void result.then((data) => { - void ProjectBus.publish({ type: def.type, properties: def.schema }, data) - }) + void result.then(publish) } else { - void ProjectBus.publish({ type: def.type, properties: def.schema }, result) + void publish(result) } GlobalBus.emit("event", { @@ -266,7 +284,7 @@ export function payloads() { id: z.string(), seq: z.number(), aggregateID: z.literal(def.aggregate), - data: def.schema, + data: zodObject(def.schema), }) .meta({ ref: `SyncEvent.${def.type}`, diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index edb74a52c74..e7a613ab56e 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,6 +1,5 @@ -import z from "zod" import * as path from "path" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" @@ -19,8 +18,8 @@ import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change import { Format } from "../format" import * as Bom from "@/util/bom" -const PatchParams = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), +export const Parameters = Schema.Struct({ + patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), }) export const ApplyPatchTool = Tool.define( @@ -31,7 +30,10 @@ export const ApplyPatchTool = Tool.define( const format = yield* Format.Service const bus = yield* Bus.Service - const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("ApplyPatchTool.execute")(function* ( + params: Schema.Schema.Type, + ctx: Tool.Context, + ) { if (!params.patchText) { return yield* Effect.fail(new Error("patchText is required")) } @@ -320,8 +322,9 @@ export const ApplyPatchTool = Tool.define( return { description: DESCRIPTION, - parameters: PatchParams, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f479ec98f5b..4fb5ae0eee7 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" @@ -50,23 +50,18 @@ const FILES = new Set([ const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -const Parameters = z.object({ - command: z.string().describe("The command to execute"), - timeout: z.number().describe("Optional timeout in milliseconds").optional(), - workdir: z - .string() - .describe( - `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - ) - .optional(), - description: z - .string() - .optional() // kilocode_change - .describe( - // kilocode_change start +export const Parameters = Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.optional(Schema.String).annotate({ // kilocode_change + // kilocode_change start + description: "Recommended: a clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - // kilocode_change end - ), + // kilocode_change end + }), }) type Part = { @@ -424,9 +419,8 @@ export const BashTool = Tool.define( }, ctx: Tool.Context, ) { - const bytes = Truncate.MAX_BYTES - const lines = Truncate.MAX_LINES - const keep = bytes * 2 + const limits = yield* trunc.limits() + const keep = limits.maxBytes * 2 let full = "" let last = "" const list: Chunk[] = [] @@ -466,7 +460,7 @@ export const BashTool = Tool.define( sink?.write(chunk) } else { full += chunk - if (Buffer.byteLength(full, "utf-8") > bytes) { + if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) { return trunc.write(full).pipe( Effect.andThen((next) => Effect.sync(() => { @@ -533,7 +527,7 @@ export const BashTool = Tool.define( } if (aborted) meta.push("User aborted the command") const raw = list.map((item) => item.text).join("") - const end = tail(raw, lines, bytes) + const end = tail(raw, limits.maxLines, limits.maxBytes) if (end.cut) cut = true if (!file && end.cut) { file = yield* trunc.write(raw) @@ -574,7 +568,7 @@ export const BashTool = Tool.define( }) return () => - Effect.sync(() => { + Effect.gen(function* () { const shell = Shell.acceptable() const name = Shell.name(shell) const chain = @@ -583,15 +577,17 @@ export const BashTool = Tool.define( : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." log.info("bash tool using shell", { shell }) + const limits = yield* trunc.limits() + return { description: DESCRIPTION.replaceAll("${directory}", Instance.directory) .replaceAll("${os}", process.platform) .replaceAll("${shell}", name) .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) - .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), + .replaceAll("${maxLines}", String(limits.maxLines)) + .replaceAll("${maxBytes}", String(limits.maxBytes)), parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const cwd = params.workdir ? yield* resolvePath(params.workdir, Instance.directory, shell) diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index ac9961e2506..e10d21175e7 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,10 +1,23 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./codesearch.txt" +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ + description: + "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", + }), + tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) + .check(Schema.isLessThanOrEqualTo(50000)) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) + .annotate({ + description: + "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", + }), +}) + export const CodeSearchTool = Tool.define( "codesearch", Effect.gen(function* () { @@ -12,21 +25,7 @@ export const CodeSearchTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - query: z - .string() - .describe( - "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", - ), - tokensNum: z - .number() - .min(1000) - .max(50000) - .default(5000) - .describe( - "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", - ), - }), + parameters: Parameters, execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ @@ -45,7 +44,7 @@ export const CodeSearchTool = Tool.define( McpExa.CodeArgs, { query: params.query, - tokensNum: params.tokensNum || 5000, + tokensNum: params.tokensNum, }, "30 seconds", ) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 6af481e10ce..644cd144a49 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -3,9 +3,8 @@ // https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts -import z from "zod" import * as path from "path" -import { Effect, Semaphore } from "effect" +import { Effect, Schema, Semaphore } from "effect" import * as Tool from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" @@ -70,11 +69,15 @@ function lock(filePath: string) { return next } -const Parameters = z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file to modify" }), + oldString: Schema.String.annotate({ description: "The text to replace" }), + newString: Schema.String.annotate({ + description: "The text to replace it with (must be different from oldString)", + }), + replaceAll: Schema.optional(Schema.Boolean).annotate({ + description: "Replace all occurrences of oldString (default false)", + }), }) export const EditTool = Tool.define( @@ -88,7 +91,7 @@ export const EditTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { if (!params.filePath) { throw new Error("filePath is required") diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index dc69fee7c8d..5caf1c0fb85 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,6 +1,5 @@ import path from "path" -import z from "zod" -import { Effect, Option } from "effect" +import { Effect, Option, Schema } from "effect" import * as Stream from "effect/Stream" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -27,6 +26,13 @@ function split(pattern: string) { } // kilocode_change end +export const Parameters = Schema.Struct({ + pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), + path: Schema.optional(Schema.String).annotate({ + description: `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, + }), +}) + export const GlobTool = Tool.define( "glob", Effect.gen(function* () { @@ -35,15 +41,7 @@ export const GlobTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - pattern: z.string().describe("The glob pattern to match files against"), - path: z - .string() - .optional() - .describe( - `The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`, - ), - }), + parameters: Parameters, execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) => Effect.gen(function* () { const ins = yield* InstanceState.context diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index caa75edad53..41600543119 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,5 +1,5 @@ import path from "path" -import z from "zod" +import { Schema } from "effect" import { Effect, Option } from "effect" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -10,6 +10,16 @@ import * as Tool from "./tool" const MAX_LINE_LENGTH = 2000 +export const Parameters = Schema.Struct({ + pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }), + path: Schema.optional(Schema.String).annotate({ + description: "The directory to search in. Defaults to the current working directory.", + }), + include: Schema.optional(Schema.String).annotate({ + description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', + }), +}) + export const GrepTool = Tool.define( "grep", Effect.gen(function* () { @@ -18,11 +28,7 @@ export const GrepTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - pattern: z.string().describe("The regex pattern to search for in file contents"), - path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), - include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), - }), + parameters: Parameters, execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => Effect.gen(function* () { const empty = { diff --git a/packages/opencode/src/tool/invalid.ts b/packages/opencode/src/tool/invalid.ts index aca3618b6d0..b8d145d0be2 100644 --- a/packages/opencode/src/tool/invalid.ts +++ b/packages/opencode/src/tool/invalid.ts @@ -1,15 +1,16 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" +export const Parameters = Schema.Struct({ + tool: Schema.String, + error: Schema.String, +}) + export const InvalidTool = Tool.define( "invalid", Effect.succeed({ description: "Do not use", - parameters: z.object({ - tool: z.string(), - error: z.string(), - }), + parameters: Parameters, execute: (params: { tool: string; error: string }) => Effect.succeed({ title: "Invalid Tool", diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 0a0edc61edd..29c6a8d843c 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import path from "path" import { LSP } from "../lsp" @@ -21,6 +20,17 @@ const operations = [ "outgoingCalls", ] as const +export const Parameters = Schema.Struct({ + operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }), + filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }), + line: Schema.Number.check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)) + .annotate({ description: "The line number (1-based, as shown in editors)" }), + character: Schema.Number.check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(1)) + .annotate({ description: "The character offset (1-based, as shown in editors)" }), +}) + export const LspTool = Tool.define( "lsp", Effect.gen(function* () { @@ -29,12 +39,7 @@ export const LspTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - operation: z.enum(operations).describe("The LSP operation to perform"), - filePath: z.string().describe("The absolute or relative path to the file"), - line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"), - character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), - }), + parameters: Parameters, execute: ( args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number }, ctx: Tool.Context, diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index b6c0edbefab..f79db784691 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -1,11 +1,12 @@ -import z from "zod" import path from "path" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Session } from "../session" import { Instance } from "../project/instance" import EXIT_DESCRIPTION from "./plan-exit.txt" +export const Parameters = Schema.Struct({}) + // kilocode_change start - simplified plan_exit: readiness signal only, no user prompt export const PlanExitTool = Tool.define( "plan_exit", @@ -14,7 +15,7 @@ export const PlanExitTool = Tool.define( return { description: EXIT_DESCRIPTION, - parameters: z.object({}), + parameters: Parameters, execute: (_params: {}, ctx: Tool.Context) => Effect.gen(function* () { const info = yield* session.get(ctx.sessionID) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 4b52ebd7cfd..3c41a863d1a 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -1,12 +1,11 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" import { KiloQuestionTool } from "@/kilocode/tool/question" // kilocode_change -const parameters = z.object({ - questions: z.array(Question.Prompt.zod).describe("Questions to ask"), +export const Parameters = Schema.Struct({ + questions: Schema.mutable(Schema.Array(Question.Prompt)).annotate({ description: "Questions to ask" }), }) type Metadata = { @@ -14,15 +13,15 @@ type Metadata = { dismissed?: boolean // kilocode_change } -export const QuestionTool = Tool.define( +export const QuestionTool = Tool.define( "question", Effect.gen(function* () { const question = yield* Question.Service return { description: DESCRIPTION, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { // kilocode_change start - surface Question.dismissAll's RejectedError as a normal // tool result via KiloQuestionTool helpers, so Effect.orDie below does not turn diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index a44a9e02700..751802fde31 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,6 +1,6 @@ -import z from "zod" -import { Effect, Option, Scope } from "effect" import { lstat } from "fs/promises" // kilocode_change +import { Effect, Option, Schema, Scope } from "effect" +import { createReadStream } from "fs" import * as path from "path" import { Readable } from "stream" // kilocode_change import { createInterface } from "readline" @@ -24,10 +24,19 @@ const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` const SAMPLE_BYTES = 4096 const DIRECTORY_CONCURRENCY = 8 // kilocode_change -const parameters = z.object({ - filePath: z.string().describe("The absolute path to the file or directory to read"), - offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), - limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(), +// `offset` and `limit` were originally `z.coerce.number()` — the runtime +// coercion was useful when the tool was called from a shell but serves no +// purpose in the LLM tool-call path (the model emits typed JSON). The JSON +// Schema output is identical (`type: "number"`), so the LLM view is +// unchanged; purely CLI-facing uses must now send numbers rather than strings. +export const Parameters = Schema.Struct({ + filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }), + offset: Schema.optional(Schema.Number).annotate({ + description: "The line number to start reading from (1-indexed)", + }), + limit: Schema.optional(Schema.Number).annotate({ + description: "The maximum number of lines to read (defaults to 2000)", + }), }) export const ReadTool = Tool.define( @@ -185,7 +194,10 @@ export const ReadTool = Tool.define( }) // kilocode_change end - const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("ReadTool.execute")(function* ( + params: Schema.Schema.Type, + 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")) } @@ -330,8 +342,9 @@ export const ReadTool = Tool.define( return { description: DESCRIPTION, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index bd8ea084155..0f6ecf320fa 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -16,7 +16,9 @@ import { SkillTool } from "./skill" import * as Tool from "./tool" import { Config } from "../config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@kilocode/plugin" +import { Schema } from "effect" import z from "zod" +import { ZodOverride } from "@/util/effect-zod" import { Plugin } from "../plugin" import { Provider } from "../provider" import { ProviderID, type ModelID } from "../provider/schema" @@ -125,9 +127,17 @@ export const layer: Layer.Layer< const custom: Tool.Def[] = [] function fromPlugin(id: string, def: ToolDefinition): Tool.Def { + // Plugin tools define their args as a raw Zod shape. Wrap the + // derived Zod object in a `Schema.declare` so it slots into the + // Schema-typed framework, and annotate with `ZodOverride` so the + // walker emits the original Zod object for LLM JSON Schema. + const zodParams = z.object(def.args) + const parameters = Schema.declare((u): u is unknown => zodParams.safeParse(u).success).annotate({ + [ZodOverride]: zodParams, + }) return { id, - parameters: z.object(def.args), + parameters, description: def.description, execute: (args, toolCtx) => Effect.gen(function* () { diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index bfbd9cf25d4..2b3376baafb 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,15 +1,14 @@ import path from "path" import { pathToFileURL } from "url" -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" 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"), +export const Parameters = Schema.Struct({ + name: Schema.String.annotate({ description: "The name of the skill from available_skills" }), }) export const SkillTool = Tool.define( @@ -21,7 +20,7 @@ export const SkillTool = Tool.define( return { description: DESCRIPTION, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const info = yield* skill.get(params.name) if (!info) { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index bd9e5fb62f6..106c86e60ca 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,15 +1,14 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" -import z from "zod" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "../config" -import { Effect } from "effect" import { KiloTask } from "../kilocode/tool/task" // kilocode_change import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change +import { Effect, Schema } from "effect" export interface TaskPromptOps { cancel(sessionID: SessionID): void @@ -19,17 +18,15 @@ export interface TaskPromptOps { const id = "task" -const parameters = z.object({ - description: z.string().describe("A short (3-5 words) description of the task"), - prompt: z.string().describe("The task for the agent to perform"), - subagent_type: z.string().describe("The type of specialized agent to use for this task"), - task_id: z - .string() - .describe( +export const Parameters = Schema.Struct({ + description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), + prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), + subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), + task_id: Schema.optional(Schema.String).annotate({ + description: "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", - ) - .optional(), - command: z.string().describe("The command that triggered this task").optional(), + }), + command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), }) export const TaskTool = Tool.define( @@ -39,7 +36,10 @@ export const TaskTool = Tool.define( const config = yield* Config.Service const sessions = yield* Session.Service - const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const run = Effect.fn("TaskTool.execute")(function* ( + params: Schema.Schema.Type, + ctx: Tool.Context, + ) { const cfg = yield* config.get() if (!ctx.extra?.bypassAgentCheck) { @@ -197,8 +197,9 @@ export const TaskTool = Tool.define( return { description: DESCRIPTION, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.orDie), } }), ) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 5090f17a7c2..18d21cf61e0 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -1,26 +1,36 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" -const parameters = z.object({ - todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"), +// Todo.Info is still a zod schema (session/todo.ts). Inline the field shape +// here rather than referencing its `.shape` — the LLM-visible JSON Schema is +// identical, and it removes the last zod dependency from this tool. +const TodoItem = Schema.Struct({ + content: Schema.String.annotate({ description: "Brief description of the task" }), + status: Schema.String.annotate({ + description: "Current status of the task: pending, in_progress, completed, cancelled", + }), + priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }), +}) + +export const Parameters = Schema.Struct({ + todos: Schema.mutable(Schema.Array(TodoItem)).annotate({ description: "The updated todo list" }), }) type Metadata = { todos: Todo.Info[] } -export const TodoWriteTool = Tool.define( +export const TodoWriteTool = Tool.define( "todowrite", Effect.gen(function* () { const todo = yield* Todo.Service return { description: DESCRIPTION_WRITE, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ permission: "todowrite", @@ -42,6 +52,6 @@ export const TodoWriteTool = Tool.define + } satisfies Tool.DefWithoutID }), ) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 179149afd28..7e753cb9bca 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" @@ -32,29 +31,39 @@ export interface ExecuteResult { attachments?: Omit[] } -export interface Def { +export interface Def< + Parameters extends Schema.Decoder = Schema.Decoder, + M extends Metadata = Metadata, +> { id: string description: string parameters: Parameters - execute(args: z.infer, ctx: Context): Effect.Effect> - formatValidationError?(error: z.ZodError): string + execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> + formatValidationError?(error: unknown): string } -export type DefWithoutID = Omit< - Def, - "id" -> +export type DefWithoutID< + Parameters extends Schema.Decoder = Schema.Decoder, + M extends Metadata = Metadata, +> = Omit, "id"> -export interface Info { +export interface Info< + Parameters extends Schema.Decoder = Schema.Decoder, + M extends Metadata = Metadata, +> { id: string init: () => Effect.Effect> } -type Init = +type Init, M extends Metadata> = | DefWithoutID | (() => Effect.Effect>) export type InferParameters = - T extends Info ? z.infer

: T extends Effect.Effect, any, any> ? z.infer

: never + T extends Info + ? Schema.Schema.Type

+ : T extends Effect.Effect, any, any> + ? Schema.Schema.Type

+ : never export type InferMetadata = T extends Info ? M : T extends Effect.Effect, any, any> ? M : never @@ -65,7 +74,7 @@ export type InferDef = ? Def : never -function wrap( +function wrap, Result extends Metadata>( id: string, init: Init, truncate: Truncate.Interface, @@ -74,6 +83,10 @@ function wrap( return () => Effect.gen(function* () { const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init } + // Compile the parser closure once per tool init; `decodeUnknownEffect` + // allocates a new closure per call, so hoisting avoids re-closing it for + // every LLM tool invocation. + const decode = Schema.decodeUnknownEffect(toolInfo.parameters) const execute = toolInfo.execute toolInfo.execute = (args, ctx) => { const attrs = { @@ -83,19 +96,17 @@ function wrap( ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}), } return Effect.gen(function* () { - yield* Effect.try({ - try: () => toolInfo.parameters.parse(args), - catch: (error) => { - if (error instanceof z.ZodError && toolInfo.formatValidationError) { - return new Error(toolInfo.formatValidationError(error), { cause: error }) - } - return new Error( - `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, - { cause: error }, - ) - }, - }) - const result = yield* execute(args, ctx) + const decoded = yield* decode(args).pipe( + Effect.mapError((error) => + toolInfo.formatValidationError + ? new Error(toolInfo.formatValidationError(error), { cause: error }) + : new Error( + `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, + { cause: error }, + ), + ), + ) + const result = yield* execute(decoded as Schema.Schema.Type, ctx) if (result.metadata.truncated !== undefined) { return result } @@ -116,7 +127,12 @@ function wrap( }) } -export function define( +export function define< + Parameters extends Schema.Decoder, + Result extends Metadata, + R, + ID extends string = string, +>( id: ID, init: Effect.Effect, never, R>, ): Effect.Effect, never, R | Truncate.Service | Agent.Service> & { id: ID } { @@ -131,7 +147,9 @@ export function define(info: Info): Effect.Effect> { +export function init

, M extends Metadata>( + info: Info, +): Effect.Effect> { return Effect.gen(function* () { const init = yield* info.init() return { diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index d990e7adf7f..e0d846858ee 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -1,9 +1,10 @@ import { NodePath } from "@effect/platform-node" -import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect" +import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect" import path from "path" import type { Agent } from "../agent/agent" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { evaluate } from "@/permission/evaluate" +import { Config } from "../config" import { Identifier } from "../id/id" import { Log } from "../util" import { ToolID } from "./schema" @@ -38,6 +39,10 @@ export interface Interface { * to the truncation directory and returns a preview plus a hint to inspect the saved file. */ readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect + /** + * Resolved truncation limits: values from `tool_output` in opencode config, or MAX_LINES / MAX_BYTES if unset. + */ + readonly limits: () => Effect.Effect<{ maxLines: number; maxBytes: number }> } export class Service extends Context.Service()("@opencode/Truncate") {} @@ -68,9 +73,20 @@ export const layer = Layer.effect( return file }) + const limits = Effect.fn("Truncate.limits")(function* () { + const configSvc = yield* Effect.serviceOption(Config.Service) + if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES } + const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined))) + return { + maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES, + maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES, + } + }) + const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { - const maxLines = options.maxLines ?? MAX_LINES - const maxBytes = options.maxBytes ?? MAX_BYTES + const resolved = yield* limits() + const maxLines = options.maxLines ?? resolved.maxLines + const maxBytes = options.maxBytes ?? resolved.maxBytes const direction = options.direction ?? "head" const lines = text.split("\n") const totalBytes = Buffer.byteLength(text, "utf-8") @@ -135,7 +151,7 @@ export const layer = Layer.effect( Effect.forkScoped, ) - return Service.of({ cleanup, write, output }) + return Service.of({ cleanup, write, output, limits }) }), ) diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 19c0dc834b6..71399f1577f 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -1,5 +1,4 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import * as Tool from "./tool" import TurndownService from "turndown" @@ -10,13 +9,14 @@ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes -const parameters = z.object({ - url: z.string().describe("The URL to fetch content from"), - format: z - .enum(["text", "markdown", "html"]) - .default("markdown") - .describe("The format to return the content in (text, markdown, or html). Defaults to markdown."), - timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(), +export const Parameters = Schema.Struct({ + url: Schema.String.annotate({ description: "The URL to fetch content from" }), + format: Schema.Literals(["text", "markdown", "html"]) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))) + .annotate({ + description: "The format to return the content in (text, markdown, or html). Defaults to markdown.", + }), + timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }), }) export const WebFetchTool = Tool.define( @@ -27,8 +27,8 @@ export const WebFetchTool = Tool.define( return { description: DESCRIPTION, - parameters, - execute: (params: z.infer, ctx: Tool.Context) => + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { throw new Error("URL must start with http:// or https://") diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 34cefd031f4..ff4c696a25e 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,27 +1,24 @@ -import z from "zod" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" import * as McpExa from "./mcp-exa" import DESCRIPTION from "./websearch.txt" -const Parameters = z.object({ - query: z.string().describe("Websearch query"), - numResults: z.number().optional().describe("Number of search results to return (default: 8)"), - livecrawl: z - .enum(["fallback", "preferred"]) - .optional() - .describe( +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ description: "Websearch query" }), + numResults: Schema.optional(Schema.Number).annotate({ + description: "Number of search results to return (default: 8)", + }), + livecrawl: Schema.optional(Schema.Literals(["fallback", "preferred"])).annotate({ + description: "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", - ), - type: z - .enum(["auto", "fast", "deep"]) - .optional() - .describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"), - contextMaxCharacters: z - .number() - .optional() - .describe("Maximum characters for context string optimized for LLMs (default: 10000)"), + }), + type: Schema.optional(Schema.Literals(["auto", "fast", "deep"])).annotate({ + description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + }), + contextMaxCharacters: Schema.optional(Schema.Number).annotate({ + description: "Maximum characters for context string optimized for LLMs (default: 10000)", + }), }) export const WebSearchTool = Tool.define( @@ -34,7 +31,7 @@ export const WebSearchTool = Tool.define( return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString()) }, parameters: Parameters, - execute: (params: z.infer, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { yield* ctx.ask({ permission: "websearch", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index d1d69e40c42..9d4dfa8e8fb 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import * as path from "path" import { Effect } from "effect" import * as Tool from "./tool" @@ -20,6 +20,13 @@ import * as Bom from "@/util/bom" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 +export const Parameters = Schema.Struct({ + content: Schema.String.annotate({ description: "The content to write to the file" }), + filePath: Schema.String.annotate({ + description: "The absolute path to the file to write (must be absolute, not relative)", + }), +}) + export const WriteTool = Tool.define( "write", Effect.gen(function* () { @@ -30,10 +37,7 @@ export const WriteTool = Tool.define( return { description: DESCRIPTION, - parameters: z.object({ - content: z.string().describe("The content to write to the file"), - filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), - }), + parameters: Parameters, execute: (params: { content: string; filePath: string }, ctx: Tool.Context) => Effect.gen(function* () { const filepath = path.isAbsolute(params.filePath) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index f6d2c5e5c04..332a5c76ebf 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -22,6 +22,43 @@ export function zod(schema: S): z.ZodType> } +/** + * Derive a Zod value from an Effect Schema (or a Schema-backed export with a + * `.zod` static) and narrow the result to `z.ZodObject` so `.shape`, + * `.omit`, `.extend`, and friends are accessible. + * + * The `zod()` walker returns `z.ZodType` because not every AST node decodes + * to an object; this helper keeps the "I started from a `Schema.Struct`" cast + * in one place instead of sprinkling `as unknown as z.ZodObject` across + * call sites. + * + * The return is intentionally loose — carrying Schema field types through the + * mapped `.omit()` / `.extend()` surface triggers brand-intersection + * explosions for branded primitives (`string & Brand<"SessionID">` extends + * `object` via the brand and gets walked into the prototype by `DeepPartial`, + * `updateSchema`, etc.), and zod's inference through `z.ZodType` + * wrappers also can't reconstruct `T` cleanly. Consumers that care about the + * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type. + */ +export function zodObject(schema: S): z.ZodObject { + const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast) + return derived as unknown as z.ZodObject +} + +function isZodType(value: unknown): value is z.ZodTypeAny { + return typeof value === "object" && value !== null && "_zod" in value +} + +/** + * Emit a JSON Schema for a tool/route parameter schema — derives the zod form + * via the walker so Effect Schema inputs flow through the same zod-openapi + * pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what + * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper. + */ +export function toJsonSchema(schema: S) { + return z.toJSONSchema(zod(schema), { io: "input" }) +} + function walk(ast: SchemaAST.AST): z.ZodTypeAny { const cached = walkCache.get(ast) if (cached) return cached @@ -32,8 +69,17 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny { function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined - if (override) return override + // `description` annotations layer on top of an override so callers can + // reuse a shared override schema (e.g. `SessionID`) and still add a + // per-field description on the outer wrapper. + const base = override ?? bodyWithChecks(ast) + const desc = SchemaAST.resolveDescription(ast) + const ref = SchemaAST.resolveIdentifier(ast) + const described = desc ? base.describe(desc) : base + return ref ? described.meta({ ref }) : described +} +function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.Class wraps its fields in a Declaration AST plus an encoding that // constructs the class instance. For the Zod derivation we want the plain // field shape (the decoded/consumer view), not the class instance — so @@ -47,11 +93,7 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) - const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base - const desc = SchemaAST.resolveDescription(ast) - const ref = SchemaAST.resolveIdentifier(ast) - const described = desc ? checked.describe(desc) : checked - return ref ? described.meta({ ref }) : described + return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base } // Walk the encoded side and apply each link's decode to produce the decoded diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 405f6a7182f..0c50482bbda 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -1,5 +1,43 @@ import { Schema } from "effect" +/** + * Integer greater than zero. + */ +export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) + +/** + * Integer greater than or equal to zero. + */ +export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + +/** + * Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable` + * until `effect:core/x228my` ("Types.DeepMutable widens unknown to `{}`") lands. + * + * The upstream version falls through `unknown` into `{ -readonly [K in keyof T]: ... }` + * where `keyof unknown = never`, so `unknown` collapses to `{}`. This local + * version gates the object branch on `extends object` (which `unknown` does + * not) so `unknown` passes through untouched. + * + * Primitive bailout matches upstream — without it, branded strings like + * `string & Brand<"SessionID">` fall into the object branch and get their + * prototype methods walked. + * + * Tuple branch preserves readonly tuples (e.g. `ConfigPlugin.Spec`'s + * `readonly [string, Options]`); the general array branch would otherwise + * widen them to unbounded arrays. + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export type DeepMutable = T extends string | number | boolean | bigint | symbol | Function + ? T + : T extends readonly [unknown, ...unknown[]] + ? { -readonly [K in keyof T]: DeepMutable } + : T extends readonly (infer U)[] + ? DeepMutable[] + : T extends object + ? { -readonly [K in keyof T]: DeepMutable } + : T + /** * Attach static methods to a schema object. Designed to be used with `.pipe()`: * @@ -16,13 +54,16 @@ export const withStatics = (schema: S): S & M => Object.assign(schema, methods(schema)) -declare const NewtypeBrand: unique symbol -type NewtypeBrand = { readonly [NewtypeBrand]: Tag } - /** * Nominal wrapper for scalar types. The class itself is a valid schema — * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc. * + * Overrides `~type.make` on the derived `Schema.Opaque` so `Schema.Schema.Type` + * of a field using this newtype resolves to `Self` rather than the underlying + * branded phantom. Without that override, passing a class instance to code + * typed against `Schema.Schema.Type` would require a cast even + * though the values are structurally equivalent at runtime. + * * @example * class QuestionID extends Newtype()("QuestionID", Schema.String) { * static make(id: string): QuestionID { @@ -34,10 +75,8 @@ type NewtypeBrand = { readonly [NewtypeBrand]: Tag } */ export function Newtype() { return (tag: Tag, schema: S) => { - type Branded = NewtypeBrand - abstract class Base { - declare readonly [NewtypeBrand]: Tag + declare readonly _newtype: Tag static make(value: Schema.Schema.Type): Self { return value as unknown as Self @@ -46,8 +85,10 @@ export function Newtype() { Object.setPrototypeOf(Base, schema) - return Base as unknown as (abstract new (_: never) => Branded) & { + return Base as unknown as (abstract new (_: never) => { readonly _newtype: Tag }) & { readonly make: (value: Schema.Schema.Type) => Self - } & Omit, "make"> + } & Omit, "make" | "~type.make"> & { + readonly "~type.make": Self + } } } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index bbebeaa4965..e122fe453b9 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -13,7 +13,7 @@ import { errorMessage } from "../util/error" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" -import { Effect, Layer, Path, Scope, Context, Stream } from "effect" +import { Effect, Layer, Path, Schema, Scope, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -26,15 +26,15 @@ const log = Log.create({ service: "worktree" }) export const Event = { Ready: BusEvent.define( "worktree.ready", - z.object({ - name: z.string(), - branch: z.string(), + Schema.Struct({ + name: Schema.String, + branch: Schema.String, }), ), Failed: BusEvent.define( "worktree.failed", - z.object({ - message: z.string(), + Schema.Struct({ + message: Schema.String, }), ), } diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 6f96a89c877..3d602ae6fd7 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -1,6 +1,5 @@ import { describe, expect } from "bun:test" -import { Deferred, Effect, Layer, Stream } from "effect" -import z from "zod" +import { Deferred, Effect, Layer, Schema, Stream } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" @@ -9,8 +8,8 @@ import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture import { testEffect } from "../lib/effect" const TestEvent = { - Ping: BusEvent.define("test.effect.ping", z.object({ value: z.number() })), - Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })), + Ping: BusEvent.define("test.effect.ping", Schema.Struct({ value: Schema.Number })), + Pong: BusEvent.define("test.effect.pong", Schema.Struct({ message: Schema.String })), } const node = CrossSpawnSpawner.defaultLayer diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index e42bd5299e7..2808344577b 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" -import z from "zod" +import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -const TestEvent = BusEvent.define("test.integration", z.object({ value: z.number() })) +const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) function withInstance(directory: string, fn: () => Promise) { return Instance.provide({ directory, fn }) @@ -42,7 +42,7 @@ describe("Bus integration: acquireRelease subscriber pattern", () => { await using tmp = await tmpdir() const received: Array<{ type: string; value?: number }> = [] - const OtherEvent = BusEvent.define("test.other", z.object({ value: z.number() })) + const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number })) await withInstance(tmp.path, async () => { Bus.subscribeAll((evt) => { diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index 3df179787dd..cdacdd51793 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test" -import z from "zod" +import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" const TestEvent = { - Ping: BusEvent.define("test.ping", z.object({ value: z.number() })), - Pong: BusEvent.define("test.pong", z.object({ message: z.string() })), + Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })), + Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })), } function withInstance(directory: string, fn: () => Promise) { diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index b5fc5b8f113..9dc5949f089 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -3,6 +3,7 @@ import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" +import { InstallationChannel } from "../../src/installation/version" const encoder = new TextEncoder() @@ -68,11 +69,15 @@ describe("installation", () => { expect(result).toBe("4.0.0-beta.1") }) - test("reads npm registry versions", async () => { + test("reads npm versions via npm view", async () => { + const calls: string[][] = [] const layer = testLayer( - () => jsonResponse({ version: "1.5.0" }), + () => { + throw new Error("unexpected http request") + }, (cmd, args) => { - if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n" + calls.push([cmd, ...args]) + if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n' return "" }, ) @@ -81,18 +86,47 @@ describe("installation", () => { Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)), ) expect(result).toBe("1.5.0") + expect(calls).toContainEqual(["npm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"]) }) - test("reads npm registry versions for bun method", async () => { + test("reads npm versions via bun pm view", async () => { + const calls: string[][] = [] const layer = testLayer( - () => jsonResponse({ version: "1.6.0" }), - () => "", + () => { + throw new Error("unexpected http request") + }, + (cmd, args) => { + calls.push([cmd, ...args]) + if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n' + return "" + }, ) const result = await Effect.runPromise( Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)), ) expect(result).toBe("1.6.0") + expect(calls).toContainEqual(["bun", "pm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"]) + }) + + test("reads npm versions via pnpm view", async () => { + const calls: string[][] = [] + const layer = testLayer( + () => { + throw new Error("unexpected http request") + }, + (cmd, args) => { + calls.push([cmd, ...args]) + if (cmd === "pnpm" && args[0] === "view") return '"1.7.0"\n' + return "" + }, + ) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("1.7.0") + expect(calls).toContainEqual(["pnpm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"]) }) test("reads scoop manifest versions", async () => { diff --git a/packages/opencode/test/npm.test.ts b/packages/opencode/test/npm.test.ts index a8ec92c2a7b..b27d668c8c7 100644 --- a/packages/opencode/test/npm.test.ts +++ b/packages/opencode/test/npm.test.ts @@ -1,10 +1,50 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" +import { Effect, Layer, Stream } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Global } from "@opencode-ai/shared/global" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Npm } from "../src/npm" import { tmpdir } from "./fixture/fixture" const win = process.platform === "win32" +const encoder = new TextEncoder() +function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") { + const spawner = ChildProcessSpawner.make((command) => { + const std = ChildProcess.isStandardCommand(command) ? command : undefined + const output = handler(std?.command ?? "", std?.args ?? []) + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ) + }) + return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner) +} + +function testLayer(spawnHandler?: (cmd: string, args: readonly string[]) => string) { + return Npm.layer.pipe( + Layer.provide(mockSpawner(spawnHandler)), + Layer.provide(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layer), + Layer.provide(NodeFileSystem.layer), + ) +} + const writePackage = (dir: string, pkg: Record) => Bun.write( path.join(dir, "package.json"), @@ -53,3 +93,51 @@ describe("Npm.install", () => { await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow() }) }) + +describe("Npm.outdated", () => { + test("checks latest via npm view", async () => { + const calls: string[][] = [] + const layer = testLayer((cmd, args) => { + calls.push([cmd, ...args]) + if (cmd === "npm" && args[0] === "view") return '"2.0.0"\n' + return "" + }) + + const result = await Effect.runPromise( + Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)), + ) + + expect(result).toBe(true) + expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"]) + }) + + test("keeps range comparison behavior", async () => { + const layer = testLayer((cmd, args) => { + if (cmd === "npm" && args[0] === "view") return '"2.3.0"\n' + return "" + }) + + const result = await Effect.runPromise( + Npm.Service.use((svc) => svc.outdated("example", "^2.0.0")).pipe(Effect.provide(layer)), + ) + + expect(result).toBe(false) + }) + + test("falls back when npm view is unavailable", async () => { + const calls: string[][] = [] + const layer = testLayer((cmd, args) => { + calls.push([cmd, ...args]) + if (cmd === "pnpm" && args[0] === "view") return '"2.0.0"\n' + return "" + }) + + const result = await Effect.runPromise( + Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)), + ) + + expect(result).toBe(true) + expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"]) + expect(calls).toContainEqual(["pnpm", "view", "example", "dist-tags.latest", "--json"]) + }) +}) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 54636eb7039..d626f6d7621 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -35,7 +35,7 @@ const svc = { create(input?: SessionNs.CreateInput) { return run(SessionNs.Service.use((svc) => svc.create(input))) }, - setArchived(input: z.output) { + setArchived(input: z.output) { return run(SessionNs.Service.use((svc) => svc.setArchived(input))) }, } diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts new file mode 100644 index 00000000000..e2e653f8209 --- /dev/null +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Context } from "effect" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace" +import { Log } from "../../src/util" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" + +void Log.init({ print: false }) + +const context = Context.empty() as Context.Context + +function request(path: string, directory: string) { + return ExperimentalHttpApiServer.webHandler().handler( + new Request(`http://localhost${path}`, { + headers: { + "x-kilo-directory": directory, + }, + }), + context, + ) +} + +afterEach(async () => { + await Instance.disposeAll() + await resetDatabase() +}) + +describe("workspace HttpApi", () => { + test("serves read endpoints", async () => { + await using tmp = await tmpdir({ git: true }) + + const [adaptors, workspaces, status] = await Promise.all([ + request(WorkspacePaths.adaptors, tmp.path), + request(WorkspacePaths.list, tmp.path), + request(WorkspacePaths.status, tmp.path), + ]) + + expect(adaptors.status).toBe(200) + expect(await adaptors.json()).toEqual([ + { + type: "worktree", + name: "Worktree", + description: "Create a git worktree", + }, + ]) + + expect(workspaces.status).toBe(200) + expect(await workspaces.json()).toEqual([]) + + expect(status.status).toBe(200) + expect(await status.json()).toEqual([]) + }) +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index a0a8c226c70..b80d6fdf960 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -38,7 +38,7 @@ const svc = { create(input?: SessionNs.CreateInput) { return run(SessionNs.Service.use((svc) => svc.create(input))) }, - messages(input: z.output) { + messages(input: z.output) { return run(SessionNs.Service.use((svc) => svc.messages(input))) }, updateMessage(msg: T) { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 0db4d685ba1..254db50c81e 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1150,6 +1150,30 @@ describe("session.message-v2.fromError", () => { }) }) + test("serializes OpenAI response server_error stream chunks as retryable APIError", () => { + const body = { + type: "error", + sequence_number: 2, + error: { + type: "server_error", + code: "server_error", + message: + "An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_77eccd008d984bf6bf82d1b2c2b68715 in your message.", + param: null, + }, + } + const result = MessageV2.fromError({ message: JSON.stringify(body) }, { providerID }) + + expect(result).toStrictEqual({ + name: "APIError", + data: { + message: body.error.message, + isRetryable: true, + responseBody: JSON.stringify(body), + }, + }) + }) + test("detects context overflow from APICallError provider messages", () => { const cases = [ "prompt is too long: 213462 tokens > 200000 maximum", diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 6317c859575..87ba74b00a8 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -311,4 +311,26 @@ describe("session.message-v2.fromError", () => { const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError expect(result.data.isRetryable).toBe(true) }) + + test("converts OpenAI server_error stream chunks to retryable APIError", () => { + const result = MessageV2.fromError( + { + message: JSON.stringify({ + type: "error", + sequence_number: 2, + error: { + type: "server_error", + code: "server_error", + message: "An error occurred while processing your request.", + param: null, + }, + }), + }, + { providerID: ProviderID.make("openai") }, + ) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.") + }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts new file mode 100644 index 00000000000..5894b261541 --- /dev/null +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" + +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" +import { SessionStatus } from "../../src/session/status" +import { SessionSummary } from "../../src/session/summary" +import { Todo } from "../../src/session/todo" +import { SessionID, MessageID, PartID } from "../../src/session/schema" +import { ProjectID } from "../../src/project/schema" +import { WorkspaceID } from "../../src/control-plane/schema" + +// Covers the session-domain Effect Schema migration. For each migrated +// schema we assert: +// 1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input. +// 2. The derived Zod (`X.zod.parse`) accepts the same input and returns the +// same shape. +// 3. Clearly-invalid input is rejected by both paths. +// +// The point is to lock down the Schema <-> Zod bridge so a future edit to +// any input schema can't silently drop or widen a field on one side. + +// Representative valid IDs — the branded schemas require the right prefix +// (see src/id/id.ts). +const sessionID = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K") +const sessionIDChild = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") +const messageID = MessageID.zod.parse("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") +const partID = PartID.zod.parse("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") +const projectID = ProjectID.zod.parse("proj-alpha") +const workspaceID = WorkspaceID.zod.parse("wrk-primary") + +function decodeUnknown(schema: S) { + const decode = Schema.decodeUnknownSync(schema as any) + return (input: unknown): Schema.Schema.Type => decode(input) as Schema.Schema.Type +} + +describe("Session.Info", () => { + const decode = decodeUnknown(Session.Info) + + test("accepts minimal session", () => { + const input = { + id: sessionID, + slug: "hello", + projectID, + directory: "/tmp/proj", + title: "First session", + version: "0.1.0", + time: { created: 1, updated: 2 }, + } + expect(decode(input)).toEqual(input) + expect(Session.Info.zod.parse(input)).toEqual(input) + }) + + test("round-trips every optional field", () => { + const input = { + id: sessionID, + slug: "fullshape", + projectID, + workspaceID, + directory: "/tmp/proj", + parentID: sessionIDChild, + summary: { + additions: 10, + deletions: 5, + files: 2, + diffs: [{ additions: 1, deletions: 0, file: "a.ts", patch: "--- a/a.ts" }], + }, + share: { url: "https://share.example.com/s/1" }, + title: "Full session", + version: "1.0.0", + time: { created: 100, updated: 200, compacting: 150, archived: 300 }, + permission: [{ action: "allow" as const, pattern: "*", permission: "read" }], + revert: { + messageID, + partID, + snapshot: "snap-1", + diff: "diff-1", + }, + } + expect(decode(input)).toEqual(input) + expect(Session.Info.zod.parse(input)).toEqual(input) + }) + + test("rejects unbranded session id", () => { + const bad = { id: "not-a-session-id" } as unknown + expect(() => decode(bad)).toThrow() + expect(() => Session.Info.zod.parse(bad)).toThrow() + }) + + test("rejects missing required fields", () => { + const bad = { id: sessionID } as unknown + expect(() => decode(bad)).toThrow() + expect(() => Session.Info.zod.parse(bad)).toThrow() + }) +}) + +describe("Session.ProjectInfo", () => { + const decode = decodeUnknown(Session.ProjectInfo) + + test("accepts with and without optional name", () => { + const noName = { id: projectID, worktree: "/tmp/wt" } + const withName = { ...noName, name: "alpha" } + expect(decode(noName)).toEqual(noName) + expect(decode(withName)).toEqual(withName) + expect(Session.ProjectInfo.zod.parse(noName)).toEqual(noName) + expect(Session.ProjectInfo.zod.parse(withName)).toEqual(withName) + }) +}) + +describe("Session.GlobalInfo", () => { + const decode = decodeUnknown(Session.GlobalInfo) + + test("accepts null project", () => { + const input = { + id: sessionID, + slug: "global", + projectID, + directory: "/tmp/proj", + title: "global", + version: "0", + time: { created: 0, updated: 0 }, + project: null, + } + expect(decode(input)).toEqual(input) + expect(Session.GlobalInfo.zod.parse(input)).toEqual(input) + }) + + test("accepts populated project", () => { + const input = { + id: sessionID, + slug: "global", + projectID, + directory: "/tmp/proj", + title: "global", + version: "0", + time: { created: 0, updated: 0 }, + project: { id: projectID, worktree: "/tmp/wt", name: "alpha" }, + } + expect(decode(input)).toEqual(input) + expect(Session.GlobalInfo.zod.parse(input)).toEqual(input) + }) +}) + +describe("Session input schemas", () => { + test("CreateInput accepts undefined and populated forms", () => { + const decode = decodeUnknown(Session.CreateInput) + expect(decode(undefined)).toBeUndefined() + expect(Session.CreateInput.zod.parse(undefined)).toBeUndefined() + + const populated = { + parentID: sessionID, + title: "child", + permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }], + workspaceID, + } + expect(decode(populated)).toEqual(populated) + expect(Session.CreateInput.zod.parse(populated)).toEqual(populated) + }) + + test("ForkInput round-trips", () => { + const decode = decodeUnknown(Session.ForkInput) + const input = { sessionID, messageID } + expect(decode(input)).toEqual(input) + expect(Session.ForkInput.zod.parse(input)).toEqual(input) + // messageID is optional + const bare = { sessionID } + expect(decode(bare)).toEqual(bare) + expect(Session.ForkInput.zod.parse(bare)).toEqual(bare) + }) + + test("SetTitleInput rejects missing title", () => { + expect(() => decodeUnknown(Session.SetTitleInput)({ sessionID })).toThrow() + expect(() => Session.SetTitleInput.zod.parse({ sessionID })).toThrow() + }) + + test("SetArchivedInput accepts both with and without time", () => { + const decode = decodeUnknown(Session.SetArchivedInput) + expect(decode({ sessionID })).toEqual({ sessionID }) + expect(decode({ sessionID, time: 123 })).toEqual({ sessionID, time: 123 }) + }) + + test("SetPermissionInput requires a ruleset", () => { + const decode = decodeUnknown(Session.SetPermissionInput) + const input = { sessionID, permission: [{ action: "deny" as const, pattern: "*", permission: "write" }] } + expect(decode(input)).toEqual(input) + expect(() => decode({ sessionID })).toThrow() + }) + + test("MessagesInput accepts optional limit", () => { + const decode = decodeUnknown(Session.MessagesInput) + expect(decode({ sessionID })).toEqual({ sessionID }) + expect(decode({ sessionID, limit: 50 })).toEqual({ sessionID, limit: 50 }) + }) +}) + +describe("SessionRevert.RevertInput", () => { + const decode = decodeUnknown(SessionRevert.RevertInput) + + test("messageID is required, partID is optional", () => { + const withPart = { sessionID, messageID, partID } + expect(decode(withPart)).toEqual(withPart) + expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart) + + const noPart = { sessionID, messageID } + expect(decode(noPart)).toEqual(noPart) + expect(SessionRevert.RevertInput.zod.parse(noPart)).toEqual(noPart) + + expect(() => decode({ sessionID })).toThrow() + expect(() => SessionRevert.RevertInput.zod.parse({ sessionID })).toThrow() + }) +}) + +describe("SessionSummary.DiffInput", () => { + const decode = decodeUnknown(SessionSummary.DiffInput) + + test("messageID optional", () => { + expect(decode({ sessionID })).toEqual({ sessionID }) + expect(decode({ sessionID, messageID })).toEqual({ sessionID, messageID }) + }) +}) + +describe("SessionStatus.Info", () => { + const decode = decodeUnknown(SessionStatus.Info) + + test("idle / busy discriminators", () => { + expect(decode({ type: "idle" })).toEqual({ type: "idle" }) + expect(decode({ type: "busy" })).toEqual({ type: "busy" }) + expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" }) + }) + + test("retry carries attempt/message/next", () => { + const input = { type: "retry" as const, attempt: 1, message: "transient", next: 500 } + expect(decode(input)).toEqual(input) + expect(SessionStatus.Info.zod.parse(input)).toEqual(input) + }) + + test("rejects unknown type", () => { + expect(() => decode({ type: "bogus" })).toThrow() + expect(() => SessionStatus.Info.zod.parse({ type: "bogus" })).toThrow() + }) +}) + +describe("Todo.Info", () => { + const decode = decodeUnknown(Todo.Info) + + test("three-field round-trip", () => { + const input = { content: "do a thing", status: "pending", priority: "high" } + expect(decode(input)).toEqual(input) + expect(Todo.Info.zod.parse(input)).toEqual(input) + }) +}) + +describe("SessionPrompt input schemas", () => { + test("LoopInput is just sessionID", () => { + const decode = decodeUnknown(SessionPrompt.LoopInput) + expect(decode({ sessionID })).toEqual({ sessionID }) + expect(SessionPrompt.LoopInput.zod.parse({ sessionID } as unknown)).toEqual({ sessionID }) + }) + + test("ShellInput requires agent + command", () => { + const decode = decodeUnknown(SessionPrompt.ShellInput) + const expected = { sessionID, agent: "build", command: "echo hi" } + const input: unknown = expected + expect(decode(input)).toEqual(expected) + expect(SessionPrompt.ShellInput.zod.parse(input as unknown)).toEqual(expected) + expect(() => decode({ sessionID })).toThrow() + }) + + test("PromptInput accepts a text part and a file part", () => { + const decode = decodeUnknown(SessionPrompt.PromptInput) + const expected = { + sessionID, + parts: [ + { type: "text" as const, text: "hello" }, + { type: "file" as const, mime: "image/png", url: "data:image/png;base64,AAAA" }, + ], + } + const input: unknown = expected + const decoded = decode(input) + expect(decoded.parts).toHaveLength(2) + expect(decoded.parts[0]).toMatchObject({ type: "text", text: "hello" }) + expect(decoded.parts[1]).toMatchObject({ type: "file", mime: "image/png" }) + + const viaZod = SessionPrompt.PromptInput.zod.parse(input) + expect(viaZod.parts).toHaveLength(2) + }) + + test("PromptInput rejects unknown part type", () => { + const decode = decodeUnknown(SessionPrompt.PromptInput) + const bad = { + sessionID, + parts: [{ type: "nonsense", payload: 42 }], + } + expect(() => decode(bad)).toThrow() + expect(() => SessionPrompt.PromptInput.zod.parse(bad)).toThrow() + }) + + test("CommandInput round-trips core fields", () => { + const decode = decodeUnknown(SessionPrompt.CommandInput) + const expected = { + sessionID, + arguments: "--flag", + command: "deploy", + } + const input: unknown = expected + expect(decode(input)).toEqual(expected) + expect(SessionPrompt.CommandInput.zod.parse(input)).toEqual(expected) + }) +}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index f63ad9beed9..d4a1d711d84 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -111,9 +111,12 @@ describe("step-finish token propagation via Bus event", () => { mode: "", } as unknown as MessageV2.Info) + // Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part` + // is the mutable domain type. Cast bridges the two — safe because the + // test only reads the value afterwards. let received: MessageV2.Part | undefined const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { - received = event.properties.part + received = event.properties.part as MessageV2.Part }) const tokens = { diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index fd43b543b85..579ca35d4c6 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test" import { tmpdir } from "../fixture/fixture" -import z from "zod" +import { Schema } from "effect" import { Bus } from "../../src/bus" import { Instance } from "../../src/project/instance" import { SyncEvent } from "../../src/sync" @@ -43,13 +43,13 @@ describe("SyncEvent", () => { type: "item.created", version: 1, aggregate: "id", - schema: z.object({ id: z.string(), name: z.string() }), + schema: Schema.Struct({ id: Schema.String, name: Schema.String }), }) const Sent = SyncEvent.define({ type: "item.sent", version: 1, aggregate: "item_id", - schema: z.object({ item_id: z.string(), to: z.string() }), + schema: Schema.Struct({ item_id: Schema.String, to: Schema.String }), }) SyncEvent.init({ diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap new file mode 100644 index 00000000000..eb3fe6cce4d --- /dev/null +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -0,0 +1,495 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`tool parameters JSON Schema (wire shape) apply_patch 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "patchText": { + "description": "The full patch text that describes all changes to be made", + "type": "string", + }, + }, + "required": [ + "patchText", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) bash 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "command": { + "description": "The command to execute", + "type": "string", + }, + "description": { + "description": +"Clear, concise description of what this command does in 5-10 words. Examples: +Input: ls +Output: Lists files in current directory + +Input: git status +Output: Shows working tree status + +Input: npm install +Output: Installs package dependencies + +Input: mkdir foo +Output: Creates directory 'foo'" +, + "type": "string", + }, + "timeout": { + "description": "Optional timeout in milliseconds", + "type": "number", + }, + "workdir": { + "description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.", + "type": "string", + }, + }, + "required": [ + "command", + "description", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "query": { + "description": "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", + "type": "string", + }, + "tokensNum": { + "default": 5000, + "description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", + "maximum": 50000, + "minimum": 1000, + "type": "number", + }, + }, + "required": [ + "query", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) edit 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "filePath": { + "description": "The absolute path to the file to modify", + "type": "string", + }, + "newString": { + "description": "The text to replace it with (must be different from oldString)", + "type": "string", + }, + "oldString": { + "description": "The text to replace", + "type": "string", + }, + "replaceAll": { + "description": "Replace all occurrences of oldString (default false)", + "type": "boolean", + }, + }, + "required": [ + "filePath", + "oldString", + "newString", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) glob 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "path": { + "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.", + "type": "string", + }, + "pattern": { + "description": "The glob pattern to match files against", + "type": "string", + }, + }, + "required": [ + "pattern", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) grep 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "include": { + "description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")", + "type": "string", + }, + "path": { + "description": "The directory to search in. Defaults to the current working directory.", + "type": "string", + }, + "pattern": { + "description": "The regex pattern to search for in file contents", + "type": "string", + }, + }, + "required": [ + "pattern", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) invalid 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "error": { + "type": "string", + }, + "tool": { + "type": "string", + }, + }, + "required": [ + "tool", + "error", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) lsp 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "character": { + "description": "The character offset (1-based, as shown in editors)", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer", + }, + "filePath": { + "description": "The absolute or relative path to the file", + "type": "string", + }, + "line": { + "description": "The line number (1-based, as shown in editors)", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer", + }, + "operation": { + "description": "The LSP operation to perform", + "enum": [ + "goToDefinition", + "findReferences", + "hover", + "documentSymbol", + "workspaceSymbol", + "goToImplementation", + "prepareCallHierarchy", + "incomingCalls", + "outgoingCalls", + ], + "type": "string", + }, + }, + "required": [ + "operation", + "filePath", + "line", + "character", + ], + "type": "object", +} +`; + + +exports[`tool parameters JSON Schema (wire shape) plan 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) question 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "questions": { + "description": "Questions to ask", + "items": { + "properties": { + "header": { + "description": "Very short label (max 30 chars)", + "type": "string", + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean", + }, + "options": { + "description": "Available choices", + "items": { + "properties": { + "description": { + "description": "Explanation of choice", + "type": "string", + }, + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string", + }, + }, + "ref": "QuestionOption", + "required": [ + "label", + "description", + ], + "type": "object", + }, + "type": "array", + }, + "question": { + "description": "Complete question", + "type": "string", + }, + }, + "ref": "QuestionPrompt", + "required": [ + "question", + "header", + "options", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "questions", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) read 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "filePath": { + "description": "The absolute path to the file or directory to read", + "type": "string", + }, + "limit": { + "description": "The maximum number of lines to read (defaults to 2000)", + "type": "number", + }, + "offset": { + "description": "The line number to start reading from (1-indexed)", + "type": "number", + }, + }, + "required": [ + "filePath", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) skill 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "name": { + "description": "The name of the skill from available_skills", + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) task 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "command": { + "description": "The command that triggered this task", + "type": "string", + }, + "description": { + "description": "A short (3-5 words) description of the task", + "type": "string", + }, + "prompt": { + "description": "The task for the agent to perform", + "type": "string", + }, + "subagent_type": { + "description": "The type of specialized agent to use for this task", + "type": "string", + }, + "task_id": { + "description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", + "type": "string", + }, + }, + "required": [ + "description", + "prompt", + "subagent_type", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) todo 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "todos": { + "description": "The updated todo list", + "items": { + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string", + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string", + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string", + }, + }, + "required": [ + "content", + "status", + "priority", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "todos", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "format": { + "default": "markdown", + "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", + "enum": [ + "text", + "markdown", + "html", + ], + "type": "string", + }, + "timeout": { + "description": "Optional timeout in seconds (max 120)", + "type": "number", + }, + "url": { + "description": "The URL to fetch content from", + "type": "string", + }, + }, + "required": [ + "url", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "contextMaxCharacters": { + "description": "Maximum characters for context string optimized for LLMs (default: 10000)", + "type": "number", + }, + "livecrawl": { + "description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + "enum": [ + "fallback", + "preferred", + ], + "type": "string", + }, + "numResults": { + "description": "Number of search results to return (default: 8)", + "type": "number", + }, + "query": { + "description": "Websearch query", + "type": "string", + }, + "type": { + "description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + "enum": [ + "auto", + "fast", + "deep", + ], + "type": "string", + }, + }, + "required": [ + "query", + ], + "type": "object", +} +`; + +exports[`tool parameters JSON Schema (wire shape) write 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "content": { + "description": "The content to write to the file", + "type": "string", + }, + "filePath": { + "description": "The absolute path to the file to write (must be absolute, not relative)", + "type": "string", + }, + }, + "required": [ + "content", + "filePath", + ], + "type": "object", +} +`; diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts new file mode 100644 index 00000000000..8ea008a457b --- /dev/null +++ b/packages/opencode/test/tool/parameters.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test } from "bun:test" +import { Result, Schema } from "effect" +import { toJsonSchema } from "../../src/util/effect-zod" + +// Each tool exports its parameters schema at module scope so this test can +// import them without running the tool's Effect-based init. The JSON Schema +// snapshot captures what the LLM sees; the parse assertions pin down the +// accepts/rejects contract. `toJsonSchema` is the same helper `session/ +// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay +// byte-identical regardless of whether a tool has migrated from zod to Schema. + +import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" +import { Parameters as Bash } from "../../src/tool/bash" +import { Parameters as CodeSearch } from "../../src/tool/codesearch" +import { Parameters as Edit } from "../../src/tool/edit" +import { Parameters as Glob } from "../../src/tool/glob" +import { Parameters as Grep } from "../../src/tool/grep" +import { Parameters as Invalid } from "../../src/tool/invalid" +import { Parameters as Lsp } from "../../src/tool/lsp" +import { Parameters as Plan } from "../../src/tool/plan" +import { Parameters as Question } from "../../src/tool/question" +import { Parameters as Read } from "../../src/tool/read" +import { Parameters as Skill } from "../../src/tool/skill" +import { Parameters as Task } from "../../src/tool/task" +import { Parameters as Todo } from "../../src/tool/todo" +import { Parameters as WebFetch } from "../../src/tool/webfetch" +import { Parameters as WebSearch } from "../../src/tool/websearch" +import { Parameters as Write } from "../../src/tool/write" + +const parse = >(schema: S, input: unknown): S["Type"] => + Schema.decodeUnknownSync(schema)(input) + +const accepts = (schema: Schema.Decoder, input: unknown): boolean => + Result.isSuccess(Schema.decodeUnknownResult(schema)(input)) + +describe("tool parameters", () => { + describe("JSON Schema (wire shape)", () => { + test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) + test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot()) + test("codesearch", () => expect(toJsonSchema(CodeSearch)).toMatchSnapshot()) + test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot()) + test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot()) + test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot()) + test("invalid", () => expect(toJsonSchema(Invalid)).toMatchSnapshot()) + test("lsp", () => expect(toJsonSchema(Lsp)).toMatchSnapshot()) + test("plan", () => expect(toJsonSchema(Plan)).toMatchSnapshot()) + test("question", () => expect(toJsonSchema(Question)).toMatchSnapshot()) + test("read", () => expect(toJsonSchema(Read)).toMatchSnapshot()) + test("skill", () => expect(toJsonSchema(Skill)).toMatchSnapshot()) + test("task", () => expect(toJsonSchema(Task)).toMatchSnapshot()) + test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot()) + test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot()) + test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot()) + test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot()) + }) + + describe("apply_patch", () => { + test("accepts patchText", () => { + expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({ + patchText: "*** Begin Patch\n*** End Patch", + }) + }) + test("rejects missing patchText", () => { + expect(accepts(ApplyPatch, {})).toBe(false) + }) + test("rejects non-string patchText", () => { + expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false) + }) + }) + + describe("bash", () => { + test("accepts minimum: command + description", () => { + expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + }) + test("accepts optional timeout + workdir", () => { + const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + expect(parsed.timeout).toBe(5000) + expect(parsed.workdir).toBe("/tmp") + }) + test("rejects missing description (required by zod)", () => { + expect(accepts(Bash, { command: "ls" })).toBe(false) + }) + test("rejects missing command", () => { + expect(accepts(Bash, { description: "list" })).toBe(false) + }) + }) + + describe("codesearch", () => { + test("accepts query; tokensNum defaults to 5000", () => { + expect(parse(CodeSearch, { query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 }) + }) + test("accepts override tokensNum", () => { + expect(parse(CodeSearch, { query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000) + }) + test("rejects tokensNum under 1000", () => { + expect(accepts(CodeSearch, { query: "x", tokensNum: 500 })).toBe(false) + }) + test("rejects tokensNum over 50000", () => { + expect(accepts(CodeSearch, { query: "x", tokensNum: 60000 })).toBe(false) + }) + }) + + describe("edit", () => { + test("accepts all four fields", () => { + expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({ + filePath: "/a", + oldString: "x", + newString: "y", + replaceAll: true, + }) + }) + test("replaceAll is optional", () => { + const parsed = parse(Edit, { filePath: "/a", oldString: "x", newString: "y" }) + expect(parsed.replaceAll).toBeUndefined() + }) + test("rejects missing filePath", () => { + expect(accepts(Edit, { oldString: "x", newString: "y" })).toBe(false) + }) + }) + + describe("glob", () => { + test("accepts pattern-only", () => { + expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" }) + }) + test("accepts optional path", () => { + expect(parse(Glob, { pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp") + }) + test("rejects missing pattern", () => { + expect(accepts(Glob, {})).toBe(false) + }) + }) + + describe("grep", () => { + test("accepts pattern-only", () => { + expect(parse(Grep, { pattern: "TODO" })).toEqual({ pattern: "TODO" }) + }) + test("accepts optional path + include", () => { + const parsed = parse(Grep, { pattern: "TODO", path: "/tmp", include: "*.ts" }) + expect(parsed.path).toBe("/tmp") + expect(parsed.include).toBe("*.ts") + }) + test("rejects missing pattern", () => { + expect(accepts(Grep, {})).toBe(false) + }) + }) + + describe("invalid", () => { + test("accepts tool + error", () => { + expect(parse(Invalid, { tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" }) + }) + test("rejects missing fields", () => { + expect(accepts(Invalid, { tool: "foo" })).toBe(false) + expect(accepts(Invalid, { error: "bar" })).toBe(false) + }) + }) + + describe("lsp", () => { + test("accepts all fields", () => { + const parsed = parse(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 1 }) + expect(parsed.operation).toBe("hover") + }) + test("rejects line < 1", () => { + expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 0, character: 1 })).toBe(false) + }) + test("rejects character < 1", () => { + expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 0 })).toBe(false) + }) + test("rejects unknown operation", () => { + expect(accepts(Lsp, { operation: "bogus", filePath: "/a.ts", line: 1, character: 1 })).toBe(false) + }) + }) + + describe("plan", () => { + test("accepts empty object", () => { + expect(parse(Plan, {})).toEqual({}) + }) + }) + + describe("question", () => { + test("accepts questions array", () => { + const parsed = parse(Question, { + questions: [ + { + question: "pick one", + header: "Header", + custom: false, + options: [{ label: "a", description: "desc" }], + }, + ], + }) + expect(parsed.questions.length).toBe(1) + }) + test("rejects missing questions", () => { + expect(accepts(Question, {})).toBe(false) + }) + }) + + describe("read", () => { + test("accepts filePath-only", () => { + expect(parse(Read, { filePath: "/a" }).filePath).toBe("/a") + }) + test("accepts optional offset + limit", () => { + const parsed = parse(Read, { filePath: "/a", offset: 10, limit: 100 }) + expect(parsed.offset).toBe(10) + expect(parsed.limit).toBe(100) + }) + }) + + describe("skill", () => { + test("accepts name", () => { + expect(parse(Skill, { name: "foo" }).name).toBe("foo") + }) + test("rejects missing name", () => { + expect(accepts(Skill, {})).toBe(false) + }) + }) + + describe("task", () => { + test("accepts description + prompt + subagent_type", () => { + const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" }) + expect(parsed.subagent_type).toBe("general") + }) + test("rejects missing prompt", () => { + expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false) + }) + }) + + describe("todo", () => { + test("accepts todos array", () => { + const parsed = parse(Todo, { + todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }], + }) + expect(parsed.todos.length).toBe(1) + }) + test("rejects missing todos", () => { + expect(accepts(Todo, {})).toBe(false) + }) + }) + + describe("webfetch", () => { + test("accepts url-only", () => { + expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com") + }) + }) + + describe("websearch", () => { + test("accepts query", () => { + expect(parse(WebSearch, { query: "opencode" }).query).toBe("opencode") + }) + }) + + describe("write", () => { + test("accepts content + filePath", () => { + expect(parse(Write, { content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" }) + }) + test("rejects missing filePath", () => { + expect(accepts(Write, { content: "hi" })).toBe(false) + }) + }) +}) diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 00d1e039a7d..283708767da 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -1,13 +1,13 @@ import { describe, test, expect } from "bun:test" -import { Effect, Layer, ManagedRuntime } from "effect" -import z from "zod" +import { Effect, Layer, ManagedRuntime, Schema } from "effect" import { Agent } from "../../src/agent/agent" +import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "../../src/tool" import { Truncate } from "../../src/tool" const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) -const params = z.object({ input: z.string() }) +const params = Schema.Struct({ input: Schema.String }) function makeTool(id: string, executeFn?: () => void) { return { @@ -56,4 +56,44 @@ describe("Tool.define", () => { expect(first).not.toBe(second) }) + + test("execute receives decoded parameters", async () => { + const parameters = Schema.Struct({ + count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))), + }) + const calls: Array> = [] + const info = await runtime.runPromise( + Tool.define( + "test-decoded", + Effect.succeed({ + description: "test tool", + parameters, + execute(args: Schema.Schema.Type) { + calls.push(args) + return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } }) + }, + }), + ), + ) + const ctx: Tool.Context = { + sessionID: SessionID.descending(), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata() { + return Effect.void + }, + ask() { + return Effect.void + }, + } + const tool = await Effect.runPromise(info.init()) + const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType + + await Effect.runPromise(execute({}, ctx)) + await Effect.runPromise(execute({ count: "7" }, ctx)) + + expect(calls).toEqual([{ count: 5 }, { count: 7 }]) + }) }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index d3cec4cd9e3..369ad2d5818 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer } from "effect" import { Truncate } from "../../src/tool" +import { Config } from "../../src/config" import { Identifier } from "../../src/id/id" import { Process } from "../../src/util" import { Filesystem } from "../../src/util" @@ -14,6 +15,14 @@ const ROOT = path.resolve(import.meta.dir, "..", "..") const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer)) +const configuredLayer = (cfg: Config.Info) => + Layer.mergeAll( + Truncate.defaultLayer, + NodeFileSystem.layer, + Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }), + ) +const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg)) + describe("Truncate", () => { describe("output", () => { it.live("truncates large json file by bytes", () => @@ -94,6 +103,61 @@ describe("Truncate", () => { expect(Truncate.MAX_BYTES).toBe(50 * 1024) }) + it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const resolved = yield* svc.limits() + expect(resolved.maxLines).toBe(Truncate.MAX_LINES) + expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES) + }), + ) + + describe("with tool_output config", () => { + const limitsIt = configuredIt({ tool_output: { max_lines: 123, max_bytes: 456 } }) + limitsIt.live("limits() reflects config overrides", () => + Effect.gen(function* () { + const resolved = yield* (yield* Truncate.Service).limits() + expect(resolved.maxLines).toBe(123) + expect(resolved.maxBytes).toBe(456) + }), + ) + + // Huge byte budget isolates line truncation. 100 lines against max_lines: 10 + // proves the configured line limit is what `output()` enforces. + const lineIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 1024 * 1024 } }) + lineIt.live("output() truncates to configured max_lines", () => + Effect.gen(function* () { + const content = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const result = yield* (yield* Truncate.Service).output(content) + expect(result.truncated).toBe(true) + expect(result.content).toContain("...90 lines truncated...") + }), + ) + + // Huge line budget isolates byte truncation. + const byteIt = configuredIt({ tool_output: { max_lines: 1_000_000, max_bytes: 100 } }) + byteIt.live("output() truncates to configured max_bytes", () => + Effect.gen(function* () { + const content = "a".repeat(1000) + const result = yield* (yield* Truncate.Service).output(content) + expect(result.truncated).toBe(true) + expect(result.content).toContain("bytes truncated...") + }), + ) + + const overrideIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 100 } }) + overrideIt.live("per-call options still override config", () => + Effect.gen(function* () { + const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n") + const result = yield* (yield* Truncate.Service).output(content, { + maxLines: 1000, + maxBytes: 1024 * 1024, + }) + expect(result.truncated).toBe(false) + }), + ) + }) + it.live("large single-line file truncates with byte message", () => Effect.gen(function* () { const svc = yield* Truncate.Service diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0f343be39ba..6d640ef781b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.26", + "version": "7.2.25", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 8f386d2aa89..b235b82c123 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.26", + "version": "7.2.25", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 38a9bbb9d53..42d3829791a 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.26", + "version": "7.2.25", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index df969eddca4..33c2fdd9876 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1224,31 +1224,31 @@ export type SyncEventSessionUpdated = { data: { sessionID: string info: { - id: string | null - slug: string | null - projectID: string | null - workspaceID: string | null - directory: string | null - parentID: string | null - summary: { + id?: string | null + slug?: string | null + projectID?: string | null + workspaceID?: string | null + directory?: string | null + parentID?: string | null + summary?: { additions: number deletions: number files: number diffs?: Array } | null share?: { - url: string | null + url?: string | null } - title: string | null - version: string | null + title?: string | null + version?: string | null time?: { - created: number | null - updated: number | null - compacting: number | null - archived: number | null + created?: number | null + updated?: number | null + compacting?: number | null + archived?: number | null } - permission: PermissionRuleset | null - revert: { + permission?: PermissionRuleset | null + revert?: { messageID: string partID?: string snapshot?: string @@ -1828,6 +1828,18 @@ export type Config = { */ prompt?: string } + /* Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned. + */ + tool_output?: { + /** + * Maximum lines of tool output before it is truncated and saved to disk (default: 2000) + */ + max_lines?: number + /** + * Maximum bytes of tool output before it is truncated and saved to disk (default: 51200) + */ + max_bytes?: number + } compaction?: { /** * Enable automatic compaction when context is full (default: true) diff --git a/packages/shared/package.json b/packages/shared/package.json index 140ae7e808b..7dbad997aa5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.26", + "version": "7.2.25", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts index c3317ca2e92..df28d79fbdc 100644 --- a/packages/storybook/.storybook/mocks/app/context/language.ts +++ b/packages/storybook/.storybook/mocks/app/context/language.ts @@ -5,7 +5,7 @@ const dict: Record = { "prompt.loading": "Loading prompt...", "prompt.placeholder.normal": "Ask anything...", "prompt.placeholder.simple": "Ask anything...", - "prompt.placeholder.shell": "Run a shell command...", + "prompt.placeholder.shell": "Run a shell command... {{example}}", "prompt.placeholder.summarizeComment": "Summarize this comment", "prompt.placeholder.summarizeComments": "Summarize these comments", "prompt.action.attachFile": "Attach files", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index f2fae9bf440..2281a90050a 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.26", + "version": "7.2.25", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 339e26e38ac..a22ae4506d7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.26", + "version": "7.2.25", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 1ccac8b1de8..0868279a563 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -109,7 +109,8 @@ const icons = { providers: ``, layers: ``, models: ``, - discard: ``, + discard: ``, // kilocode_change + "arrow-undo-down": ``, } export interface IconProps extends ComponentProps<"svg"> { @@ -119,7 +120,8 @@ export interface IconProps extends ComponentProps<"svg"> { export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) - const viewBox = () => (local.name === "magnifying-glass" ? "0 0 16 16" : "0 0 20 20") + const viewBox = () => + local.name === "magnifying-glass" || local.name === "arrow-undo-down" ? "0 0 16 16" : "0 0 20 20" return (