diff --git a/bun.lock b/bun.lock index cc4990883e6..ab50720b24e 100644 --- a/bun.lock +++ b/bun.lock @@ -129,6 +129,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", + "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", @@ -152,7 +153,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "@valibot/to-json-schema": "1.6.0", - "electron": "40.8.5", + "electron": "41.2.1", "electron-builder": "^26", "electron-vite": "^5", "solid-js": "catalog:", @@ -412,6 +413,7 @@ "@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:", @@ -422,8 +424,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -527,8 +529,8 @@ "zod": "catalog:", }, "devDependencies": { - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", @@ -677,6 +679,7 @@ ], "patchedDependencies": { "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch", }, "overrides": { @@ -716,7 +719,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.11", + "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@types/luxon": "3.7.1", "@types/node": "22.13.9", @@ -1504,6 +1507,8 @@ "@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="], + "@npmcli/config": ["@npmcli/config@10.8.1", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ=="], + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], "@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="], @@ -2214,7 +2219,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="], @@ -2698,7 +2703,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], @@ -3074,7 +3079,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@40.8.5", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A=="], + "electron": ["electron@41.2.1", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA=="], "electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="], diff --git a/flake.lock b/flake.lock index 5c1783a1e94..1c8e62bd825 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1775823930, - "narHash": "sha256-ALT447J7FcxP/97J01A/gp/hgdO5lXRsm+zLMt+gIjc=", + "lastModified": 1776683584, + "narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8c11f88bb9573a10a7d6bf87161ef08455ac70b9", + "rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89", "type": "github" }, "original": { diff --git a/nix/kilo.nix b/nix/kilo.nix index ba6c7afc966..6c6375405f3 100644 --- a/nix/kilo.nix +++ b/nix/kilo.nix @@ -7,6 +7,7 @@ sysctl, makeBinaryWrapper, models-dev, + ripgrep, installShellFiles, versionCheckHook, writableTmpDirAsHomeHook, @@ -51,25 +52,25 @@ stdenvNoCC.mkDerivation (finalAttrs: { runHook postBuild ''; - installPhase = - '' - runHook preInstall - - install -Dm755 dist/@kilocode/cli-*/bin/kilo $out/bin/kilo - install -Dm644 schema.json $out/share/kilo/schema.json - '' - # bun runs sysctl to detect if dunning on rosetta2 - + lib.optionalString stdenvNoCC.hostPlatform.isDarwin '' - wrapProgram $out/bin/kilo \ - --prefix PATH : ${ - lib.makeBinPath [ - sysctl + installPhase = '' + runHook preInstall + + install -Dm755 dist/@kilocode/cli-*/bin/kilo $out/bin/kilo + install -Dm644 schema.json $out/share/kilo/schema.json + + wrapProgram $out/bin/kilo \ + --prefix PATH : ${ + lib.makeBinPath ( + [ + ripgrep ] - } - '' - + '' - runHook postInstall - ''; + # bun runs sysctl to detect if dunning on rosetta2 + ++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl + ) + } + + runHook postInstall + ''; postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) '' # trick yargs into also generating zsh completions diff --git a/package.json b/package.json index 4039051fec8..0378928eecd 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.11", + "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", - "dev:desktop": "bun --cwd packages/desktop tauri dev", + "dev:desktop": "bun --cwd packages/desktop-electron dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", @@ -29,7 +29,7 @@ "@effect/opentelemetry": "4.0.0-beta.48", "@effect/platform-node": "4.0.0-beta.48", "@npmcli/arborist": "9.4.0", - "@types/bun": "1.3.11", + "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", @@ -141,6 +141,7 @@ "happy-dom": ">=20.8.9" }, "patchedDependencies": { + "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" diff --git a/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 b/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..02a57c6f500 Binary files /dev/null and b/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 differ diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index dc75754d477..6bec1de84f0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -141,13 +141,11 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { }> - - - - {props.children} - - - + + + {props.children} + + @@ -293,20 +291,22 @@ export function AppInterface(props: { > - - - {routerProps.children}} - > - - - - - - - - + + + + {routerProps.children}} + > + + + + + + + + + diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index ea5d70065ad..8eb12daf52e 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -12,6 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout" import { getFilename } from "@opencode-ai/shared/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" +import { getProjectAvatarSource } from "@/pages/layout/sidebar-items" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const @@ -26,8 +27,8 @@ export function DialogEditProject(props: { project: LocalProject }) { const [store, setStore] = createStore({ name: defaultName(), - color: props.project.icon?.color || "pink", - iconUrl: props.project.icon?.override || "", + color: props.project.icon?.color, + iconOverride: props.project.icon?.override, startup: props.project.commands?.start ?? "", dragOver: false, iconHover: false, @@ -39,7 +40,7 @@ export function DialogEditProject(props: { project: LocalProject }) { if (!file.type.startsWith("image/")) return const reader = new FileReader() reader.onload = (e) => { - setStore("iconUrl", e.target?.result as string) + setStore("iconOverride", e.target?.result as string) setStore("iconHover", false) } reader.readAsDataURL(file) @@ -68,7 +69,7 @@ export function DialogEditProject(props: { project: LocalProject }) { } function clearIcon() { - setStore("iconUrl", "") + setStore("iconOverride", "") } const saveMutation = useMutation(() => ({ @@ -81,17 +82,17 @@ export function DialogEditProject(props: { project: LocalProject }) { projectID: props.project.id, directory: props.project.worktree, name, - icon: { color: store.color, override: store.iconUrl }, + icon: { color: store.color || "", override: store.iconOverride || "" }, commands: { start }, }) - globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) + globalSync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return } globalSync.project.meta(props.project.worktree, { name, - icon: { color: store.color, override: store.iconUrl || undefined }, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, commands: { start: start || undefined }, }) dialog.close() @@ -130,13 +131,13 @@ export function DialogEditProject(props: { project: LocalProject }) { classList={{ "border-text-interactive-base bg-surface-info-base/20": store.dragOver, "border-border-base hover:border-border-strong": !store.dragOver, - "overflow-hidden": !!store.iconUrl, + "overflow-hidden": !!store.iconOverride, }} onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onClick={() => { - if (store.iconUrl && store.iconHover) { + if (store.iconOverride && store.iconHover) { clearIcon() } else { iconInput?.click() @@ -144,7 +145,11 @@ export function DialogEditProject(props: { project: LocalProject }) { }} > } > - {language.t("dialog.project.edit.icon.alt")} + {(src) => ( + {language.t("dialog.project.edit.icon.alt")} + )}
@@ -174,8 +181,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
@@ -198,7 +205,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
- +
@@ -215,7 +222,10 @@ export function DialogEditProject(props: { project: LocalProject }) { "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": store.color !== color, }} - onClick={() => setStore("color", color)} + onClick={() => { + if (store.color === color && !props.project.icon?.url) return + setStore("color", store.color === color ? undefined : color) + }} > -
+
{(i) => { const key = ServerConnection.key(i) @@ -619,7 +619,7 @@ export function DialogSelectServer() { -
+
= (props) => { } } - const agentsQuery = useQuery(() => loadAgentsQuery(sdk.directory)) - const agentsLoading = () => agentsQuery.isLoading - - const globalProvidersQuery = useQuery(() => loadProvidersQuery(null)) - const providersQuery = useQuery(() => loadProvidersQuery(sdk.directory)) + const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ + queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)], + })) + const agentsLoading = () => agentsQuery.isLoading + const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading + const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) + + const [promptReady] = createResource( + () => prompt.ready().promise, + (p) => p, + ) return (
+ {(promptReady(), null)} (slashPopoverRef = el)} @@ -1359,15 +1366,13 @@ export const PromptInput: Component = (props) => { }} style={{ "padding-bottom": space }} /> - -
- {placeholder()} -
-
+
+ {placeholder()} +
= (props) => {
-
+
= (props) => { -
+
0} fallback={ @@ -1558,7 +1569,10 @@ export const PromptInput: Component = (props) => {
-
+
{ const soundOptions = [noneSound, ...SOUND_OPTIONS] const mono = () => monoInput(settings.appearance.font()) const sans = () => sansInput(settings.appearance.uiFont()) + const terminal = () => terminalInput(settings.appearance.terminalFont()) const soundSelectProps = ( enabled: () => boolean, @@ -276,6 +280,18 @@ export const SettingsGeneral: Component = () => { />
+ + +
+ settings.general.setShowSessionProgressBar(checked)} + /> +
+
) @@ -451,6 +467,29 @@ export const SettingsGeneral: Component = () => { />
+ + +
+ settings.appearance.setTerminalFont(value)} + placeholder={terminalDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + class="text-12-regular" + style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} + /> +
+
) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 57e91d6d335..ff5ff9dada8 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { useServer } from "@/context/server" -import { monoFontFamily, useSettings } from "@/context/settings" +import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" @@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => { }) createEffect(() => { - const font = monoFontFamily(settings.appearance.font()) + const font = terminalFontFamily(settings.appearance.terminalFont()) if (!term) return setOptionIfSupported(term, "fontFamily", font) scheduleFit() @@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => { cols: restoreSize?.cols, rows: restoreSize?.rows, fontSize: 14, - fontFamily: monoFontFamily(settings.appearance.font()), + fontFamily: terminalFontFamily(settings.appearance.terminalFont()), allowTransparency: false, convertEol: false, theme: terminalColors(), diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b7edea70cd1..edebef2b909 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,10 +9,9 @@ import type { } from "@kilocode/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/shared/util/path" -import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" +import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" -import { Persist, persisted } from "@/utils/persist" import type { InitError } from "../pages/error" import { useGlobalSDK } from "./global-sdk" import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap" @@ -24,7 +23,6 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" -import { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query" @@ -56,15 +54,10 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() - const [projectCache, setProjectCache, projectInit] = persisted( - Persist.global("globalSync.project", ["globalSync.project.v1"]), - createStore({ value: [] as Project[] }), - ) - const [globalStore, setGlobalStore] = createStore({ ready: false, path: { state: "", config: "", worktree: "", directory: "", home: "" }, - project: projectCache.value, + project: [], session_todo: {}, provider: { all: [], connected: [], default: {} }, provider_auth: {}, @@ -73,37 +66,18 @@ function createGlobalSync() { }) const queryClient = useQueryClient() - let active = true - let projectWritten = false let bootedAt = 0 let bootingRoot = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined - onCleanup(() => { - active = false - }) onCleanup(() => { if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventTimer !== undefined) clearTimeout(eventTimer) }) - const cacheProjects = () => { - setProjectCache( - "value", - untrack(() => globalStore.project.map(sanitizeProject)), - ) - } - - const setProjects = (next: Project[] | ((draft: Project[]) => void)) => { - projectWritten = true - if (typeof next === "function") { - setGlobalStore("project", produce(next)) - cacheProjects() - return - } + const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { setGlobalStore("project", next) - cacheProjects() } const setBootStore = ((...input: unknown[]) => { @@ -116,22 +90,12 @@ function createGlobalSync() { const set = ((...input: unknown[]) => { if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { - setProjects(input[1] as Project[] | ((draft: Project[]) => void)) + setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) return input[1] } return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore - if (projectInit instanceof Promise) { - void projectInit.then(() => { - if (!active) return - if (projectWritten) return - const cached = projectCache.value - if (cached.length === 0) return - setGlobalStore("project", cached) - }) - } - const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { if (!sessionID) return if (!todos) { @@ -223,16 +187,18 @@ function createGlobalSync() { limit, permission: store.permission, }) - setStore( - "sessionTotal", - estimateRootSessionTotal({ - count: nonArchived.length, - limit: x.limit, - limited: x.limited, - }), - ) - setStore("session", reconcile(sessions, { key: "id" })) - cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) + batch(() => { + setStore( + "sessionTotal", + estimateRootSessionTotal({ + count: nonArchived.length, + limit: x.limit, + limited: x.limited, + }), + ) + setStore("session", reconcile(sessions, { key: "id" })) + cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) + }) sessionMeta.set(directory, { limit }) }) .catch((err) => { @@ -298,6 +264,19 @@ function createGlobalSync() { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 + if (event.type === "session.error") { + const error = event.properties.error + if (error?.name !== "MessageAbortedError") { + console.error("[global-sync] session error", { + scope: directory === "global" ? "global" : "workspace", + directory: directory === "global" ? undefined : directory, + project: directory === "global" ? undefined : getFilename(directory), + sessionID: event.properties.sessionID, + error, + }) + } + } + if (directory === "global") { applyGlobalEvent({ event, diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 86386fd59a3..0b4d843026a 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -19,7 +19,6 @@ import type { State, VcsCache } from "./types" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query" -import { loadSessionsQuery } from "../global-sync" type GlobalStore = { ready: boolean @@ -82,6 +81,9 @@ export async function bootstrapGlobal(input: { input.setGlobalStore("config", x.data!) }), ), + ] + + const slow = [ () => input.queryClient.fetchQuery({ ...loadProvidersQuery(null), @@ -93,9 +95,6 @@ export async function bootstrapGlobal(input: { }), ), }), - ] - - const slow = [ () => retry(() => input.globalSDK.path.get().then((x) => { @@ -183,8 +182,43 @@ function warmSessions(input: { export const loadProvidersQuery = (directory: string | null) => queryOptions({ queryKey: [directory, "providers"], queryFn: skipToken }) -export const loadAgentsQuery = (directory: string | null) => - queryOptions({ queryKey: [directory, "agents"], queryFn: skipToken }) +export const loadAgentsQuery = ( + directory: string | null, + sdk?: KiloClient, + transform?: (x: Awaited>) => void, +) => + queryOptions({ + queryKey: [directory, "agents"], + queryFn: + sdk && transform + ? () => + retry(() => + sdk.app + .agents() + .then(transform) + .then(() => null), + ) + : skipToken, + }) + +export const loadPathQuery = ( + directory: string | null, + sdk?: KiloClient, + transform?: (x: Awaited>) => void, +) => + queryOptions({ + queryKey: [directory, "path"], + queryFn: + sdk && transform + ? () => + retry(() => + sdk.path.get().then(async (x) => { + transform(x) + return x.data! + }), + ) + : skipToken, + }) export async function bootstrapDirectory(input: { directory: string @@ -222,45 +256,27 @@ export async function bootstrapDirectory(input: { input.setStore("lsp", []) if (loading) input.setStore("status", "partial") - const fast = [() => Promise.resolve(input.loadSessions(input.directory))] - - const errs = errors(await runAll(fast)) - if (errs.length > 0) { - console.error("Failed to bootstrap instance", errs[0]) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(errs[0], input.translate), - }) - } - + const rev = (providerRev.get(input.directory) ?? 0) + 1 + providerRev.set(input.directory, rev) ;(async () => { const slow = [ + () => Promise.resolve(input.loadSessions(input.directory)), () => - input.queryClient.ensureQueryData({ - ...loadAgentsQuery(input.directory), - queryFn: () => - retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then( - () => null, - ), - }), + input.queryClient.ensureQueryData( + loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))), + ), () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), - () => - seededProject - ? Promise.resolve() - : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - () => - seededPath - ? Promise.resolve() - : retry(() => - input.sdk.path.get().then((x) => { - input.setStore("path", x.data!) - const next = projectID(x.data?.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - }), - ), + !seededProject && + (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), + !seededPath && + (() => + input.queryClient.ensureQueryData( + loadPathQuery(input.directory, input.sdk, (x) => { + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + )), () => retry(() => input.sdk.vcs.get().then((x) => { @@ -330,7 +346,28 @@ export async function bootstrapDirectory(input: { input.setStore("mcp_ready", true) }), ), - ] + () => + input.queryClient.ensureQueryData({ + ...loadProvidersQuery(input.directory), + queryFn: () => + retry(() => input.sdk.provider.list()) + .then((x) => { + if (providerRev.get(input.directory) !== rev) return + input.setStore("provider", normalizeProviderList(x.data!)) + input.setStore("provider_ready", true) + }) + .catch((err) => { + if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }) + .then(() => null), + }), + ].filter(Boolean) as (() => Promise)[] await waitForPaint() const slowErrs = errors(await runAll(slow)) @@ -344,29 +381,6 @@ export async function bootstrapDirectory(input: { }) } - if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") - - const rev = (providerRev.get(input.directory) ?? 0) + 1 - providerRev.set(input.directory, rev) - void input.queryClient.ensureQueryData({ - ...loadSessionsQuery(input.directory), - queryFn: () => - retry(() => input.sdk.provider.list()) - .then((x) => { - if (providerRev.get(input.directory) !== rev) return - input.setStore("provider", normalizeProviderList(x.data!)) - input.setStore("provider_ready", true) - }) - .catch((err) => { - if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }) - .then(() => null), - }) + if (loading && slowErrs.length === 0) input.setStore("status", "complete") })() } diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index b94b03fbc35..1545fb42089 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -14,6 +14,8 @@ import { type VcsCache, } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" +import { useQuery } from "@tanstack/solid-query" +import { loadPathQuery } from "./bootstrap" export function createChildStoreManager(input: { owner: Owner @@ -154,16 +156,21 @@ export function createChildStoreManager(input: { const init = () => createRoot((dispose) => { - const initialMeta = meta[0].value const initialIcon = icon[0].value + + const pathQuery = useQuery(() => loadPathQuery(directory)) const child = createStore({ project: "", - projectMeta: initialMeta, + projectMeta: undefined, icon: initialIcon, provider_ready: false, provider: { all: [], connected: [], default: {} }, config: {}, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, + get path() { + if (pathQuery.isLoading || !pathQuery.data) + return { state: "", config: "", worktree: "", directory: "", home: "" } + return pathQuery.data + }, status: "loading" as const, agent: [], command: [], @@ -200,11 +207,6 @@ export function createChildStoreManager(input: { child[1]("vcs", (value) => value ?? cached) }) - onPersistedInit(meta[2], () => { - if (child[0].projectMeta !== initialMeta) return - child[1]("projectMeta", meta[0].value) - }) - onPersistedInit(icon[2], () => { if (child[0].icon !== initialIcon) return child[1]("icon", icon[0].value) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index d628cad723d..9196d5b4f76 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -21,7 +21,7 @@ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } project: Project[] - setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void + setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void refresh: () => void }) { if (input.event.type === "global.disposed" || input.event.type === "server.connected") { @@ -33,14 +33,18 @@ export function applyGlobalEvent(input: { const properties = input.event.properties as Project const result = Binary.search(input.project, properties.id, (s) => s.id) if (result.found) { - input.setGlobalProject((draft) => { - draft[result.index] = { ...draft[result.index], ...properties } - }) + input.setGlobalProject( + produce((draft) => { + draft[result.index] = { ...draft[result.index], ...properties } + }), + ) return } - input.setGlobalProject((draft) => { - draft.splice(result.index, 0, properties) - }) + input.setGlobalProject( + produce((draft) => { + draft.splice(result.index, 0, properties) + }), + ) } function cleanupSessionCaches( diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 192d2499900..603c3c5961c 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -391,37 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ? globalSync.data.project.find((x) => x.id === projectID) : globalSync.data.project.find((x) => x.worktree === project.worktree) - const local = childStore.projectMeta - const localOverride = - local?.name !== undefined || - local?.commands?.start !== undefined || - local?.icon?.override !== undefined || - local?.icon?.color !== undefined - - const base = { - ...metadata, - ...project, - icon: { - url: metadata?.icon?.url, - override: metadata?.icon?.override ?? childStore.icon, - color: metadata?.icon?.color, - }, - } - - const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride) - if (!isGlobal) return base - - return { - ...base, - id: base.id ?? "global", - name: local?.name, - commands: local?.commands, - icon: { - url: base.icon?.url, - override: local?.icon?.override, - color: local?.icon?.color, - }, - } + return { ...metadata, ...project } } const roots = createMemo(() => { @@ -516,7 +486,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } for (const project of projects) { - if (project.icon?.color) continue + if (project.icon?.color || project.icon?.override || project.icon?.url) continue const worktree = project.worktree const existing = colors[worktree] const color = existing ?? pickAvailableColor(used) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 9b666e5e751..15af57b355e 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -185,9 +185,9 @@ function createPromptSession(dir: string, id: string | undefined) { return { ready, - current: createMemo(() => store.prompt), + current: () => store.prompt, cursor: createMemo(() => store.cursor), - dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), context: { items: createMemo(() => store.context.items), add(item: ContextItem) { @@ -277,7 +277,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session()) return { - ready: () => session().ready(), + ready: () => session().ready, current: () => session().current(), cursor: () => session().cursor(), dirty: () => session().dirty(), diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index a585789ce46..be2fb49d7e0 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -31,6 +31,7 @@ export interface Settings { showReasoningSummaries: boolean shellToolPartsExpanded: boolean editToolPartsExpanded: boolean + showSessionProgressBar: boolean } updates: { startup: boolean @@ -39,6 +40,7 @@ export interface Settings { fontSize: number mono: string sans: string + terminal: string } keybinds: Record permissions: { @@ -50,13 +52,17 @@ export interface Settings { export const monoDefault = "System Mono" export const sansDefault = "System Sans" +export const terminalDefault = "JetBrainsMono Nerd Font Mono" const monoFallback = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' +const terminalFallback = + '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' const monoBase = monoFallback const sansBase = sansFallback +const terminalBase = terminalFallback function input(font: string | undefined) { return font ?? "" @@ -89,6 +95,14 @@ export function sansFontFamily(font: string | undefined) { return stack(font, sansBase) } +export function terminalInput(font: string | undefined) { + return input(font) +} + +export function terminalFontFamily(font: string | undefined) { + return stack(font, terminalBase) +} + const defaultSettings: Settings = { general: { autoSave: true, @@ -102,6 +116,7 @@ const defaultSettings: Settings = { showReasoningSummaries: false, shellToolPartsExpanded: false, editToolPartsExpanded: false, + showSessionProgressBar: true, }, updates: { startup: true, @@ -110,6 +125,7 @@ const defaultSettings: Settings = { fontSize: 14, mono: "", sans: "", + terminal: "", }, keybinds: {}, permissions: { @@ -213,6 +229,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setEditToolPartsExpanded(value: boolean) { setStore("general", "editToolPartsExpanded", value) }, + showSessionProgressBar: withFallback( + () => store.general?.showSessionProgressBar, + defaultSettings.general.showSessionProgressBar, + ), + setShowSessionProgressBar(value: boolean) { + setStore("general", "showSessionProgressBar", value) + }, }, updates: { startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), @@ -233,6 +256,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setUIFont(value: string) { setStore("appearance", "sans", value.trim() ? value : "") }, + terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal), + setTerminalFont(value: string) { + setStore("appearance", "terminal", value.trim() ? value : "") + }, }, keybinds: { get: (action: string) => store.keybinds?.[action], diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index afc5d08765f..6a5c8a24a11 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -564,7 +564,9 @@ export const dict = { "settings.general.row.theme.title": "السمة", "settings.general.row.theme.description": "تخصيص سمة Kilo.", "settings.general.row.font.title": "خط الكود", - "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات", + "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "خط الواجهة", "settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها", "settings.general.row.followup.title": "سلوك المتابعة", @@ -579,6 +581,8 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", "settings.general.row.editToolPartsExpanded.description": "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", + "settings.general.row.showSessionProgressBar.title": "إظهار شريط تقدم الجلسة", + "settings.general.row.showSessionProgressBar.description": "عرض شريط التقدم المتحرك أعلى الجلسة أثناء عمل الوكيل", "settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 217a017e606..23ab983bd65 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -572,7 +572,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Personalize como o Kilo é tematizado.", "settings.general.row.font.title": "Fonte de código", - "settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais", + "settings.general.row.font.description": "Personalize a fonte usada em blocos de código", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Fonte da interface", "settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface", "settings.general.row.followup.title": "Comportamento de acompanhamento", @@ -588,6 +590,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", "settings.general.row.editToolPartsExpanded.description": "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", + "settings.general.row.showSessionProgressBar.title": "Mostrar barra de progresso da sessão", + "settings.general.row.showSessionProgressBar.description": + "Exibir a barra de progresso animada no topo da sessão quando o agente estiver trabalhando", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 46b40269b00..e8944d2406b 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -637,7 +637,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Prilagodi temu Kilo-a.", "settings.general.row.font.title": "Font za kod", - "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima", + "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI font", "settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu", "settings.general.row.followup.title": "Ponašanje nadovezivanja", @@ -653,6 +655,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", "settings.general.row.editToolPartsExpanded.description": "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", + "settings.general.row.showSessionProgressBar.title": "Prikaži traku napretka sesije", + "settings.general.row.showSessionProgressBar.description": + "Prikaži animiranu traku napretka na vrhu sesije kada agent radi", "settings.general.row.wayland.title": "Koristi nativni Wayland", "settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 66b6ead5799..a6fb99eeac6 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -632,7 +632,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Tilpas hvordan Kilo er temabestemt.", "settings.general.row.font.title": "Kode-skrifttype", - "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler", + "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI-skrifttype", "settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen", "settings.general.row.followup.title": "Opfølgningsadfærd", @@ -647,6 +649,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", "settings.general.row.editToolPartsExpanded.description": "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", + "settings.general.row.showSessionProgressBar.title": "Vis sessionens fremdriftslinje", + "settings.general.row.showSessionProgressBar.description": + "Vis den animerede fremdriftslinje øverst i sessionen, når agenten arbejder", "settings.general.row.wayland.title": "Brug native Wayland", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e5ca0038a3e..4b6c2310714 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -581,7 +581,9 @@ export const dict = { "settings.general.row.theme.title": "Thema", "settings.general.row.theme.description": "Das Thema von Kilo anpassen.", "settings.general.row.font.title": "Code-Schriftart", - "settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen", + "settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI-Schriftart", "settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen", "settings.general.row.followup.title": "Verhalten bei Folgefragen", @@ -598,6 +600,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", "settings.general.row.editToolPartsExpanded.description": "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", + "settings.general.row.showSessionProgressBar.title": "Sitzungsfortschrittsleiste anzeigen", + "settings.general.row.showSessionProgressBar.description": + "Die animierte Fortschrittsleiste oben in der Sitzung anzeigen, wenn der Agent arbeitet", "settings.general.row.wayland.title": "Natives Wayland verwenden", "settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 30b5fce6361..11c83dde208 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -735,7 +735,9 @@ export const dict = { "settings.general.row.theme.title": "Theme", "settings.general.row.theme.description": "Customise how Kilo is themed.", "settings.general.row.font.title": "Code Font", - "settings.general.row.font.description": "Customise the font used in code blocks and terminals", + "settings.general.row.font.description": "Customise the font used in code blocks", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI Font", "settings.general.row.uiFont.description": "Customise the font used throughout the interface", "settings.general.row.followup.title": "Follow-up behavior", @@ -760,6 +762,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", "settings.general.row.editToolPartsExpanded.description": "Show edit, write, and patch tool parts expanded by default in the timeline", + "settings.general.row.showSessionProgressBar.title": "Show session progress bar", + "settings.general.row.showSessionProgressBar.description": + "Display the animated progress bar at the top of the session when the agent is working", "settings.general.row.wayland.title": "Use native Wayland", "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c00c2ba50c9..bae6b6e3b18 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -640,7 +640,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Personaliza el tema de Kilo.", "settings.general.row.font.title": "Fuente de código", - "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales", + "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Fuente de la interfaz", "settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz", "settings.general.row.followup.title": "Comportamiento de seguimiento", @@ -657,6 +659,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", "settings.general.row.editToolPartsExpanded.description": "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", + "settings.general.row.showSessionProgressBar.title": "Mostrar barra de progreso de la sesión", + "settings.general.row.showSessionProgressBar.description": + "Mostrar la barra de progreso animada en la parte superior de la sesión cuando el agente esté trabajando", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 2bd6ea940e6..e73c54d2128 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -579,7 +579,9 @@ export const dict = { "settings.general.row.theme.title": "Thème", "settings.general.row.theme.description": "Personnaliser le thème d'Kilo.", "settings.general.row.font.title": "Police de code", - "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux", + "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Police de l'interface", "settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface", "settings.general.row.followup.title": "Comportement de suivi", @@ -596,6 +598,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", "settings.general.row.editToolPartsExpanded.description": "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", + "settings.general.row.showSessionProgressBar.title": "Afficher la barre de progression de la session", + "settings.general.row.showSessionProgressBar.description": + "Afficher la barre de progression animée en haut de la session lorsque l'agent travaille", "settings.general.row.wayland.title": "Utiliser Wayland natif", "settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 9d1657c12f4..9d68b8975d4 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -569,7 +569,9 @@ export const dict = { "settings.general.row.theme.title": "テーマ", "settings.general.row.theme.description": "Kiloのテーマをカスタマイズします。", "settings.general.row.font.title": "コードフォント", - "settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします", + "settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UIフォント", "settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします", "settings.general.row.followup.title": "フォローアップの動作", @@ -585,6 +587,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", "settings.general.row.editToolPartsExpanded.description": "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", + "settings.general.row.showSessionProgressBar.title": "セッション進行状況バーを表示", + "settings.general.row.showSessionProgressBar.description": + "エージェントの作業中に、セッション上部にアニメーション付きの進行状況バーを表示します", "settings.general.row.wayland.title": "ネイティブWaylandを使用", "settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 18274900092..234980c1038 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -566,7 +566,9 @@ export const dict = { "settings.general.row.theme.title": "테마", "settings.general.row.theme.description": "Kilo 테마 사용자 지정", "settings.general.row.font.title": "코드 글꼴", - "settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정", + "settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI 글꼴", "settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정", "settings.general.row.followup.title": "후속 조치 동작", @@ -581,6 +583,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기", "settings.general.row.editToolPartsExpanded.description": "타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다", + "settings.general.row.showSessionProgressBar.title": "세션 진행 표시줄 표시", + "settings.general.row.showSessionProgressBar.description": + "에이전트가 작업 중일 때 세션 상단에 애니메이션 진행 표시줄을 표시합니다", "settings.general.row.wayland.title": "네이티브 Wayland 사용", "settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a77f8b025aa..0dbf94e347c 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -640,7 +640,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Tilpass hvordan Kilo er tematisert.", "settings.general.row.font.title": "Kodefont", - "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler", + "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI-skrift", "settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet", "settings.general.row.followup.title": "Oppfølgingsadferd", @@ -654,6 +656,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler", "settings.general.row.editToolPartsExpanded.description": "Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen", + "settings.general.row.showSessionProgressBar.title": "Vis fremdriftslinje for sesjonen", + "settings.general.row.showSessionProgressBar.description": + "Vis den animerte fremdriftslinjen øverst i sesjonen når agenten jobber", "settings.general.row.wayland.title": "Bruk innebygd Wayland", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 64170ee1d64..e0b11074339 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -571,7 +571,9 @@ export const dict = { "settings.general.row.theme.title": "Motyw", "settings.general.row.theme.description": "Dostosuj motyw Kilo.", "settings.general.row.font.title": "Czcionka kodu", - "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach", + "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Czcionka interfejsu", "settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie", "settings.general.row.followup.title": "Zachowanie kontynuacji", @@ -586,6 +588,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", "settings.general.row.editToolPartsExpanded.description": "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", + "settings.general.row.showSessionProgressBar.title": "Pokazuj pasek postępu sesji", + "settings.general.row.showSessionProgressBar.description": + "Wyświetlaj animowany pasek postępu u góry sesji, gdy agent pracuje", "settings.general.row.wayland.title": "Użyj natywnego Wayland", "settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 89c9862951b..7838dcd14be 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -637,7 +637,9 @@ export const dict = { "settings.general.row.theme.title": "Тема", "settings.general.row.theme.description": "Настройте оформление Kilo.", "settings.general.row.font.title": "Шрифт кода", - "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах", + "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Шрифт интерфейса", "settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе", "settings.general.row.followup.title": "Поведение уточняющих вопросов", @@ -654,6 +656,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", "settings.general.row.editToolPartsExpanded.description": "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", + "settings.general.row.showSessionProgressBar.title": "Показывать индикатор прогресса сессии", + "settings.general.row.showSessionProgressBar.description": + "Показывать анимированный индикатор прогресса вверху сессии, когда агент работает", "settings.general.row.wayland.title": "Использовать нативный Wayland", "settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 4a6bffdcbb5..90f33affd5b 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -631,7 +631,9 @@ export const dict = { "settings.general.row.theme.title": "ธีม", "settings.general.row.theme.description": "ปรับแต่งวิธีการที่ Kilo มีธีม", "settings.general.row.font.title": "ฟอนต์โค้ด", - "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล", + "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "ฟอนต์ UI", "settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ", "settings.general.row.followup.title": "พฤติกรรมการติดตามผล", @@ -645,6 +647,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", "settings.general.row.editToolPartsExpanded.description": "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", + "settings.general.row.showSessionProgressBar.title": "แสดงแถบความคืบหน้าของเซสชัน", + "settings.general.row.showSessionProgressBar.description": + "แสดงแถบความคืบหน้าแบบเคลื่อนไหวที่ด้านบนของเซสชันเมื่อเอเจนต์กำลังทำงาน", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", "settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท", "settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index a49256cc13f..4a2ab351ff3 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -642,7 +642,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Kilo'un temasını özelleştirin.", "settings.general.row.font.title": "Kod Yazı Tipi", - "settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin", + "settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Arayüz Yazı Tipi", "settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin", "settings.general.row.followup.title": "Takip davranışı", @@ -659,6 +661,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.description": "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", + "settings.general.row.showSessionProgressBar.title": "Oturum ilerleme çubuğunu göster", + "settings.general.row.showSessionProgressBar.description": + "Ajan çalışırken oturumun üst kısmında animasyonlu ilerleme çubuğunu göster", + "settings.general.row.wayland.title": "Yerel Wayland kullan", "settings.general.row.wayland.description": "Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 840c5ac9e11..7c54b6d60bc 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -631,7 +631,9 @@ export const dict = { "settings.general.row.theme.title": "主题", "settings.general.row.theme.description": "自定义 Kilo 的主题。", "settings.general.row.font.title": "代码字体", - "settings.general.row.font.description": "自定义代码块和终端使用的字体", + "settings.general.row.font.description": "自定义代码块使用的字体", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "界面字体", "settings.general.row.uiFont.description": "自定义整个界面使用的字体", "settings.general.row.followup.title": "跟进消息行为", @@ -644,6 +646,8 @@ export const dict = { "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", + "settings.general.row.showSessionProgressBar.title": "显示会话进度条", + "settings.general.row.showSessionProgressBar.description": "当智能体正在工作时,在会话顶部显示动画进度条", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。", "settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index dcd680c9b93..1cc5cb521f8 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -626,7 +626,9 @@ export const dict = { "settings.general.row.theme.title": "主題", "settings.general.row.theme.description": "自訂 Kilo 的主題。", "settings.general.row.font.title": "程式碼字型", - "settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型", + "settings.general.row.font.description": "自訂程式碼區塊使用的字型", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "介面字型", "settings.general.row.uiFont.description": "自訂整個介面使用的字型", "settings.general.row.followup.title": "後續追問行為", @@ -640,6 +642,8 @@ export const dict = { "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊", "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊", "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", + "settings.general.row.showSessionProgressBar.title": "顯示工作階段進度列", + "settings.general.row.showSessionProgressBar.description": "當代理程式正在運作時,在工作階段頂部顯示動畫進度列", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。", "settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 629ac80a869..8db576dd834 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,5 +1,12 @@ @import "@opencode-ai/ui/styles/tailwind"; +@font-face { + font-family: "JetBrainsMono Nerd Font Mono"; + src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2"); + font-weight: normal; + font-style: normal; +} + @layer components { @keyframes session-progress-whip { 0% { @@ -66,4 +73,13 @@ width: auto; } } + + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } } diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f604dd6c5c7..36514f56c63 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/shared/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, type ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -23,11 +23,10 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) - createEffect(() => { - const id = params.id - if (!id) return - void sync.session.sync(id) - }) + createResource( + () => params.id, + (id) => sync.session.sync(id), + ) return ( workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -const roots = (store: SessionStore) => +export const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 8cc554e723b..adb46170a87 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -19,6 +19,12 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers" const KILO_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" +export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) { + return id === KILO_PROJECT_ID + ? "https://kilo.ai/favicon.svg" + : (icon?.override ?? (icon?.color ? undefined : icon?.url)) +} + export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const globalSync = useGlobalSync() const notification = useNotification() @@ -42,9 +48,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
{
- + {(child) => (
- +
)}
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 8a685af3fe0..a39e28cdb3b 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -321,7 +321,7 @@ export const SortableWorkspace = (props: { const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) const busy = createMemo(() => props.ctx.isBusy(props.directory)) - const loading = () => query.isLoading + const loading = () => query.isLoading && count() === 0 const touch = createMediaQuery("(hover: none)") const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const loadMore = async () => { diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 4dbdb24633f..90e74e90a88 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -259,7 +259,7 @@ export function MessageTimeline(props: { if (!id) return idle return sync.data.session_status[id] ?? idle }) - const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") + const working = createMemo(() => sessionStatus().type !== "idle") const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) const [timeoutDone, setTimeoutDone] = createSignal(true) @@ -721,7 +721,7 @@ export function MessageTimeline(props: { "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > - +
- + {(id) => (
@@ -878,12 +878,12 @@ export function MessageTimeline(props: { - void archiveSession(id())}> + void archiveSession(id)}> {language.t("common.archive")} dialog.show(() => )} + onSelect={() => dialog.show(() => )} > {language.t("common.delete")} diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile index 045ff7512ce..6998577bd53 100644 --- a/packages/containers/bun-node/Dockerfile +++ b/packages/containers/bun-node/Dockerfile @@ -6,7 +6,7 @@ FROM ${REGISTRY}/build/base:24.04 SHELL ["/bin/bash", "-lc"] ARG NODE_VERSION=24.4.0 -ARG BUN_VERSION=1.3.11 +ARG BUN_VERSION=1.3.13 ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index ad0a65a8140..22eedeada64 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -53,6 +53,10 @@ export default defineConfig({ build: { rollupOptions: { input: { index: "src/preload/index.ts" }, + output: { + format: "cjs", + entryFileNames: "[name].js", + }, }, }, }, diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 1956e37bbec..ae8cef171f7 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -30,6 +30,7 @@ "electron-store": "^10", "electron-updater": "^6", "electron-window-state": "^5.0.3", + "drizzle-orm": "catalog:", "marked": "^15", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -53,7 +54,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "@valibot/to-json-schema": "1.6.0", - "electron": "40.8.5", + "electron": "41.2.1", "electron-builder": "^26", "electron-vite": "^5", "solid-js": "catalog:", diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 5cee8a97194..98766e1b80d 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -28,8 +28,10 @@ const APP_IDS: Record = { beta: "ai.opencode.desktop.beta", prod: "ai.opencode.desktop", } +const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") -app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev")) +app.setAppUserModelId(appId) +app.setPath("userData", join(app.getPath("appData"), appId)) const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" @@ -40,7 +42,14 @@ import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" -import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" +import { + createLoadingWindow, + createMainWindow, + registerRendererProtocol, + setBackgroundColor, + setDockIcon, +} from "./windows" +import { drizzle } from "drizzle-orm/node-sqlite/driver" import type { Server } from "virtual:opencode-server" const initEmitter = new EventEmitter() @@ -103,6 +112,7 @@ function setupApp() { void app.whenReady().then(async () => { app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() setDockIcon() setupAutoUpdater() await initialize() @@ -137,15 +147,6 @@ async function initialize() { const url = `http://${hostname}:${port}` const password = randomUUID() - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer(hostname, port, password) - server = listener - serverReady.resolve({ - url, - username: "kilo", // kilocode_change - password, - }) - const loadingTask = (async () => { logger.log("sidecar connection started", { url }) @@ -156,10 +157,32 @@ async function initialize() { if (progress.type === "Done") sqliteDone?.resolve() }) + if (needsMigration) { + const { Database, JsonMigration } = await import("virtual:opencode-server") + await JsonMigration.run(drizzle({ client: Database.Client().$client }), { + progress: (event: { current: number; total: number }) => { + const percent = Math.round(event.current / event.total) * 100 + initEmitter.emit("sqlite", { type: "InProgress", value: percent }) + }, + }) + initEmitter.emit("sqlite", { type: "Done" }) + + sqliteDone?.resolve() + } + if (needsMigration) { await sqliteDone?.promise } + logger.log("spawning sidecar", { url }) + const { listener, health } = await spawnLocalServer(hostname, port, password) + server = listener + serverReady.resolve({ + url, + username: "kilo", // kilocode_change + password, + }) + await Promise.race([ health.wait, delay(30_000).then(() => { @@ -172,15 +195,10 @@ async function initialize() { logger.log("loading task finished") })() - const globals = { - updaterEnabled: UPDATER_ENABLED, - deepLinks: pendingDeepLinks, - } - if (needsMigration) { const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) if (show) { - overlay = createLoadingWindow(globals) + overlay = createLoadingWindow() await delay(1_000) } } @@ -192,7 +210,7 @@ async function initialize() { await loadingComplete.promise } - mainWindow = createMainWindow(globals) + mainWindow = createMainWindow() wireMenu() overlay?.close() @@ -229,6 +247,8 @@ registerIpcHandlers({ initEmitter.off("step", listener) } }, + getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), + consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), getWslConfig: () => Promise.resolve(getWslConfig()), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 52d87ed7ee3..8dbca8eea17 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -2,7 +2,14 @@ import { execFile } from "node:child_process" import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" -import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types" +import type { + InitStep, + ServerReadyData, + SqliteMigrationProgress, + TitlebarTheme, + WindowConfig, + WslConfig, +} from "../preload/types" import { getStore } from "./store" import { setTitlebar } from "./windows" @@ -14,6 +21,8 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise + getWindowConfig: () => Promise | WindowConfig + consumeInitialDeepLinks: () => Promise | string[] getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void getWslConfig: () => Promise @@ -37,6 +46,8 @@ export function registerIpcHandlers(deps: Deps) { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) }) + ipcMain.handle("get-window-config", () => deps.getWindowConfig()) + ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index fcf209fb670..0d9a697fa90 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -47,7 +47,7 @@ export function createMenu(deps: Deps) { { label: "New Window", accelerator: "Cmd+Shift+N", - click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }), + click: () => createMainWindow(), }, { type: "separator" }, { role: "close" }, diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts index bad1349eeba..70e3dc9c750 100644 --- a/packages/desktop-electron/src/main/migrate.ts +++ b/packages/desktop-electron/src/main/migrate.ts @@ -4,7 +4,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" import { CHANNEL } from "./constants" -import { getStore, store } from "./store" +import { getStore } from "./store" const TAURI_MIGRATED_KEY = "tauriMigrated" @@ -67,7 +67,7 @@ function migrateFile(datPath: string, filename: string) { } export function migrate() { - if (store.get(TAURI_MIGRATED_KEY)) { + if (getStore().get(TAURI_MIGRATED_KEY)) { log.log("tauri migration: already done, skipping") return } @@ -77,7 +77,7 @@ export function migrate() { if (!existsSync(dir)) { log.log("tauri migration: no tauri data directory found, nothing to migrate") - store.set(TAURI_MIGRATED_KEY, true) + getStore().set(TAURI_MIGRATED_KEY, true) return } @@ -87,5 +87,5 @@ export function migrate() { } log.log("tauri migration: complete") - store.set(TAURI_MIGRATED_KEY, true) + getStore().set(TAURI_MIGRATED_KEY, true) } diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 72a83b5a05f..8c8f0895e93 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,33 +1,33 @@ import { app } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" -import { store } from "./store" +import { getStore } from "./store" export type WslConfig = { enabled: boolean } export type HealthCheck = { wait: Promise } export function getDefaultServerUrl(): string | null { - const value = store.get(DEFAULT_SERVER_URL_KEY) + const value = getStore().get(DEFAULT_SERVER_URL_KEY) return typeof value === "string" ? value : null } export function setDefaultServerUrl(url: string | null) { if (url) { - store.set(DEFAULT_SERVER_URL_KEY, url) + getStore().set(DEFAULT_SERVER_URL_KEY, url) return } - store.delete(DEFAULT_SERVER_URL_KEY) + getStore().delete(DEFAULT_SERVER_URL_KEY) } export function getWslConfig(): WslConfig { - const value = store.get(WSL_ENABLED_KEY) + const value = getStore().get(WSL_ENABLED_KEY) return { enabled: typeof value === "boolean" ? value : false } } export function setWslConfig(config: WslConfig) { - store.set(WSL_ENABLED_KEY, config.enabled) + getStore().set(WSL_ENABLED_KEY, config.enabled) } export async function spawnLocalServer(hostname: string, port: number, password: string) { @@ -39,6 +39,7 @@ export async function spawnLocalServer(hostname: string, port: number, password: hostname, username: "opencode", password, + cors: ["oc://renderer"], }) const wait = (async () => { diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts index 709e820e253..61f0c0a4938 100644 --- a/packages/desktop-electron/src/main/store.ts +++ b/packages/desktop-electron/src/main/store.ts @@ -4,6 +4,10 @@ import { SETTINGS_STORE } from "./constants" const cache = new Map() +// We cannot instantiate the electron-store at module load time because +// module import hoisting causes this to run before app.setPath("userData", ...) +// in index.ts has executed, which would result in files being written to the default directory +// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings). export function getStore(name = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached @@ -11,5 +15,3 @@ export function getStore(name = SETTINGS_STORE) { cache.set(name, next) return next } - -export const store = getStore(SETTINGS_STORE) diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 192e2dab9c9..337e1ca0bcc 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -1,15 +1,24 @@ import windowState from "electron-window-state" -import { app, BrowserWindow, nativeImage, nativeTheme } from "electron" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" +import { app, BrowserWindow, net, nativeImage, nativeTheme, protocol } from "electron" +import { dirname, isAbsolute, join, relative, resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" import type { TitlebarTheme } from "../preload/types" -type Globals = { - updaterEnabled: boolean - deepLinks?: string[] -} - const root = dirname(fileURLToPath(import.meta.url)) +const rendererRoot = join(root, "../renderer") +const rendererProtocol = "oc" +const rendererHost = "renderer" + +protocol.registerSchemesAsPrivileged([ + { + scheme: rendererProtocol, + privileges: { + secure: true, + standard: true, + supportFetchAPI: true, + }, + }, +]) let backgroundColor: string | undefined @@ -54,7 +63,7 @@ export function setDockIcon() { if (!icon.isEmpty()) app.dock?.setIcon(icon) } -export function createMainWindow(globals: Globals) { +export function createMainWindow() { const state = windowState({ defaultWidth: 1280, defaultHeight: 800, @@ -84,15 +93,29 @@ export function createMainWindow(globals: Globals) { } : {}), webPreferences: { - preload: join(root, "../preload/index.mjs"), - sandbox: false, + preload: join(root, "../preload/index.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, }, }) + win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { + const { requestHeaders } = details + upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"]) + callback({ requestHeaders }) + }) + + win.webContents.session.webRequest.onHeadersReceived((details, callback) => { + const { responseHeaders = {} } = details + upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"]) + upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"]) + callback({ responseHeaders }) + }) + state.manage(win) loadWindow(win, "index.html") wireZoom(win) - injectGlobals(win, globals) win.once("ready-to-show", () => { win.show() @@ -101,7 +124,7 @@ export function createMainWindow(globals: Globals) { return win } -export function createLoadingWindow(globals: Globals) { +export function createLoadingWindow() { const mode = tone() const win = new BrowserWindow({ width: 640, @@ -120,17 +143,37 @@ export function createLoadingWindow(globals: Globals) { } : {}), webPreferences: { - preload: join(root, "../preload/index.mjs"), - sandbox: false, + preload: join(root, "../preload/index.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, }, }) loadWindow(win, "loading.html") - injectGlobals(win, globals) return win } +export function registerRendererProtocol() { + if (protocol.isProtocolHandled(rendererProtocol)) return + + protocol.handle(rendererProtocol, (request) => { + const url = new URL(request.url) + if (url.host !== rendererHost) { + return new Response("Not found", { status: 404 }) + } + + const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) + const rel = relative(rendererRoot, file) + if (rel.startsWith("..") || isAbsolute(rel)) { + return new Response("Not found", { status: 404 }) + } + + return net.fetch(pathToFileURL(file).toString()) + }) +} + function loadWindow(win: BrowserWindow, html: string) { const devUrl = process.env.ELECTRON_RENDERER_URL if (devUrl) { @@ -139,25 +182,25 @@ function loadWindow(win: BrowserWindow, html: string) { return } - void win.loadFile(join(root, `../renderer/${html}`)) -} - -function injectGlobals(win: BrowserWindow, globals: Globals) { - win.webContents.on("dom-ready", () => { - const deepLinks = globals.deepLinks ?? [] - const data = { - updaterEnabled: globals.updaterEnabled, - deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks, - } - void win.webContents.executeJavaScript( - `window.__KILO__ = Object.assign(window.__KILO__ ?? {}, ${JSON.stringify(data)})`, - ) - }) + void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) } - function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) win.webContents.on("zoom-changed", () => { win.webContents.setZoomFactor(1) }) } + +function upsertKeyValue(obj: Record, keyToChange: string, value: any) { + const keyToChangeLower = keyToChange.toLowerCase() + for (const key of Object.keys(obj)) { + if (key.toLowerCase() === keyToChangeLower) { + // Reassign old key + obj[key] = value + // Done + return + } + } + // Insert at end instead + obj[keyToChange] = value +} diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 296fcb2f1cc..6261419ca55 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -11,6 +11,8 @@ const api: ElectronAPI = { ipcRenderer.removeListener("init-step", handler) }) }, + getWindowConfig: () => ipcRenderer.invoke("get-window-config"), + consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"), getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), getWslConfig: () => ipcRenderer.invoke("get-wsl-config"), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index f8e6d52c7db..6e22954d18f 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -15,10 +15,16 @@ export type TitlebarTheme = { mode: "light" | "dark" } +export type WindowConfig = { + updaterEnabled: boolean +} + export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise awaitInitialization: (onStep: (step: InitStep) => void) => Promise + getWindowConfig: () => Promise + consumeInitialDeepLinks: () => Promise getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise getWslConfig: () => Promise diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts index d1590ff0486..6dff3baf1c4 100644 --- a/packages/desktop-electron/src/renderer/env.d.ts +++ b/packages/desktop-electron/src/renderer/env.d.ts @@ -4,8 +4,6 @@ declare global { interface Window { api: ElectronAPI __OPENCODE__?: { - updaterEnabled?: boolean - wsl?: boolean deepLinks?: string[] } } diff --git a/packages/desktop-electron/src/renderer/html.test.ts b/packages/desktop-electron/src/renderer/html.test.ts index bd8281c2fbe..1fc5c87178b 100644 --- a/packages/desktop-electron/src/renderer/html.test.ts +++ b/packages/desktop-electron/src/renderer/html.test.ts @@ -9,9 +9,9 @@ const root = resolve(dir, "../..") const html = async (name: string) => Bun.file(join(dir, name)).text() /** - * Electron loads renderer HTML via `win.loadFile()` which uses the `file://` - * protocol. Absolute paths like `src="/foo.js"` resolve to the filesystem root - * (e.g. `file:///C:/foo.js` on Windows) instead of relative to the app bundle. + * Packaged Electron windows load renderer HTML via the privileged `oc://` + * protocol. Root-relative asset paths like `src="/foo.js"` would resolve from + * the protocol origin root instead of relative to the current HTML entrypoint. * * All local resource references must use relative paths (`./`). */ diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 54a7ea2b57b..a815a717126 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -20,7 +20,6 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" -import { UPDATER_ENABLED } from "./updater" import { webviewZoom } from "./webview-zoom" import "./styles.css" import { useTheme } from "@opencode-ai/ui/theme" @@ -43,8 +42,7 @@ const emitDeepLinks = (urls: string[]) => { } const listenForDeepLinks = () => { - const startUrls = window.__KILO__?.deepLinks ?? [] - if (startUrls.length) emitDeepLinks(startUrls) + void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls)) return window.api.onDeepLink((urls) => emitDeepLinks(urls)) } @@ -57,13 +55,21 @@ const createPlatform = (): Platform => { return undefined })() + const isWslEnabled = async () => { + if (os !== "windows") return false + return window.api + .getWslConfig() + .then((config) => config.enabled) + .catch(() => false) + } + const wslHome = async () => { - if (os !== "windows" || !window.__KILO__?.wsl) return undefined + if (!(await isWslEnabled())) return undefined return window.api.wslPath("~", "windows").catch(() => undefined) } const handleWslPicker = async (result: T | null): Promise => { - if (!result || !window.__KILO__?.wsl) return result + if (!result || !(await isWslEnabled())) return result if (Array.isArray(result)) { return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any } @@ -137,7 +143,7 @@ const createPlatform = (): Platform => { if (os === "windows") { const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null const resolvedPath = await (async () => { - if (window.__KILO__?.wsl) { + if (await isWslEnabled()) { const converted = await window.api.wslPath(path, "windows").catch(() => null) if (converted) return converted } @@ -159,12 +165,14 @@ const createPlatform = (): Platform => { storage, checkUpdate: async () => { - if (!UPDATER_ENABLED()) return { updateAvailable: false } + const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })) + if (!config.updaterEnabled) return { updateAvailable: false } return window.api.checkUpdate() }, update: async () => { - if (!UPDATER_ENABLED()) return + const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })) + if (!config.updaterEnabled) return await window.api.installUpdate() }, @@ -194,11 +202,7 @@ const createPlatform = (): Platform => { return fetch(input, init) }, - getWslEnabled: async () => { - const next = await window.api.getWslConfig().catch(() => null) - if (next) return next.enabled - return window.__KILO__!.wsl ?? false - }, + getWslEnabled: () => isWslEnabled(), setWslEnabled: async (enabled) => { await window.api.setWslConfig({ enabled }) @@ -249,6 +253,7 @@ listenForDeepLinks() render(() => { const platform = createPlatform() + const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))) const loadLocale = async () => { const current = await platform.storage?.("opencode.global.dat").getItem("language") const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") @@ -325,7 +330,15 @@ render(() => { return ( - + {(_) => { return ( window.__KILO__?.updaterEnabled ?? false - export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { await initI18n() try { diff --git a/packages/desktop/src-tauri/release/appstream.metainfo.xml b/packages/desktop/src-tauri/release/appstream.metainfo.xml index ed21a0e5072..c15633a5a44 100644 --- a/packages/desktop/src-tauri/release/appstream.metainfo.xml +++ b/packages/desktop/src-tauri/release/appstream.metainfo.xml @@ -33,6 +33,9 @@ + + https://github.com/anomalyco/opencode/releases/tag/v1.4.0 + https://github.com/Kilo-Org/kilocode/releases/tag/v1.0.223 diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 30f02b3c30d..cbca92a9825 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -32,6 +32,7 @@ "icons/dev/icon.ico" ], "active": true, + "category": "DeveloperTool", "targets": ["deb", "rpm", "dmg", "nsis", "app"], "externalBin": ["sidecars/kilo-cli"], "linux": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 51f7aba4706..c66b57cc447 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.23" +version = "1.14.22" 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.23/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/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.23/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/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.23/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/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.23/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/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.23/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.22/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index dd97d3bdfe3..dc51bd3d38d 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -33,6 +33,8 @@ - +- + - - diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql b/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql new file mode 100644 index 00000000000..e28a1d4e989 --- /dev/null +++ b/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `project` ADD `icon_url_override` text; +UPDATE `project` SET `icon_url_override` = `icon_url` WHERE `icon_url` IS NOT NULL; diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json b/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json new file mode 100644 index 00000000000..06dae8e44b7 --- /dev/null +++ b/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json @@ -0,0 +1,1409 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "66cbe0d7-def0-451b-b88a-7608513a9b44", + "prevIds": ["30b928c5-deef-472c-856d-b5b5064bf6d4"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_entry_pk", + "table": "session_entry", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 72fd1e43e2a..2b60c3ff245 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -117,6 +117,7 @@ "@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:", @@ -127,8 +128,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 3d70edff9fd..4354d90b27b 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -126,12 +126,10 @@ const allTargets: { arch: "x64", avx2: false, }, - // kilocode_change start - Windows ARM64 target { os: "win32", arch: "arm64", }, - // kilocode_change end { os: "win32", arch: "x64", @@ -141,12 +139,6 @@ const allTargets: { arch: "x64", avx2: false, }, - // kilocode_change start - added Windows ARM64 target - { - os: "win32", - arch: "arm64", - }, - // kilocode_change end ] const targets = singleFlag @@ -195,7 +187,6 @@ for (const item of targets) { const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js") const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath) const workerPath = "./src/cli/cmd/tui/worker.ts" - const rgPath = "./src/file/ripgrep.worker.ts" // Use platform-specific bunfs root path based on target OS const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/" @@ -220,19 +211,12 @@ for (const item of targets) { windows: {}, }, files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}, - entrypoints: [ - "./src/index.ts", - parserWorker, - workerPath, - rgPath, - ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : []), - ], + entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])], define: { KILO_VERSION: `'${Script.version}'`, KILO_MIGRATIONS: JSON.stringify(migrations), OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath, KILO_WORKER_PATH: workerPath, - KILO_RIPGREP_WORKER_PATH: rgPath, KILO_CHANNEL: `'${Script.channel}'`, KILO_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", }, diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 98fbd082e54..ced313749bf 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -77,10 +77,10 @@ const image = "ghcr.io/kilo-org/kilo" // kilocode_change const platforms = "linux/amd64,linux/arm64" const tags = [`${image}:${version}`, `${image}:${Script.channel}`] const tagFlags = tags.flatMap((t) => ["-t", t]) -await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` // registries if (!Script.preview) { + await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` // Calculate SHA values // kilocode_change start const arm64Sha = await $`sha256sum ./dist/kilo-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) diff --git a/packages/opencode/script/run-workspace-server b/packages/opencode/script/run-workspace-server new file mode 100755 index 00000000000..4371a157976 --- /dev/null +++ b/packages/opencode/script/run-workspace-server @@ -0,0 +1,106 @@ +#!/usr/bin/env bun + +// This script runs a separate OpenCode server to be used as a remote +// workspace, simulating a remote environment but all local to make +// debugger easier +// +// *Important*: make sure you add the debug workspace plugin first. +// In `.opencode/opencode.jsonc` in the root of this project add: +// +// "plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"] +// +// Afterwards, run `./packages/opencode/script/run-workspace-server` + +import { stat } from "node:fs/promises" +import { setTimeout as sleep } from "node:timers/promises" + +const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json" +const RESTART_POLL_INTERVAL = 250 + +async function readData() { + return await Bun.file(DEV_DATA_FILE).json() +} + +async function readDataMtime() { + return await stat(DEV_DATA_FILE) + .then((info) => info.mtimeMs) + .catch((error) => { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + return undefined + } + + throw error + }) +} + +async function readSnapshot() { + while (true) { + try { + const before = await readDataMtime() + if (before === undefined) { + await sleep(RESTART_POLL_INTERVAL) + continue + } + + const data = await readData() + const after = await readDataMtime() + + if (before === after) { + return { data, mtime: after } + } + } catch (error) { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + await sleep(RESTART_POLL_INTERVAL) + continue + } + + throw error + } + } +} + +function startDevServer(data: any) { + const env = Object.fromEntries(Object.entries(data.env ?? {}).filter(([, value]) => value !== undefined)) + + return Bun.spawn(["bun", "run", "dev", "serve", "--port", String(data.port), "--print-logs"], { + env: { + ...process.env, + ...env, + XDG_DATA_HOME: "/tmp/data", + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) +} + +async function waitForRestartSignal(mtime: number, signal: AbortSignal) { + while (!signal.aborted) { + await sleep(RESTART_POLL_INTERVAL) + if (signal.aborted) return false + if ((await readDataMtime()) !== mtime) return true + } + + return false +} + +while (true) { + const { data, mtime } = await readSnapshot() + const proc = startDevServer(data) + const restartAbort = new AbortController() + + const result = await Promise.race([ + proc.exited.then((code) => ({ type: "exit" as const, code })), + waitForRestartSignal(mtime, restartAbort.signal).then((restart) => ({ type: "restart" as const, restart })), + ]) + + restartAbort.abort() + + if (result.type === "restart" && result.restart) { + proc.kill() + await proc.exited + continue + } + + process.exit(result.code) +} diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index c0f302f21ae..448760ae1aa 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -55,7 +55,7 @@ const configFile = process.argv[2] const tuiFile = process.argv[3] console.log(configFile) -await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2)) +await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2)) if (tuiFile) { console.log(tuiFile) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 67f8abfbc4d..438f90796c9 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -224,7 +224,7 @@ When to use each: Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape. -Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior: +Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those — and for pure-object schemas where handlers populate plain objects rather than class instances — add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior without the `instanceof` requirement: ```ts export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" }) @@ -373,9 +373,9 @@ The first slice is successful if: - `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`. - scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes. -- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. +- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. `Schema.Class`'s Declaration AST enforces `input instanceof self || input.[ClassTypeId]` during encode (see effect-smol `Schema.ts:10479-10484`). Plain objects from zod parse fail with `Expected Foo, got {...}`. This surfaced on `GET /config` where the service returns zod-parsed plain objects and `Config.InfoSchema` referenced `ConfigProvider.Info` (class). The fix was to convert pure-object classes to `Schema.Struct(...).annotate({ identifier: "..." })` — same named SDK `$ref`, no instance requirement. Verified byte-identical `types.gen.ts` vs `dev`. - internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes. -- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes. +- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema **and** when the handler/service returns real instances. For schemas that need a named `$ref` but are populated from plain objects, use `Schema.Struct(...).annotate({ identifier: "..." })` instead. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes. ### Integration @@ -404,8 +404,7 @@ Current instance route inventory: - `provider` - `bridged` endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback` - `config` - `bridged` (partial) - bridged endpoint: `GET /config/providers` - later endpoint: `GET /config` + bridged endpoints: `GET /config`, `GET /config/providers` defer `PATCH /config` for now - `project` - `bridged` (partial) bridged endpoints: `GET /project`, `GET /project/current` @@ -431,9 +430,8 @@ Current instance route inventory: Recommended near-term sequence: 1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`) -2. `config` full read endpoint (`GET /config`) -3. `file` JSON read endpoints -4. `mcp` JSON read endpoints +2. `file` JSON read endpoints +3. `mcp` JSON read endpoints ## Checklist @@ -449,8 +447,8 @@ Recommended near-term sequence: - [x] port remaining provider endpoints (`GET /provider`, OAuth mutations) - [x] port `config` providers read endpoint - [x] port `project` read endpoints (`GET /project`, `GET /project/current`) +- [x] port `GET /config` full read endpoint - [ ] port `workspace` read endpoints -- [ ] port `GET /config` full read endpoint - [ ] port `file` JSON read endpoints - [ ] decide when to remove the flag and make Effect routes the default diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md index 7d0d7eb13c8..6d63715030f 100644 --- a/packages/opencode/specs/effect/instance-context.md +++ b/packages/opencode/specs/effect/instance-context.md @@ -224,7 +224,6 @@ These tools mostly use direct getters for path resolution and repo-relative disp - `src/tool/bash.ts` - `src/tool/edit.ts` - `src/tool/lsp.ts` -- `src/tool/multiedit.ts` - `src/tool/plan.ts` - `src/tool/read.ts` - `src/tool/write.ts` diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 72ee10350dd..9ff6859cee1 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -97,7 +97,7 @@ creating a parallel schema source of truth. ## Escape hatches -The walker in `@/util/effect-zod` exposes three explicit escape hatches for +The walker in `@/util/effect-zod` exposes two explicit escape hatches for cases the pure-Schema path cannot express. Each one stays in the codebase only as long as its upstream or local dependency requires it — inline comments document when each can be deleted. @@ -109,19 +109,7 @@ Replaces the entire derivation with a hand-crafted zod schema. Used when: - the target carries external `$ref` metadata (e.g. `config/model-id.ts` points at `https://models.dev/...`) - the target is a zod-only schema that cannot yet be expressed as Schema - (e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`) - -### `ZodPreprocess` annotation - -Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by -`config/permission.ts` to inject `__originalKeys` before parsing, because -`Schema.StructWithRest` canonicalises output (known fields first, catchall -after) and destroys the user's original property order — which permission -rule precedence depends on. - -Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder -(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and -the `__originalKeys` hack can both be deleted. + (e.g. `ConfigAgent.Info`, `Log.Level`) ### Local `DeepMutable` in `config/config.ts` @@ -171,21 +159,95 @@ Schema at source. These are the highest-priority next targets. Each is a small, self-contained schema module with a clear domain. -- [ ] `src/control-plane/schema.ts` -- [ ] `src/permission/schema.ts` -- [ ] `src/project/schema.ts` -- [ ] `src/provider/schema.ts` -- [ ] `src/pty/schema.ts` -- [ ] `src/question/schema.ts` -- [ ] `src/session/schema.ts` -- [ ] `src/sync/schema.ts` -- [ ] `src/tool/schema.ts` +- [x] `src/control-plane/schema.ts` +- [x] `src/permission/schema.ts` +- [x] `src/project/schema.ts` +- [x] `src/provider/schema.ts` +- [x] `src/pty/schema.ts` +- [x] `src/question/schema.ts` +- [x] `src/session/schema.ts` +- [x] `src/sync/schema.ts` +- [x] `src/tool/schema.ts` ### Session domain Major cluster. Message + event types flow through the SSE API and every SDK output, so byte-identical SDK surface is critical. +Suggested order for this cluster, starting from the leaves that `session.ts` +and the SSE/event surface depend on: + +1. `src/session/schema.ts` ✅ already migrated +2. `src/provider/schema.ts` if `message-v2.ts` still relies on zod-first IDs +3. `src/lsp/*` schema leaves needed by `LSP.Range` +4. `src/snapshot/*` leaves used by `Snapshot.FileDiff` +5. `src/session/message-v2.ts` +6. `src/session/message.ts` +7. `src/session/prompt.ts` +8. `src/session/revert.ts` +9. `src/session/summary.ts` +10. `src/session/status.ts` +11. `src/session/todo.ts` +12. `src/session/session.ts` +13. `src/session/compaction.ts` + +Dependency sketch: + +```text +session.ts +|- project/schema.ts +|- control-plane/schema.ts +|- permission/schema.ts +|- snapshot/* +|- message-v2.ts +| |- provider/schema.ts +| |- lsp/* +| |- snapshot/* +| |- sync/index.ts +| `- bus/bus-event.ts +|- sync/index.ts +|- bus/bus-event.ts +`- util/update-schema.ts +``` + +Working rule for this cluster: + +- migrate reusable leaf schemas and nested payload objects first +- migrate aggregate DTOs like `Session.Info` after their nested pieces exist as + named Schema values +- leave zod-only event/update helpers in place temporarily when converting + them would force unrelated churn across sync/bus boundaries + +`message-v2.ts` first-pass outline: + +1. Schema-backed imports already available + - `SessionID`, `MessageID`, `PartID` + - `ProviderID`, `ModelID` +2. Local leaf objects to extract and migrate first + - output format payloads + - common part bases like `PartBase` + - timestamp/range helper objects like `time.start/end` + - file/source helper objects + - token/cost/model helper objects +3. Part variants built from those leaves + - `SnapshotPart`, `PatchPart`, `TextPart`, `ReasoningPart` + - `FilePart`, `AgentPart`, `CompactionPart`, `SubtaskPart` + - retry/step/tool related parts +4. Higher-level unions and DTOs + - `FilePartSource` + - part unions + - message unions and assistant/user payloads +5. Errors and event payloads last + - `NamedError.create(...)` shapes can stay temporarily if converting them to + `Schema.TaggedErrorClass` would force unrelated churn + - `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can keep using + derived `.zod` until the sync/bus layers are migrated + +Possible later tightening after the Schema-first migration is stable: + +- promote repeated opaque strings and timestamp numbers into branded/newtype + leaf schemas where that adds domain value without changing the wire format + - [ ] `src/session/compaction.ts` - [ ] `src/session/message-v2.ts` - [ ] `src/session/message.ts` @@ -216,7 +278,6 @@ emitted JSON Schema must stay byte-identical. - [ ] `src/tool/grep.ts` - [ ] `src/tool/invalid.ts` - [ ] `src/tool/lsp.ts` -- [ ] `src/tool/multiedit.ts` - [ ] `src/tool/plan.ts` - [ ] `src/tool/question.ts` - [ ] `src/tool/read.ts` diff --git a/packages/opencode/specs/effect/tools.md b/packages/opencode/specs/effect/tools.md index 7b47831709a..3cc277357be 100644 --- a/packages/opencode/specs/effect/tools.md +++ b/packages/opencode/specs/effect/tools.md @@ -46,7 +46,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`: - [x] `grep.ts` - [x] `invalid.ts` - [x] `lsp.ts` -- [x] `multiedit.ts` - [x] `plan.ts` - [x] `question.ts` - [x] `read.ts` @@ -82,7 +81,6 @@ Notable items that are already effectively on the target path and do not need se - `write.ts` - `codesearch.ts` - `websearch.ts` -- `multiedit.ts` - `edit.ts` ## Filesystem notes diff --git a/packages/opencode/src/agent/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt index 11deccb3afc..c7cb838bbaa 100644 --- a/packages/opencode/src/agent/prompt/compaction.txt +++ b/packages/opencode/src/agent/prompt/compaction.txt @@ -1,15 +1,9 @@ -You are a helpful AI assistant tasked with summarizing conversations. +You are an anchored context summarization assistant for coding sessions. -When asked to summarize, provide a detailed but concise summary of the conversation. -Focus on information that would be helpful for continuing the conversation, including: -- What was done -- What is currently being worked on -- Which files are being modified -- What needs to be done next -- Key user requests, constraints, or preferences that should persist -- Important technical decisions and why they were made +Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work. -Your summary should be comprehensive enough to provide context but concise enough to be quickly understood. +If the prompt includes a block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts. -Do not respond to any questions in the conversation, only output the summary. -Respond in the same language the user used in the conversation. +Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs. + +Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation. diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 185cab9c758..47db6358b6e 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -23,8 +23,7 @@ const DiagnosticsCommand = cmd({ const out = await AppRuntime.runPromise( LSP.Service.use((lsp) => Effect.gen(function* () { - yield* lsp.touchFile(args.file, true) - yield* Effect.sleep(1000) + yield* lsp.touchFile(args.file, "full") return yield* lsp.diagnostics() }), ), diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index c64f3edafe7..b62ca11c2fb 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -992,7 +992,8 @@ export const GithubRunCommand = cmd({ const err = result.info.error console.error("Agent error:", err) if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files)) - throw new Error(`${err.name}: ${err.data?.message || ""}`) + const message = "message" in err.data ? err.data.message : "" + throw new Error(`${err.name}: ${message}`) } const text = extractResponseText(result.parts) @@ -1021,7 +1022,8 @@ export const GithubRunCommand = cmd({ const err = summary.info.error console.error("Summary agent error:", err) if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files)) - throw new Error(`${err.name}: ${err.data?.message || ""}`) + const message = "message" in err.data ? err.data.message : "" + throw new Error(`${err.name}: ${message}`) } const summaryText = extractResponseText(summary.parts) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 72cd5e71cc2..e9a435096d6 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -197,7 +197,7 @@ export const ImportCommand = cmd({ ) for (const msg of exportData.messages) { - const msgInfo = MessageV2.Info.parse(msg.info) + const msgInfo = MessageV2.Info.zod.parse(msg.info) const { id, sessionID: _, ...msgData } = msgInfo Database.use((db) => db @@ -213,7 +213,7 @@ export const ImportCommand = cmd({ ) for (const part of msg.parts) { - const partInfo = MessageV2.Part.parse(part) + const partInfo = MessageV2.Part.zod.parse(part) const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8dca70bdcb5..b43f24588ee 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -126,8 +126,8 @@ export function tui(input: { const mode = await Terminal.getTerminalBackgroundColor() - // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores - // the original console mode which re-enables ENABLE_PROCESSED_INPUT. + // Re-clear after getTerminalBackgroundColor() because setRawMode(false) + // restores the original console mode, including processed input on Windows. win32DisableProcessedInput() const onExit = async () => { diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 2b0273c3c6d..a1d745753bb 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -5,6 +5,8 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change +import { errorMessage } from "@/util/error" +import { validateSession } from "./validate-session" export const AttachCommand = cmd({ command: "attach ", @@ -98,6 +100,20 @@ export const AttachCommand = cmd({ } // kilocode_change end const config = await TuiConfig.get() + + try { + await validateSession({ + url: args.url, + sessionID: args.session, + directory, + headers, + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } + await tui({ url: args.url, config, diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 803752e7664..43266315bf0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -1,7 +1,9 @@ import { Global } from "@/global" import { Filesystem } from "@/util" +import { Flock } from "@opencode-ai/shared/util/flock" +import { rename, rm } from "fs/promises" import { createSignal, type Setter } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, unwrap } from "solid-js/store" import { createSimpleContext } from "./helper" import path from "path" @@ -11,12 +13,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ const [ready, setReady] = createSignal(false) const [store, setStore] = createStore>() const filePath = path.join(Global.Path.state, "kv.json") + const lock = `tui-kv:${filePath}` + // Queue same-process writes so rapid updates persist in order. + let write = Promise.resolve() - Filesystem.readJson>(filePath) + // Write to a temp file first so kv.json is only replaced once the JSON is complete, avoiding partial writes if shutdown interrupts persistence. + function writeSnapshot(snapshot: Record) { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp` + return Filesystem.writeJson(tempPath, snapshot) + .then(() => rename(tempPath, filePath)) + .catch(async (error) => { + await rm(tempPath, { force: true }).catch(() => undefined) + throw error + }) + } + + // Read under the same lock used for writes because kv.json is shared across processes. + Flock.withLock(lock, () => Filesystem.readJson>(filePath)) .then((x) => { setStore(x) }) - .catch(() => {}) + .catch((error) => { + console.error("Failed to read KV state", { filePath, error }) + }) .finally(() => { setReady(true) }) @@ -44,7 +63,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ }, set(key: string, value: any) { setStore(key, value) - void Filesystem.writeJson(filePath, store) + const snapshot = structuredClone(unwrap(store)) + write = write + .then(() => Flock.withLock(lock, () => writeSnapshot(snapshot))) + .catch((error) => { + console.error("Failed to write KV state", { filePath, error }) + }) }, } return result diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 2e47afeab2a..055015a501e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -327,8 +327,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setStore( produce((draft) => { const lock = pick(kv.get("theme_mode_lock")) - const mode = pick(kv.get("theme_mode", props.mode)) - draft.mode = lock ?? mode ?? props.mode + const mode = lock ?? props.mode + if (!lock && pick(kv.get("theme_mode")) !== undefined) { + kv.set("theme_mode", undefined) + } + draft.mode = mode draft.lock = lock const active = config.theme ?? kv.get("theme", "kilo") // kilocode_change draft.active = typeof active === "string" ? active : "kilo" // kilocode_change @@ -386,7 +389,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } function apply(mode: "dark" | "light") { - kv.set("theme_mode", mode) + if (store.lock !== undefined) kv.set("theme_mode", mode) if (store.mode === mode) return setStore("mode", mode) renderer.clearPaletteCache() @@ -402,6 +405,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ function free() { setStore("lock", undefined) kv.set("theme_mode_lock", undefined) + kv.set("theme_mode", undefined) const mode = renderer.themeMode if (mode) apply(mode) } @@ -410,7 +414,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (store.lock) return apply(mode) } - // renderer.on(CliRenderEvents.THEME_MODE, handle) + renderer.on(CliRenderEvents.THEME_MODE, handle) const refresh = () => { renderer.clearPaletteCache() 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 02b64629034..e074880165c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -63,6 +63,7 @@ import { Flag } from "@/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import * as Clipboard from "../../util/clipboard" +import { errorMessage } from "@/util/error" import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import * as Editor from "../../util/editor" @@ -261,31 +262,43 @@ export function Session() { const toast = useToast() const sdk = useSDK() - createEffect(async () => { - const previousWorkspace = project.workspace.current() - const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true }) - if (!result.data) { + createEffect(() => { + const sessionID = route.sessionID + void (async () => { + const previousWorkspace = project.workspace.current() + const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) + if (!result.data) { + toast.show({ + message: `Session not found: ${sessionID}`, + variant: "error", + duration: 5000, + }) + navigate({ type: "home" }) + return + } + + if (result.data.workspaceID !== previousWorkspace) { + project.workspace.set(result.data.workspaceID) + + // Sync all the data for this workspace. Note that this + // workspace may not exist anymore which is why this is not + // fatal. If it doesn't we still want to show the session + // (which will be non-interactive) + try { + await sync.bootstrap({ fatal: false }) + } catch {} + } + await sync.session.sync(sessionID) + if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + })().catch((error) => { + if (route.sessionID !== sessionID) return toast.show({ - message: `Session not found: ${route.sessionID}`, + message: errorMessage(error), variant: "error", + duration: 5000, }) navigate({ type: "home" }) - return - } - - if (result.data.workspaceID !== previousWorkspace) { - project.workspace.set(result.data.workspaceID) - - // Sync all the data for this workspace. Note that this - // workspace may not exist anymore which is why this is not - // fatal. If it doesn't we still want to show the session - // (which will be non-interactive) - try { - await sync.bootstrap({ fatal: false }) - } catch (e) {} - } - await sync.session.sync(route.sessionID) - if (scroll) scroll.scrollBy(100_000) + }) }) let lastSwitch: string | undefined = undefined diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 3e0e037bd4b..155e9d52efc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -9,6 +9,7 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useSync } from "../../context/sync" import { useTextareaKeybindings } from "../../component/textarea-keybindings" +import { useProject } from "../../context/project" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util" @@ -132,6 +133,7 @@ function TextBody(props: { title: string; description?: string; icon?: string }) export function PermissionPrompt(props: { request: PermissionRequest }) { const sdk = useSDK() + const project = useProject() const sync = useSync() const [store, setStore] = createStore({ stage: "permission" as PermissionStage, @@ -191,6 +193,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { void sdk.client.permission.reply({ reply: "always", requestID: props.request.id, + workspace: project.workspace.current(), }) }} /> @@ -202,6 +205,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { reply: "reject", requestID: props.request.id, message: message || undefined, + workspace: project.workspace.current(), }) }} onCancel={() => { @@ -467,12 +471,14 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { void sdk.client.permission.reply({ reply: "reject", requestID: props.request.id, + workspace: project.workspace.current(), }) return } void sdk.client.permission.reply({ reply: "once", requestID: props.request.id, + workspace: project.workspace.current(), }) }} /> diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 45b6195676d..123631addfe 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -19,6 +19,7 @@ import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change import { writeHeapSnapshot } from "v8" import { TuiConfig } from "./config/tui" import { KILO_PROCESS_ROLE, KILO_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process" +import { validateSession } from "./validate-session" declare global { const KILO_WORKER_PATH: string @@ -285,6 +286,19 @@ export const TuiThreadCommand = cmd({ events: createEventSource(client), } + try { + await validateSession({ + url: transport.url, + sessionID: args.session, + directory: cwd, + fetch: transport.fetch, + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } + setTimeout(() => { client.call("checkUpgrade", { directory: cwd }).catch(() => {}) }, 1000).unref?.() diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts index d0fe4b12da3..f57eb0b289c 100644 --- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts +++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts @@ -41,9 +41,9 @@ function parse(color: string): RGBA | null { return null } -function mode(bg: RGBA | null): "dark" | "light" { - if (!bg) return "dark" - const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255 +function mode(background: RGBA | null): "dark" | "light" { + if (!background) return "dark" + const luminance = (0.299 * background.r + 0.587 * background.g + 0.114 * background.b) / 255 return luminance > 0.5 ? "light" : "dark" } diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts new file mode 100644 index 00000000000..da94eb9ca24 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts @@ -0,0 +1,24 @@ +import { createKiloClient } from "@kilocode/sdk/v2" +import { SessionID } from "@/session/schema" + +export async function validateSession(input: { + url: string + sessionID?: string + directory?: string + fetch?: typeof fetch + headers?: RequestInit["headers"] +}) { + if (!input.sessionID) return + + const result = SessionID.zod.safeParse(input.sessionID) + if (!result.success) { + throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`) + } + + await createKiloClient({ + baseUrl: input.url, + directory: input.directory, + fetch: input.fetch, + headers: input.headers, + }).session.get({ sessionID: result.data }, { throwOnError: true }) +} diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index e56f57ebb84..ec623fcf81e 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -7,6 +7,7 @@ import { InstallationVersion } from "@/installation/version" export async function upgrade() { const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) + if (config.autoupdate === false || Flag.KILO_DISABLE_AUTOUPDATE) return const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) // kilocode_change start - only auto-upgrade for npm/pnpm/bun (we only publish @kilocode/cli via npm registry) if (method !== "npm" && method !== "pnpm" && method !== "bun") return @@ -20,7 +21,6 @@ export async function upgrade() { } if (InstallationVersion === latest) return - if (config.autoupdate === false || Flag.KILO_DISABLE_AUTOUPDATE) return const kind = Installation.getReleaseType(InstallationVersion, latest) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 3918507ee0a..8ae4d3f818f 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -3,7 +3,7 @@ export * as ConfigAgent from "./agent" import { Schema } from "effect" import z from "zod" import { Bus } from "@/bus" -import { zod, ZodOverride } from "@/util/effect-zod" +import { zod } from "@/util/effect-zod" import { Log } from "../util" import { NamedError } from "@opencode-ai/shared/util/error" import { Glob } from "@opencode-ai/shared/util/glob" @@ -25,12 +25,6 @@ const Color = Schema.Union([ Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), ]) -// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)` -// shape lives outside the Effect Schema type system), so the walker reaches it -// via ZodOverride rather than a pure Schema reference. This preserves the -// `$ref: PermissionConfig` emitted in openapi.json. -const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info }) - const AgentSchema = Schema.StructWithRest( Schema.Struct({ model: Schema.optional(Schema.NullOr(ConfigModelID)), // kilocode_change - nullable for delete sentinel @@ -57,7 +51,7 @@ const AgentSchema = Schema.StructWithRest( description: "Maximum number of agentic iterations before forcing text-only response", }), maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }), - permission: Schema.optional(PermissionRef), + permission: Schema.optional(ConfigPermission.Info), }), [Schema.Record(Schema.String, Schema.Any)], ) @@ -96,7 +90,7 @@ const normalize = (agent: z.infer) => { const permission: ConfigPermission.Info = {} for (const [tool, enabled] of Object.entries(agent.tools ?? {})) { const action = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + if (tool === "write" || tool === "edit" || tool === "patch") { permission.edit = action continue } @@ -104,7 +98,8 @@ const normalize = (agent: z.infer) => { } globalThis.Object.assign(permission, agent.permission) - return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps } + const steps = agent.steps ?? agent.maxSteps + return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) } } export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType< diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1c59befd81d..63d1126e152 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -25,6 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { InstanceRef } from "@/effect/instance-ref" import { zod, ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" @@ -101,13 +102,20 @@ export type Layout = ConfigLayout.Layout // ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the // exact zod directly, preserving component $refs. const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info }) -const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info }) const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level }) const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) -const InfoSchema = Schema.Struct({ +// The Effect Schema is the canonical source of truth. The `.zod` compatibility +// surface is derived so existing Hono validators keep working without a parallel +// Zod definition. +// +// The walker emits `z.object({...})` which is non-strict by default. Config +// historically uses `.strict()` (additionalProperties: false in openapi.json), +// so layer that on after derivation. Re-apply the Config ref afterward +// since `.strict()` strips the walker's meta annotation. +export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation", }), @@ -220,7 +228,7 @@ const InfoSchema = Schema.Struct({ description: "Additional instruction files or patterns to include", }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), - permission: Schema.optional(PermissionRef), + permission: Schema.optional(ConfigPermission.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), enterprise: Schema.optional( Schema.Struct({ @@ -236,6 +244,13 @@ const InfoSchema = Schema.Struct({ prune: Schema.optional(Schema.Boolean).annotate({ description: "Enable pruning of old tool outputs (default: true)", }), + tail_turns: Schema.optional(NonNegativeInt).annotate({ + description: + "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", + }), + preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ + description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", + }), reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", }), @@ -263,6 +278,14 @@ const InfoSchema = Schema.Struct({ }), ), }) + .annotate({ identifier: "Config" }) + .pipe( + withStatics((s) => ({ + zod: (zod(s) as unknown as z.ZodObject).strict().meta({ ref: "Config" }) as unknown as z.ZodType< + DeepMutable> + >, + })), + ) // Schema.Struct produces readonly types by default, but the service code // below mutates Info objects directly (e.g. `config.mode = ...`). Strip the @@ -284,15 +307,7 @@ type DeepMutable = T extends readonly [unknown, ...unknown[]] ? { -readonly [K in keyof T]: DeepMutable } : T -// The walker emits `z.object({...})` which is non-strict by default. Config -// historically uses `.strict()` (additionalProperties: false in openapi.json), -// so layer that on after derivation. Re-apply the Config ref afterward -// since `.strict()` strips the walker's meta annotation. -export const Info = (zod(InfoSchema) as unknown as z.ZodObject) - .strict() - .meta({ ref: "Config" }) as unknown as z.ZodType>> - -export type Info = z.output & { +export type Info = DeepMutable> & { // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together // with the file and scope it came from so later runtime code can make location-sensitive decisions. plugin_origins?: ConfigPlugin.Origin[] @@ -414,7 +429,7 @@ export const layer = Layer.effect( ), ) const parsed = ConfigParse.jsonc(expanded, source) - const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) + const data = ConfigParse.schema(Info.zod, normalizeLoadedConfig(parsed, source), source) if (!("path" in options)) return data yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) @@ -666,9 +681,8 @@ export const layer = Layer.effect( const deps: Fiber.Fiber[] = [] + // kilocode_change start for (const dir of unique(directories)) { - // kilocode_change - // kilocode_change start if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(dir, file) @@ -826,7 +840,7 @@ export const layer = Layer.effect( const perms: Record = {} for (const [tool, enabled] of Object.entries(result.tools)) { const action: ConfigPermission.Action = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + if (tool === "write" || tool === "edit" || tool === "patch") { perms.edit = action continue } @@ -932,13 +946,13 @@ export const layer = Layer.effect( let next: Info if (!file.endsWith(".jsonc")) { - const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file) + const existing = ConfigParse.schema(Info.zod, ConfigParse.jsonc(before, file), file) const merged = KilocodeConfig.mergeConfig(writable(existing), writable(config)) // kilocode_change yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) next = merged } else { const updated = patchJsonc(before, writable(config)) - next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file) + next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 8b77bc4c286..0887fa984ab 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -2,7 +2,7 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -export class Local extends Schema.Class("McpLocalConfig")({ +export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), command: Schema.mutable(Schema.Array(Schema.String)).annotate({ description: "Command and arguments to run the MCP server", @@ -16,11 +16,12 @@ export class Local extends Schema.Class("McpLocalConfig")({ timeout: Schema.optional(Schema.Number).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "McpLocalConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Local = Schema.Schema.Type -export class OAuth extends Schema.Class("McpOAuthConfig")({ +export const OAuth = Schema.Struct({ clientId: Schema.optional(Schema.String).annotate({ description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", }), @@ -31,11 +32,12 @@ export class OAuth extends Schema.Class("McpOAuthConfig")({ redirectUri: Schema.optional(Schema.String).annotate({ description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "McpOAuthConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type OAuth = Schema.Schema.Type -export class Remote extends Schema.Class("McpRemoteConfig")({ +export const Remote = Schema.Struct({ type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }), url: Schema.String.annotate({ description: "URL of the remote MCP server" }), enabled: Schema.optional(Schema.Boolean).annotate({ @@ -50,9 +52,10 @@ export class Remote extends Schema.Class("McpRemoteConfig")({ timeout: Schema.optional(Schema.Number).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "McpRemoteConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Remote = Schema.Schema.Type export const Info = Schema.Union([Local, Remote]) .annotate({ discriminator: "type" }) diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 632ea571cdf..f2ced3112e2 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,6 +1,6 @@ export * as ConfigPermission from "./permission" -import { Schema } from "effect" -import { zod, ZodPreprocess } from "@/util/effect-zod" +import { Schema, SchemaGetter } from "effect" +import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" export const Action = Schema.NullOr(Schema.Literals(["ask", "allow", "deny"])) // kilocode_change - nullable allows null as a delete sentinel @@ -18,21 +18,19 @@ export const Rule = Schema.Union([Action, Object]) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Rule = Schema.Schema.Type -// Captures the user's original property insertion order before Schema.Struct -// canonicalises the object. See the `ZodPreprocess` comment in -// `util/effect-zod.ts` for the full rationale — in short: rule precedence is -// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win) -// and `Schema.StructWithRest` would otherwise drop that order. -const permissionPreprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __originalKeys: globalThis.Object.keys(val), ...val } - } - return val -} - -const ObjectShape = Schema.StructWithRest( +// Known permission keys get explicit types — most are full Rule (either a +// single Action or a per-pattern object), but a handful of tools take no +// sub-target patterns and are Action-only. Unknown keys fall through the +// Record rest signature as Rule. +// +// StructWithRest canonicalises key order on decode (known first, then rest), +// which used to require the `__originalKeys` preprocess hack because +// `Permission.fromConfig` depended on the user's insertion order. That +// dependency is gone — `fromConfig` now sorts top-level keys so wildcard +// permissions come before specifics, making the final precedence +// order-independent. +const InputObject = Schema.StructWithRest( Schema.Struct({ - __originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), read: Schema.optional(Rule), edit: Schema.optional(Rule), glob: Schema.optional(Rule), @@ -53,24 +51,29 @@ const ObjectShape = Schema.StructWithRest( [Schema.Record(Schema.String, Rule)], ) -const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({ - [ZodPreprocess]: permissionPreprocess, -}) +// Input the user writes in config: either a single Action (shorthand for "*") +// or an object of per-target rules. +const InputSchema = Schema.Union([Action, InputObject]) -// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the -// user's original insertion order. A plain string input (the Action branch of -// the union) becomes `{ "*": action }`. -const transform = (x: unknown): Record => { - if (typeof x === "string") return { "*": x as Action } - const obj = x as { __originalKeys?: string[] } & Record - const { __originalKeys, ...rest } = obj - if (!__originalKeys) return rest as Record - const result: Record = {} - for (const key of __originalKeys) { - if (key in rest) result[key] = rest[key] as Rule - } - return result -} +// Normalise the Action shorthand into `{ "*": action }`. Object inputs pass +// through untouched. +const normalizeInput = (input: Schema.Schema.Type): Schema.Schema.Type => + input === null || typeof input === "string" ? { "*": input } : input // kilocode_change -export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" }) -export type Info = Record +export const Info = InputSchema.pipe( + Schema.decodeTo(InputObject, { + decode: SchemaGetter.transform(normalizeInput), + // Not perfectly invertible (we lose whether the user originally typed an + // Action shorthand), but the object form is always a valid representation + // of the same rules. + encode: SchemaGetter.passthrough({ strict: false }), + }), +) + .annotate({ identifier: "PermissionConfig" }) + .pipe( + // Walker already emits the decodeTo transform into the derived zod (see + // `encoded()` in effect-zod.ts), so just expose that directly. + withStatics((s) => ({ zod: zod(s) })), + ) +type _Info = Schema.Schema.Type +export type Info = { -readonly [K in keyof _Info]: _Info[K] } diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index d17606fe60f..e6305449787 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -60,7 +60,8 @@ export const Model = Schema.Struct({ variants: Schema.optional( Schema.Record( Schema.String, - Schema.NullOr( // kilocode_change - allow null values so removed variants can be deleted via stripNulls on save + Schema.NullOr( + // kilocode_change - allow null values so removed variants can be deleted via stripNulls on save Schema.StructWithRest( Schema.Struct({ disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }), @@ -72,7 +73,7 @@ export const Model = Schema.Struct({ ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) -export class Info extends Schema.Class("ProviderConfig")({ +export const Info = Schema.Struct({ api: Schema.optional(Schema.String), name: Schema.optional(Schema.String), env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), @@ -109,8 +110,9 @@ export class Info extends Schema.Class("ProviderConfig")({ ), ), models: Schema.optional(Schema.Record(Schema.String, Schema.NullOr(Model))), // kilocode_change - allow null values so removed models can be deleted via stripNulls on save -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "ProviderConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type export * as ConfigProvider from "./provider" diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 5d1171f2456..8ba60c3e585 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,7 +1,8 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export class Server extends Schema.Class("ServerConfig")({ +export const Server = Schema.Struct({ port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({ description: "Port to listen on", }), @@ -13,8 +14,9 @@ export class Server extends Schema.Class("ServerConfig")({ cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional domains to allow for CORS", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "ServerConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Server = Schema.Schema.Type export * as ConfigServer from "./server" diff --git a/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts b/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts new file mode 100644 index 00000000000..a8265c5da9b --- /dev/null +++ b/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts @@ -0,0 +1,73 @@ +import type { Plugin } from "@kilocode/plugin" +import { rename, writeFile } from "node:fs/promises" +import { randomInt } from "node:crypto" +import { setTimeout as sleep } from "node:timers/promises" + +const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json" +const DEV_DATA_TEMP_FILE = `${DEV_DATA_FILE}.tmp` + +async function waitForHealth(port: number) { + const url = `http://127.0.0.1:${port}/global/health` + const started = Date.now() + + while (Date.now() - started < 30_000) { + try { + const response = await fetch(url) + if (response.ok) { + return + } + } catch {} + + await sleep(250) + } + + throw new Error(`Timed out waiting for debug server health check at ${url}`) +} + +let PORT: number | undefined + +async function writeDebugData(port: number, id: string, env: Record) { + await writeFile( + DEV_DATA_TEMP_FILE, + JSON.stringify( + { + port, + id, + env, + }, + null, + 2, + ), + ) + + await rename(DEV_DATA_TEMP_FILE, DEV_DATA_FILE) +} + +export const DebugWorkspacePlugin: Plugin = async ({ experimental_workspace }) => { + experimental_workspace.register("debug", { + name: "Debug", + description: "Create a debugging server", + configure(config) { + return config + }, + async create(config, env) { + const port = randomInt(5000, 9001) + PORT = port + + await writeDebugData(port, config.id, env) + + await waitForHealth(port) + }, + async remove(_config) {}, + target(_config) { + return { + type: "remote", + url: `http://localhost:${PORT!}/`, + } + }, + }) + + return {} +} + +export default DebugWorkspacePlugin diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 4c7ced010d6..5a0850a2495 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,8 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( @@ -14,6 +13,6 @@ export type WorkspaceID = typeof workspaceIdSchema.Type export const WorkspaceID = workspaceIdSchema.pipe( withStatics((schema: typeof workspaceIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), - zod: Identifier.schema("workspace").pipe(z.custom()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 5f909bfd1a8..0200401113b 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -1,15 +1,29 @@ -import fs from "fs/promises" import path from "path" -import { fileURLToPath } from "url" import z from "zod" -import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" -import { ripgrep } from "ripgrep" -import { Filesystem } from "@/util" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Cause, Context, Effect, Fiber, Layer, Queue, Stream } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" + +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { Global } from "@/global" import { Log } from "@/util" import { sanitizedProcessEnv } from "@/util/opencode-process" -import { KiloRipgrepStream } from "../kilocode/kilo-ripgrep-stream" // kilocode_change - UTF-8 safe stream decoder shared with worker +import { which } from "@/util/which" const log = Log.create({ service: "ripgrep" }) +const VERSION = "15.1.0" +const PLATFORM = { + "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" }, + "arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" }, + "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" }, + "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" }, + "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" }, + "ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" }, + "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" }, +} as const const Stats = z.object({ elapsed: z.object({ @@ -121,62 +135,20 @@ export interface TreeInput { } export interface Interface { - readonly files: (input: FilesInput) => Stream.Stream - readonly tree: (input: TreeInput) => Effect.Effect - readonly search: (input: SearchInput) => Effect.Effect + readonly files: (input: FilesInput) => Stream.Stream + readonly tree: (input: TreeInput) => Effect.Effect + readonly search: (input: SearchInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/Ripgrep") {} -type Run = { kind: "files" | "search"; cwd: string; args: string[] } - -type WorkerResult = { - type: "result" - code: number - stdout: string - stderr: string -} - -type WorkerLine = { - type: "line" - line: string -} - -type WorkerDone = { - type: "done" - code: number - stderr: string -} - -type WorkerError = { - type: "error" - error: { - message: string - name?: string - stack?: string - } -} - function env() { const env = sanitizedProcessEnv() delete env.RIPGREP_CONFIG_PATH return env } -function text(input: unknown) { - if (typeof input === "string") return input - if (input instanceof ArrayBuffer) return Buffer.from(input).toString() - if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString() - return String(input) -} - -function toError(input: unknown) { - if (input instanceof Error) return input - if (typeof input === "string") return new Error(input) - return new Error(String(input)) -} - -function abort(signal?: AbortSignal) { +function aborted(signal?: AbortSignal) { const err = signal?.reason if (err instanceof Error) return err const out = new Error("Aborted") @@ -184,6 +156,16 @@ function abort(signal?: AbortSignal) { return out } +function waitForAbort(signal?: AbortSignal) { + if (!signal) return Effect.never + if (signal.aborted) return Effect.fail(aborted(signal)) + return Effect.callback((resume) => { + const onabort = () => resume(Effect.fail(aborted(signal))) + signal.addEventListener("abort", onabort, { once: true }) + return Effect.sync(() => signal.removeEventListener("abort", onabort)) + }) +} + function error(stderr: string, code: number) { const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`) err.name = "RipgrepError" @@ -204,376 +186,300 @@ function row(data: Row): Row { } } -function opts(cwd: string) { - return { - env: env(), - preopens: { ".": cwd }, - } +function parse(line: string) { + return Effect.try({ + try: () => Result.parse(JSON.parse(line)), + catch: (cause) => new Error("invalid ripgrep output", { cause }), + }) } -function check(cwd: string) { - return Effect.tryPromise({ - try: () => fs.stat(cwd).catch(() => undefined), - catch: toError, - }).pipe( - Effect.flatMap((stat) => - stat?.isDirectory() - ? Effect.void - : Effect.fail( - Object.assign(new Error(`No such file or directory: '${cwd}'`), { - code: "ENOENT", - errno: -2, - path: cwd, - }), - ), - ), - ) +function fail(queue: Queue.Queue, err: PlatformError | Error) { + Queue.failCauseUnsafe(queue, Cause.fail(err)) } function filesArgs(input: FilesInput) { - const args = ["--files", "--glob=!.git/*"] + const args = ["--no-config", "--files", "--glob=!.git/*"] if (input.follow) args.push("--follow") if (input.hidden !== false) args.push("--hidden") + if (input.hidden === false) args.push("--glob=!.*") if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) if (input.glob) { - for (const glob of input.glob) { - args.push(`--glob=${glob}`) - } + for (const glob of input.glob) args.push(`--glob=${glob}`) } args.push(".") return args } function searchArgs(input: SearchInput) { - const args = ["--json", "--hidden", "--glob=!.git/*", "--no-messages"] + const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"] if (input.follow) args.push("--follow") if (input.glob) { - for (const glob of input.glob) { - args.push(`--glob=${glob}`) - } + for (const glob of input.glob) args.push(`--glob=${glob}`) } if (input.limit) args.push(`--max-count=${input.limit}`) args.push("--", input.pattern, ...(input.file ?? ["."])) return args } -function parse(stdout: string) { - return stdout - .trim() - .split(/\r?\n/) - .filter(Boolean) - .map((line) => Result.parse(JSON.parse(line))) - .flatMap((item) => (item.type === "match" ? [row(item.data)] : [])) +function raceAbort(effect: Effect.Effect, signal?: AbortSignal) { + return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect } -declare const KILO_RIPGREP_WORKER_PATH: string - -function target(): Effect.Effect { - if (typeof KILO_RIPGREP_WORKER_PATH !== "undefined") { - return Effect.succeed(KILO_RIPGREP_WORKER_PATH) - } - const js = new URL("./ripgrep.worker.js", import.meta.url) - return Effect.tryPromise({ - try: () => Filesystem.exists(fileURLToPath(js)), - catch: toError, - }).pipe(Effect.map((exists) => (exists ? js : new URL("./ripgrep.worker.ts", import.meta.url)))) -} +export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + const spawner = yield* ChildProcessSpawner + + const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) { + const handle = yield* spawner.spawn( + ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), + ) + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ) + return { stdout, stderr, code } + }, Effect.scoped) + + const extract = Effect.fnUntraced(function* ( + archive: string, + config: (typeof PLATFORM)[keyof typeof PLATFORM], + target: string, + ) { + const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" }) + + if (config.extension === "zip") { + const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe" + const result = yield* run(shell, [ + "-NoProfile", + "-NonInteractive", + "-Command", + `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`, + ]) + if (result.code !== 0) { + return yield* Effect.fail(error(result.stderr || result.stdout, result.code)) + } + } -function worker() { - return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() })))) -} + if (config.extension === "tar.gz") { + const result = yield* run("tar", ["-xzf", archive, "-C", dir]) + if (result.code !== 0) { + return yield* Effect.fail(error(result.stderr || result.stdout, result.code)) + } + } -// kilocode_change start - delegate to KiloRipgrepStream for UTF-8 safe decoding -function drain( - dec: ReturnType, - buf: string, - chunk: unknown, - push: (line: string) => void, -) { - return KiloRipgrepStream.drain(dec, buf, chunk, push) -} -// kilocode_change end + const extracted = path.join( + dir, + `ripgrep-${VERSION}-${config.platform}`, + process.platform === "win32" ? "rg.exe" : "rg", + ) + if (!(yield* fs.isFile(extracted))) { + return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`)) + } -function fail(queue: Queue.Queue, err: Error) { - Queue.failCauseUnsafe(queue, Cause.fail(err)) -} + yield* fs.copyFile(extracted, target) + if (process.platform === "win32") return + yield* fs.chmod(target, 0o755) + }, Effect.scoped) -function searchDirect(input: SearchInput) { - return Effect.tryPromise({ - try: () => - ripgrep(searchArgs(input), { - buffer: true, - ...opts(input.cwd), - }), - catch: toError, - }).pipe( - Effect.flatMap((ret) => { - const out = ret.stdout ?? "" - if (ret.code !== 0 && ret.code !== 1 && ret.code !== 2) { - return Effect.fail(error(ret.stderr ?? "", ret.code ?? 1)) - } - return Effect.sync(() => ({ - items: ret.code === 1 ? [] : parse(out), - partial: ret.code === 2, - })) - }), - ) -} + const filepath = yield* Effect.cached( + Effect.gen(function* () { + const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg")) + if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system -function searchWorker(input: SearchInput) { - if (input.signal?.aborted) return Effect.fail(abort(input.signal)) - - return Effect.acquireUseRelease( - worker(), - (w) => - Effect.callback((resume, signal) => { - let open = true - const done = (effect: Effect.Effect) => { - if (!open) return - open = false - resume(effect) - } - const onabort = () => done(Effect.fail(abort(input.signal))) + const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`) + if (yield* fs.isFile(target).pipe(Effect.orDie)) return target - w.onerror = (evt) => { - done(Effect.fail(toError(evt.error ?? evt.message))) - } - w.onmessage = (evt: MessageEvent) => { - const msg = evt.data - if (msg.type === "error") { - done(Effect.fail(Object.assign(new Error(msg.error.message), msg.error))) - return - } - if (msg.code === 1) { - done(Effect.succeed({ items: [], partial: false })) - return + const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM + const config = PLATFORM[platformKey] + if (!config) { + return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`)) } - if (msg.code !== 0 && msg.code !== 1 && msg.code !== 2) { - done(Effect.fail(error(msg.stderr, msg.code))) - return - } - done( - Effect.sync(() => ({ - items: parse(msg.stdout), - partial: msg.code === 2, - })), - ) - } - input.signal?.addEventListener("abort", onabort, { once: true }) - signal.addEventListener("abort", onabort, { once: true }) - w.postMessage({ - kind: "search", - cwd: input.cwd, - args: searchArgs(input), - } satisfies Run) - - return Effect.sync(() => { - input.signal?.removeEventListener("abort", onabort) - signal.removeEventListener("abort", onabort) - w.onerror = null - w.onmessage = null - }) - }), - (w) => Effect.sync(() => w.terminate()), - ) -} + const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}` + const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}` + const archive = path.join(Global.Path.bin, filename) -function filesDirect(input: FilesInput) { - return Stream.callback( - Effect.fnUntraced(function* (queue: Queue.Queue) { - let buf = "" - let err = "" - // kilocode_change start - const decoder = KiloRipgrepStream.decoder() - const out = { - write(chunk: unknown) { - buf = drain(decoder, buf, chunk, (line) => { - Queue.offerUnsafe(queue, clean(line)) - }) - }, - } - // kilocode_change end - - const stderr = { - write(chunk: unknown) { - err += text(chunk) - }, - } - - yield* Effect.forkScoped( - Effect.gen(function* () { - yield* check(input.cwd) - const ret = yield* Effect.tryPromise({ - try: () => - ripgrep(filesArgs(input), { - stdout: out, - stderr, - ...opts(input.cwd), - }), - catch: toError, - }) - buf += decoder.end() // kilocode_change - if (buf) Queue.offerUnsafe(queue, clean(buf)) - if (ret.code === 0 || ret.code === 1) { - Queue.endUnsafe(queue) - return + log.info("downloading ripgrep", { url }) + yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie) + + const bytes = yield* HttpClientRequest.get(url).pipe( + http.execute, + Effect.flatMap((response) => response.arrayBuffer), + Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))), + ) + if (bytes.byteLength === 0) { + return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`)) } - fail(queue, error(err, ret.code ?? 1)) - }).pipe( - Effect.catch((err) => - Effect.sync(() => { - fail(queue, err) - }), - ), - ), + + yield* fs.writeWithDirs(archive, new Uint8Array(bytes)) + yield* extract(archive, config, target) + yield* fs.remove(archive, { force: true }).pipe(Effect.ignore) + return target + }), ) - }), - ) -} -function filesWorker(input: FilesInput) { - return Stream.callback( - Effect.fnUntraced(function* (queue: Queue.Queue) { - if (input.signal?.aborted) { - fail(queue, abort(input.signal)) - return - } - - const w = yield* Effect.acquireRelease(worker(), (w) => Effect.sync(() => w.terminate())) - let open = true - const close = () => { - if (!open) return false - open = false - return true - } - const onabort = () => { - if (!close()) return - fail(queue, abort(input.signal)) - } - - w.onerror = (evt) => { - if (!close()) return - fail(queue, toError(evt.error ?? evt.message)) - } - w.onmessage = (evt: MessageEvent) => { - const msg = evt.data - if (msg.type === "line") { - if (open) Queue.offerUnsafe(queue, msg.line) - return + const check = Effect.fnUntraced(function* (cwd: string) { + if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return + return yield* Effect.fail( + Object.assign(new Error(`No such file or directory: '${cwd}'`), { + code: "ENOENT", + errno: -2, + path: cwd, + }), + ) + }) + + const command = Effect.fnUntraced(function* (cwd: string, args: string[]) { + const binary = yield* filepath + return ChildProcess.make(binary, args, { + cwd, + env: env(), + extendEnv: true, + stdin: "ignore", + }) + }) + + const files: Interface["files"] = (input) => + Stream.callback((queue) => + Effect.gen(function* () { + yield* Effect.forkScoped( + Effect.gen(function* () { + yield* check(input.cwd) + const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input))) + const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped) + const stdout = yield* Stream.decodeText(handle.stdout).pipe( + Stream.splitLines, + Stream.filter((line) => line.length > 0), + Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))), + Effect.forkScoped, + ) + const code = yield* raceAbort(handle.exitCode, input.signal) + yield* Fiber.join(stdout) + if (code === 0 || code === 1) { + Queue.endUnsafe(queue) + return + } + fail(queue, error(yield* Fiber.join(stderr), code)) + }).pipe( + Effect.catch((err) => + Effect.sync(() => { + fail(queue, err) + }), + ), + ), + ) + }), + ) + + const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) { + yield* check(input.cwd) + + const program = Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input))) + + const [items, stderr, code] = yield* Effect.all( + [ + Stream.decodeText(handle.stdout).pipe( + Stream.splitLines, + Stream.filter((line) => line.length > 0), + Stream.mapEffect(parse), + Stream.filter((item): item is Match => item.type === "match"), + Stream.map((item) => row(item.data)), + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ) + + if (code !== 0 && code !== 1 && code !== 2) { + return yield* Effect.fail(error(stderr, code)) + } + + return { + items: code === 1 ? [] : items, + partial: code === 2, + } + }), + ) + + return yield* raceAbort(program, input.signal) + }) + + const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) { + log.info("tree", input) + const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect)) + + interface Node { + name: string + children: Map } - if (!close()) return - if (msg.type === "error") { - fail(queue, Object.assign(new Error(msg.error.message), msg.error)) - return + + function child(node: Node, name: string) { + const item = node.children.get(name) + if (item) return item + const next = { name, children: new Map() } + node.children.set(name, next) + return next } - if (msg.code === 0 || msg.code === 1) { - Queue.endUnsafe(queue) - return + + function count(node: Node): number { + return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0) } - fail(queue, error(msg.stderr, msg.code)) - } - - yield* Effect.acquireRelease( - Effect.sync(() => { - input.signal?.addEventListener("abort", onabort, { once: true }) - w.postMessage({ - kind: "files", - cwd: input.cwd, - args: filesArgs(input), - } satisfies Run) - }), - () => - Effect.sync(() => { - input.signal?.removeEventListener("abort", onabort) - w.onerror = null - w.onmessage = null - }), - ) - }), - ) -} -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const source = (input: FilesInput) => { - const useWorker = !!input.signal && typeof Worker !== "undefined" - if (!useWorker && input.signal) { - log.warn("worker unavailable, ripgrep abort disabled") - } - return useWorker ? filesWorker(input) : filesDirect(input) - } - - const files: Interface["files"] = (input) => source(input) - - const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) { - log.info("tree", input) - const list = Array.from(yield* source({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect)) - - interface Node { - name: string - children: Map - } - - function child(node: Node, name: string) { - const item = node.children.get(name) - if (item) return item - const next = { name, children: new Map() } - node.children.set(name, next) - return next - } - - function count(node: Node): number { - return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0) - } - - const root: Node = { name: "", children: new Map() } - for (const file of list) { - if (file.includes(".kilo") || file.includes(".opencode")) continue // kilocode_change - const parts = file.split(path.sep) - if (parts.length < 2) continue - let node = root - for (const part of parts.slice(0, -1)) { - node = child(node, part) + const root: Node = { name: "", children: new Map() } + for (const file of list) { + if (file.includes(".kilo") || file.includes(".opencode")) continue // kilocode_change + const parts = file.split(path.sep) + if (parts.length < 2) continue + let node = root + for (const part of parts.slice(0, -1)) { + node = child(node, part) + } + } + + const total = count(root) + const limit = input.limit ?? total + const lines: string[] = [] + const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ({ node, path: node.name })) + + let used = 0 + for (let i = 0; i < queue.length && used < limit; i++) { + const item = queue[i] + lines.push(item.path) + used++ + queue.push( + ...Array.from(item.node.children.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ({ node, path: `${item.path}/${node.name}` })), + ) } - } - - const total = count(root) - const limit = input.limit ?? total - const lines: string[] = [] - const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => ({ node, path: node.name })) - - let used = 0 - for (let i = 0; i < queue.length && used < limit; i++) { - const item = queue[i] - lines.push(item.path) - used++ - queue.push( - ...Array.from(item.node.children.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => ({ node, path: `${item.path}/${node.name}` })), - ) - } - if (total > used) lines.push(`[${total - used} truncated]`) - return lines.join("\n") - }) + if (total > used) lines.push(`[${total - used} truncated]`) + return lines.join("\n") + }) - const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) { - const useWorker = !!input.signal && typeof Worker !== "undefined" - if (!useWorker && input.signal) { - log.warn("worker unavailable, ripgrep abort disabled") - } - return yield* useWorker ? searchWorker(input) : searchDirect(input) - }) + return Service.of({ files, tree, search }) + }), + ) - return Service.of({ files, tree, search }) - }), +export const defaultLayer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), ) -export const defaultLayer = layer - export * as Ripgrep from "./ripgrep" diff --git a/packages/opencode/src/file/ripgrep.worker.ts b/packages/opencode/src/file/ripgrep.worker.ts deleted file mode 100644 index 10566a8225a..00000000000 --- a/packages/opencode/src/file/ripgrep.worker.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ripgrep } from "ripgrep" -import { sanitizedProcessEnv } from "@/util/opencode-process" -import { KiloRipgrepStream } from "../kilocode/kilo-ripgrep-stream" // kilocode_change - share UTF-8 stream decoding - -function env() { - const env = sanitizedProcessEnv() - delete env.RIPGREP_CONFIG_PATH - return env -} - -function opts(cwd: string) { - return { - env: env(), - preopens: { ".": cwd }, - } -} - -type Run = { - kind: "files" | "search" - cwd: string - args: string[] -} - -function text(input: unknown) { - if (typeof input === "string") return input - if (input instanceof ArrayBuffer) return Buffer.from(input).toString() - if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString() - return String(input) -} - -function error(input: unknown) { - if (input instanceof Error) { - return { - message: input.message, - name: input.name, - stack: input.stack, - } - } - - return { - message: String(input), - } -} - -function clean(file: string) { - return file.replace(/^\.[\\/]/, "") -} - -onmessage = async (evt: MessageEvent) => { - const msg = evt.data - - try { - if (msg.kind === "search") { - const ret = await ripgrep(msg.args, { - buffer: true, - ...opts(msg.cwd), - }) - postMessage({ - type: "result", - code: ret.code ?? 0, - stdout: ret.stdout ?? "", - stderr: ret.stderr ?? "", - }) - return - } - - let buf = "" - let err = "" - // kilocode_change start - keep decoder state across stdout chunks - const decoder = KiloRipgrepStream.decoder() - const out = { - write(chunk: unknown) { - buf = KiloRipgrepStream.drain(decoder, buf, chunk, (line) => postMessage({ type: "line", line: clean(line) })) - }, - } - // kilocode_change end - const stderr = { - write(chunk: unknown) { - err += text(chunk) - }, - } - - const ret = await ripgrep(msg.args, { - stdout: out, - stderr, - ...opts(msg.cwd), - }) - - buf += decoder.end() // kilocode_change - flush any trailing buffered bytes - if (buf) postMessage({ type: "line", line: clean(buf) }) - postMessage({ - type: "done", - code: ret.code ?? 0, - stderr: err, - }) - } catch (err) { - postMessage({ - type: "error", - error: error(err), - }) - } -} diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 85934ce9c9a..53a2c10119b 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -25,7 +25,7 @@ export type Status = z.infer export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect - readonly file: (filepath: string) => Effect.Effect + readonly file: (filepath: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Format") {} @@ -70,16 +70,19 @@ export const layer = Layer.effect( } }), ) - return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! })) + return checks + .filter((x): x is { item: Formatter.Info; cmd: string[] } => x.cmd !== false) + .map((x) => ({ item: x.item, cmd: x.cmd })) } function formatFile(filepath: string) { return Effect.gen(function* () { log.info("formatting", { file: filepath }) - const ext = path.extname(filepath) + const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath))) - for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) { - if (cmd === false) continue + if (!formatters.length) return false + + for (const { item, cmd } of formatters) { log.info("running", { command: cmd }) const replaced = cmd.map((x) => x.replace("$FILE", filepath)) const dir = yield* InstanceState.directory @@ -113,6 +116,8 @@ export const layer = Layer.effect( }) } } + + return true }) } @@ -188,7 +193,7 @@ export const layer = Layer.effect( const file = Effect.fn("Format.file")(function* (filepath: string) { const { formatFile } = yield* InstanceState.get(state) - yield* formatFile(filepath) + return yield* formatFile(filepath) }) return Service.of({ init, status, file }) diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index ee3141a6f88..40feff1afe5 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -277,7 +277,7 @@ export namespace RemoteSender { return } dispatchLongRunning(msg, directoryFor(input.data.sessionID), async () => { - await SessionPrompt.prompt(input.data) + await SessionPrompt.prompt(input.data as SessionPrompt.PromptInput) }) return } diff --git a/packages/opencode/src/kilocode/config-validation.ts b/packages/opencode/src/kilocode/config-validation.ts index a14f946edbb..24c9abb5b28 100644 --- a/packages/opencode/src/kilocode/config-validation.ts +++ b/packages/opencode/src/kilocode/config-validation.ts @@ -43,7 +43,7 @@ export namespace ConfigValidation { return `\n\n\nERROR: Config file at ${label(filepath)} is not valid JSON(C)\n ${detail}\n` } - const result = Config.Info.safeParse(data) + const result = Config.Info.zod.safeParse(data) if (!result.success) { const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n") return `\n\n\nWARNING: Configuration is invalid at ${label(filepath)}\n${issues}\n` diff --git a/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts b/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts deleted file mode 100644 index eacedee8a55..00000000000 --- a/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { StringDecoder } from "string_decoder" - -export namespace KiloRipgrepStream { - export function decoder() { - return new StringDecoder("utf8") - } - - function decode(dec: StringDecoder, input: unknown) { - if (typeof input === "string") return input - if (input instanceof ArrayBuffer) return dec.write(Buffer.from(input)) - if (ArrayBuffer.isView(input)) return dec.write(Buffer.from(input.buffer, input.byteOffset, input.byteLength)) - return String(input) - } - - export function drain(dec: StringDecoder, buf: string, chunk: unknown, push: (line: string) => void) { - const lines = (buf + decode(dec, chunk)).split(/\r?\n/) - const rest = lines.pop() || "" - for (const line of lines) { - if (line) push(line) - } - return rest - } -} diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts index bf416d0a24c..c516235ad80 100644 --- a/packages/opencode/src/kilocode/review/worktree-diff.ts +++ b/packages/opencode/src/kilocode/review/worktree-diff.ts @@ -3,23 +3,26 @@ import { $ } from "bun" import { createTwoFilesPatch } from "diff" import fs from "node:fs/promises" import path from "node:path" -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" import { FileIgnore } from "@/file/ignore" import { Snapshot } from "@/snapshot" import { Log } from "@/util" +import { withStatics } from "@/util/schema" export namespace WorktreeDiff { - export const Item = Snapshot.FileDiff.extend({ - before: z.string(), - after: z.string(), - tracked: z.boolean(), - generatedLike: z.boolean(), - summarized: z.boolean(), - stamp: z.string(), - }).meta({ - ref: "WorktreeDiffItem", + export const Item = Schema.Struct({ + ...Snapshot.FileDiff.fields, + before: Schema.String, + after: Schema.String, + tracked: Schema.Boolean, + generatedLike: Schema.Boolean, + summarized: Schema.Boolean, + stamp: Schema.String, }) - export type Item = z.infer + .annotate({ identifier: "WorktreeDiffItem" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Item = typeof Item.Type type Status = NonNullable diff --git a/packages/opencode/src/kilocode/ts-client.ts b/packages/opencode/src/kilocode/ts-client.ts index 6c9ccc24d90..3cd3a955be2 100644 --- a/packages/opencode/src/kilocode/ts-client.ts +++ b/packages/opencode/src/kilocode/ts-client.ts @@ -72,12 +72,13 @@ export namespace TsClient { // trigger notify.open() but should NOT spawn tsgo. The actual // check is deferred to waitForDiagnostics() which is only // called when tools need diagnostics (write, edit, apply_patch). + return 0 }, }, get diagnostics() { return diagnostics }, - async waitForDiagnostics(_input: { path: string }) { + async waitForDiagnostics(_input: { path: string; version: number; mode?: "document" | "full"; after?: number }) { // Run tsgo --noEmit and wait for results. Coalesces concurrent calls. // 30s cap matches the process timeout in TsCheck.run(). Silent catch // matches the real LSPClient's .catch(() => {}) on its 3s timeout. diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index b20e8ae7f00..f6d5110a6c4 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -14,6 +14,16 @@ import { withTimeout } from "../util/timeout" import { Filesystem } from "../util" const DIAGNOSTICS_DEBOUNCE_MS = 150 +const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000 +const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000 +const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000 + +const INITIALIZE_TIMEOUT_MS = 45_000 + +// LSP spec constants +const FILE_CHANGE_CREATED = 1 +const FILE_CHANGE_CHANGED = 2 +const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 const log = Log.create({ service: "lsp.client" }) @@ -38,48 +48,194 @@ export const Event = { ), } +type DocumentDiagnosticReport = { + items?: Diagnostic[] + relatedDocuments?: Record +} + +type WorkspaceDiagnosticReport = { + items?: { + uri?: string + items?: Diagnostic[] + }[] +} + +type DiagnosticRequestResult = { + handled: boolean + matched: boolean + byFile: Map +} + +type CapabilityRegistration = { + id: string + method: string + registerOptions?: { + identifier?: string + workspaceDiagnostics?: boolean + } +} + +type ServerCapabilities = { + textDocumentSync?: + | number + | { + change?: number + } + diagnosticProvider?: unknown + [key: string]: unknown +} + +function getFilePath(uri: string) { + if (!uri.startsWith("file://")) return + return Filesystem.normalizePath(fileURLToPath(uri)) +} + +function getSyncKind(capabilities?: ServerCapabilities) { + if (!capabilities) return + const sync = capabilities.textDocumentSync + if (typeof sync === "number") return sync + return sync?.change +} + +function endPosition(text: string) { + const lines = text.split(/\r\n|\r|\n/) + return { + line: lines.length - 1, + character: lines.at(-1)?.length ?? 0, + } +} + +function dedupeDiagnostics(items: Diagnostic[]) { + const seen = new Set() + return items.filter((item) => { + const key = JSON.stringify({ + code: item.code, + severity: item.severity, + message: item.message, + source: item.source, + range: item.range, + }) + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function configurationValue(settings: unknown, section?: string) { + if (!section) return settings ?? null + const result = section.split(".").reduce((acc, key) => { + if (!acc || typeof acc !== "object" || !(key in acc)) return undefined + return (acc as Record)[key] + }, settings) + return result ?? null +} + +// TypeScript's built-in LSP pushes diagnostics aggressively on first open. +// We seed the push cache on the very first publish so waitForFreshPush can +// resolve immediately instead of waiting for a second debounced push. +function shouldSeedDiagnosticsOnFirstPush(serverID: string) { + return serverID === "typescript" +} + export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) { - const l = log.clone().tag("serverID", input.serverID) - l.info("starting client") + const logger = log.clone().tag("serverID", input.serverID) + logger.info("starting client") const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout as any), new StreamMessageWriter(input.server.process.stdin as any), ) + // Server stderr can contain both real errors and routine informational logs, + // which is normal stderr practice for some tools. Keep the raw stream at + // debug so users can opt in with --print-logs --log-level DEBUG without + // polluting normal logs. + input.server.process.stderr?.on("data", (data: Buffer) => { + const text = data.toString().trim() + if (text) logger.debug("server stderr", { text: text.slice(0, 1000) }) + }) + + // --- Connection state --- + + const pushDiagnostics = new Map() + const pullDiagnostics = new Map() + const published = new Map() + const diagnosticRegistrations = new Map() + const registrationListeners = new Set<() => void>() + const mergedDiagnostics = (filePath: string) => + dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])]) + const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { + pushDiagnostics.set(filePath, next) + Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + } + const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { + pullDiagnostics.set(filePath, next) + } + const emitRegistrationChange = () => { + for (const listener of [...registrationListeners]) listener() + } + + // --- LSP connection handlers --- - const diagnostics = new Map() connection.onNotification("textDocument/publishDiagnostics", (params) => { - const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) - l.info("textDocument/publishDiagnostics", { + const filePath = getFilePath(params.uri) + if (!filePath) return + logger.info("textDocument/publishDiagnostics", { path: filePath, count: params.diagnostics.length, + version: params.version, }) - const exists = diagnostics.has(filePath) - diagnostics.set(filePath, params.diagnostics) - if (!exists && input.serverID === "typescript") return - Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + published.set(filePath, { + at: Date.now(), + version: typeof params.version === "number" ? params.version : undefined, + }) + if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) { + pushDiagnostics.set(filePath, params.diagnostics) + return + } + updatePushDiagnostics(filePath, params.diagnostics) }) connection.onRequest("window/workDoneProgress/create", (params) => { - l.info("window/workDoneProgress/create", params) + logger.info("window/workDoneProgress/create", params) return null }) - connection.onRequest("workspace/configuration", async () => { - // Return server initialization options - return [input.server.initialization ?? {}] + connection.onRequest("workspace/configuration", async (params) => { + const items = (params as { items?: { section?: string }[] }).items ?? [] + return items.map((item) => configurationValue(input.server.initialization, item.section)) + }) + connection.onRequest("client/registerCapability", async (params) => { + const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? [] + let changed = false + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue + diagnosticRegistrations.set(registration.id, registration) + changed = true + } + if (changed) emitRegistrationChange() + }) + connection.onRequest("client/unregisterCapability", async (params) => { + const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? [] + let changed = false + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue + diagnosticRegistrations.delete(registration.id) + changed = true + } + if (changed) emitRegistrationChange() }) - connection.onRequest("client/registerCapability", async () => {}) - connection.onRequest("client/unregisterCapability", async () => {}) connection.onRequest("workspace/workspaceFolders", async () => [ { name: "workspace", uri: pathToFileURL(input.root).href, }, ]) + connection.onRequest("workspace/diagnostic/refresh", async () => null) connection.listen() - l.info("sending initialize") - await withTimeout( - connection.sendRequest("initialize", { + // --- Initialize handshake --- + + logger.info("sending initialize") + const initialized = await withTimeout( + connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { rootUri: pathToFileURL(input.root).href, processId: input.server.process.pid, workspaceFolders: [ @@ -100,21 +256,28 @@ export async function create(input: { serverID: string; server: LSPServer.Handle didChangeWatchedFiles: { dynamicRegistration: true, }, + diagnostics: { + refreshSupport: false, + }, }, textDocument: { synchronization: { didOpen: true, didChange: true, }, + diagnostic: { + dynamicRegistration: true, + relatedDocumentSupport: true, + }, publishDiagnostics: { - versionSupport: true, + versionSupport: false, }, }, }, }), - 45_000, + INITIALIZE_TIMEOUT_MS, ).catch((err) => { - l.error("initialize error", { error: err }) + logger.error("initialize error", { error: err }) throw new InitializeError( { serverID: input.serverID }, { @@ -123,6 +286,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle ) }) + const syncKind = getSyncKind(initialized.capabilities) + const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider) + await connection.sendNotification("initialized", {}) if (input.server.initialization) { @@ -131,9 +297,280 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }) } - const files: { - [path: string]: number - } = {} + const files: Record = {} + + // --- Diagnostic helpers --- + + const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => { + const handled = results.some((result) => result.handled) + const matched = results.some((result) => result.matched) + if (!handled) return { handled: false, matched: false } + + const merged = new Map() + for (const result of results) { + for (const [target, items] of result.byFile.entries()) { + const existing = merged.get(target) ?? [] + merged.set(target, existing.concat(items)) + } + } + + if (matched && !merged.has(filePath)) merged.set(filePath, []) + for (const [target, items] of merged.entries()) { + updatePullDiagnostics(target, dedupeDiagnostics(items)) + } + + return { handled, matched } + } + + async function requestDiagnosticReport(filePath: string, identifier?: string): Promise { + const report = await withTimeout( + connection.sendRequest("textDocument/diagnostic", { + ...(identifier ? { identifier } : {}), + textDocument: { + uri: pathToFileURL(filePath).href, + }, + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null) + if (!report) return { handled: false, matched: false, byFile: new Map() } + + const byFile = new Map() + const push = (target: string, items: Diagnostic[]) => { + const existing = byFile.get(target) ?? [] + byFile.set(target, existing.concat(items)) + } + + let handled = false + let matched = false + if (Array.isArray(report.items)) { + push(filePath, report.items) + handled = true + matched = true + } + for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { + const relatedPath = getFilePath(uri) + if (!relatedPath || !Array.isArray(related.items)) continue + push(relatedPath, related.items) + handled = true + matched = matched || relatedPath === filePath + } + + return { handled, matched, byFile } + } + + async function requestWorkspaceDiagnosticReport( + filePath: string, + identifier?: string, + ): Promise { + const report = await withTimeout( + connection.sendRequest("workspace/diagnostic", { + ...(identifier ? { identifier } : {}), + previousResultIds: [], + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null) + if (!report) return { handled: false, matched: false, byFile: new Map() } + + const byFile = new Map() + let matched = false + for (const item of report.items ?? []) { + const relatedPath = item.uri ? getFilePath(item.uri) : undefined + if (!relatedPath || !Array.isArray(item.items)) continue + const existing = byFile.get(relatedPath) ?? [] + byFile.set(relatedPath, existing.concat(item.items)) + matched = matched || relatedPath === filePath + } + + return { handled: true, matched, byFile } + } + + function documentPullState() { + const documentRegistrations = [...diagnosticRegistrations.values()].filter( + (registration) => registration.registerOptions?.workspaceDiagnostics !== true, + ) + return { + documentIdentifiers: [ + ...new Set(documentRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])), + ], + supported: hasStaticPullDiagnostics || documentRegistrations.length > 0, + } + } + + function workspacePullState() { + const workspaceRegistrations = [...diagnosticRegistrations.values()].filter( + (registration) => registration.registerOptions?.workspaceDiagnostics === true, + ) + return { + workspaceIdentifiers: [ + ...new Set(workspaceRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])), + ], + supported: workspaceRegistrations.length > 0, + } + } + + const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) => + results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0) + + async function requestDiagnostics( + filePath: string, + requests: Promise[], + done: (results: DiagnosticRequestResult[]) => boolean, + ) { + if (!requests.length) return { handled: false, matched: false } + + const results: DiagnosticRequestResult[] = [] + return new Promise<{ handled: boolean; matched: boolean }>((resolve) => { + let pending = requests.length + let resolved = false + const finish = (merged: { handled: boolean; matched: boolean }, force = false) => { + if (resolved) return + if (!force && !done(results)) return + resolved = true + resolve(merged) + } + + for (const request of requests) { + request.then((result) => { + results.push(result) + pending -= 1 + const merged = mergeResults(filePath, results) + finish(merged) + if (pending === 0) finish(merged, true) + }) + } + }) + } + + // LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one + // batch already produced diagnostics for the current file. Let slower pulls keep + // merging in the background; do not sequence identifier-by-identifier, and do + // not add a post-match settle/debounce delay. See PR #23771. + async function requestDocumentDiagnostics(filePath: string) { + const state = documentPullState() + if (!state.supported) return { handled: false, matched: false } + return requestDiagnostics( + filePath, + [ + requestDiagnosticReport(filePath), + ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)), + ], + (results) => hasCurrentFileDiagnostics(filePath, results), + ) + } + + async function requestFullDiagnostics(filePath: string) { + const documentState = documentPullState() + const workspaceState = workspacePullState() + if (!documentState.supported && !workspaceState.supported) return { handled: false, matched: false } + return mergeResults( + filePath, + await Promise.all([ + ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []), + ...documentState.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)), + ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []), + ...workspaceState.workspaceIdentifiers.map((identifier) => + requestWorkspaceDiagnosticReport(filePath, identifier), + ), + ]), + ) + } + + function waitForRegistrationChange(timeout: number) { + if (timeout <= 0) return Promise.resolve(false) + return new Promise((resolve) => { + let finished = false + let timer: ReturnType | undefined + const finish = (result: boolean) => { + if (finished) return + finished = true + if (timer) clearTimeout(timer) + registrationListeners.delete(listener) + resolve(result) + } + const listener = () => finish(true) + registrationListeners.add(listener) + timer = setTimeout(() => finish(false), timeout) + }) + } + + function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) { + if (request.timeout <= 0) return Promise.resolve(false) + return new Promise((resolve) => { + let finished = false + let debounceTimer: ReturnType | undefined + let timeoutTimer: ReturnType | undefined + let unsub: (() => void) | undefined + const finish = (result: boolean) => { + if (finished) return + finished = true + if (debounceTimer) clearTimeout(debounceTimer) + if (timeoutTimer) clearTimeout(timeoutTimer) + unsub?.() + resolve(result) + } + const schedule = () => { + const hit = published.get(request.path) + if (!hit) return + if (typeof hit.version === "number" && hit.version !== request.version) return + if (hit.at < request.after && hit.version !== request.version) return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at))) + } + + timeoutTimer = setTimeout(() => finish(false), request.timeout) + unsub = Bus.subscribe(Event.Diagnostics, (event) => { + if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return + schedule() + }) + schedule() + }) + } + + async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) { + const startedAt = request.after ?? Date.now() + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS, + }) + + while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) { + const result = await requestDocumentDiagnostics(request.path) + if (result.matched) return + const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + if (remaining <= 0) return + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : ("timeout" as const))), + waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))), + ]) + if (next !== "registration") return + } + } + + async function waitForFullDiagnostics(request: { path: string; version: number; after?: number }) { + const startedAt = request.after ?? Date.now() + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS, + }) + + while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) { + const result = await requestFullDiagnostics(request.path) + if (result.handled || result.matched) return + const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + if (remaining <= 0) return + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : ("timeout" as const))), + waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))), + ]) + if (next !== "registration") return + } + } + + // --- Public API --- const result = { root: input.root, @@ -145,26 +582,32 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }, notify: { async open(request: { path: string }) { - request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path) + request.path = Filesystem.normalizePath( + path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), + ) const text = await Filesystem.readText(request.path) const extension = path.extname(request.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - const version = files[request.path] - if (version !== undefined) { - log.info("workspace/didChangeWatchedFiles", request) + const document = files[request.path] + if (document !== undefined) { + // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only + // re-emit diagnostics when the content actually changes, so clearing + // here would lose errors for no-op touchFile calls. Let the server's + // next push/pull overwrite naturally. + logger.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { uri: pathToFileURL(request.path).href, - type: 2, // Changed + type: FILE_CHANGE_CHANGED, }, ], }) - const next = version + 1 - files[request.path] = next - log.info("textDocument/didChange", { + const next = document.version + 1 + files[request.path] = { version: next, text } + logger.info("textDocument/didChange", { path: request.path, version: next, }) @@ -173,23 +616,35 @@ export async function create(input: { serverID: string; server: LSPServer.Handle uri: pathToFileURL(request.path).href, version: next, }, - contentChanges: [{ text }], + contentChanges: + syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: endPosition(document.text), + }, + text, + }, + ] + : [{ text }], }) - return + return next } - log.info("workspace/didChangeWatchedFiles", request) + logger.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { uri: pathToFileURL(request.path).href, - type: 1, // Created + type: FILE_CHANGE_CREATED, }, ], }) - log.info("textDocument/didOpen", request) - diagnostics.delete(request.path) + logger.info("textDocument/didOpen", request) + pushDiagnostics.delete(request.path) + pullDiagnostics.delete(request.path) await connection.sendNotification("textDocument/didOpen", { textDocument: { uri: pathToFileURL(request.path).href, @@ -198,52 +653,42 @@ export async function create(input: { serverID: string; server: LSPServer.Handle text, }, }) - files[request.path] = 0 - return + files[request.path] = { version: 0, text } + return 0 }, }, get diagnostics() { - return diagnostics + const result = new Map() + for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) { + result.set(key, mergedDiagnostics(key)) + } + return result }, - async waitForDiagnostics(request: { path: string }) { + async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full"; after?: number }) { const normalizedPath = Filesystem.normalizePath( path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), ) - log.info("waiting for diagnostics", { path: normalizedPath }) - let unsub: () => void - let debounceTimer: ReturnType | undefined - return await withTimeout( - new Promise((resolve) => { - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { - // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - log.info("got diagnostics", { path: normalizedPath }) - unsub?.() - resolve() - }, DIAGNOSTICS_DEBOUNCE_MS) - } - }) - }), - 3000, - ) - .catch(() => {}) - .finally(() => { - if (debounceTimer) clearTimeout(debounceTimer) - unsub?.() - }) + logger.info("waiting for diagnostics", { + path: normalizedPath, + mode: request.mode ?? "full", + version: request.version, + }) + if (request.mode === "document") { + await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) + return + } + await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) }, async shutdown() { - l.info("shutting down") + logger.info("shutting down") connection.end() connection.dispose() await Process.stop(input.server.process) - l.info("shutdown") + logger.info("shutdown") }, } - l.info("initialized") + logger.info("initialized") return result } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 309a9fa84d8..08a8a3e4c32 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -10,10 +10,12 @@ import { Config } from "../config" import { Flag } from "@/flag/flag" import { Process } from "../util" import { spawn as lspspawn } from "./launch" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { TsClient } from "../kilocode/ts-client" // kilocode_change +import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@/util/effect-zod" const log = Log.create({ service: "lsp" }) @@ -21,60 +23,53 @@ export const Event = { Updated: BusEvent.define("lsp.updated", z.object({})), } -export const Range = z - .object({ - start: z.object({ - line: z.number(), - character: z.number(), - }), - end: z.object({ - line: z.number(), - character: z.number(), - }), - }) - .meta({ - ref: "Range", - }) -export type Range = z.infer - -export const Symbol = z - .object({ - name: z.string(), - kind: z.number(), - location: z.object({ - uri: z.string(), - range: Range, - }), - }) - .meta({ - ref: "Symbol", - }) -export type Symbol = z.infer - -export const DocumentSymbol = z - .object({ - name: z.string(), - detail: z.string().optional(), - kind: z.number(), +const Position = Schema.Struct({ + line: Schema.Number, + character: Schema.Number, +}) + +export const Range = Schema.Struct({ + start: Position, + end: Position, +}) + .annotate({ identifier: "Range" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Range = typeof Range.Type + +export const Symbol = Schema.Struct({ + name: Schema.String, + kind: Schema.Number, + location: Schema.Struct({ + uri: Schema.String, range: Range, - selectionRange: Range, - }) - .meta({ - ref: "DocumentSymbol", - }) -export type DocumentSymbol = z.infer - -export const Status = z - .object({ - id: z.string(), - name: z.string(), - root: z.string(), - status: z.union([z.literal("connected"), z.literal("error")]), - }) - .meta({ - ref: "LSPStatus", - }) -export type Status = z.infer + }), +}) + .annotate({ identifier: "Symbol" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Symbol = typeof Symbol.Type + +export const DocumentSymbol = Schema.Struct({ + name: Schema.String, + detail: Schema.optional(Schema.String), + kind: Schema.Number, + range: Range, + selectionRange: Range, +}) + .annotate({ identifier: "DocumentSymbol" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type DocumentSymbol = typeof DocumentSymbol.Type + +export const Status = Schema.Struct({ + id: Schema.String, + name: Schema.String, + root: Schema.String, + status: Schema.Literals(["connected", "error"]).annotate({ + [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]), + }), +}) + .annotate({ identifier: "LSPStatus" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Status = typeof Status.Type enum SymbolKind { File = 1, @@ -142,7 +137,7 @@ export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect readonly hasClients: (file: string) => Effect.Effect - readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect + readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect readonly diagnostics: () => Effect.Effect> readonly hover: (input: LocInput) => Effect.Effect readonly definition: (input: LocInput) => Effect.Effect @@ -379,15 +374,21 @@ export const layer = Layer.effect( }) }) - const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { + const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") { log.info("touching file", { file: input }) const clients = yield* getClients(input) yield* Effect.promise(() => Promise.all( clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) - return wait + const after = Date.now() + const version = await client.notify.open({ path: input }) + if (!diagnostics) return + return client.waitForDiagnostics({ + path: input, + version, + mode: diagnostics, + after, + }) }), ).catch((err) => { log.error("failed to touch file", { err, file: input }) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 30ef500aacb..8cf65635c0a 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -488,7 +488,7 @@ export const Pyright: Info = { const args = [] if (!binary) { if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("pyright") + const resolved = await Npm.which("pyright", "pyright-langserver") if (!resolved) return binary = resolved } @@ -703,32 +703,32 @@ export const CSharp: Info = { root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), extensions: [".cs"], async spawn(root) { - let bin = which("csharp-ls") + let bin = which("roslyn-language-server") if (!bin) { if (!which("dotnet")) { - log.error(".NET SDK is required to install csharp-ls") + log.error(".NET SDK is required to install roslyn-language-server") return } if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return - log.info("installing csharp-ls via dotnet tool") - const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], { + log.info("installing roslyn-language-server via dotnet tool") + const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], { stdout: "pipe", stderr: "pipe", stdin: "pipe", }) const exit = await proc.exited if (exit !== 0) { - log.error("Failed to install csharp-ls") + log.error("Failed to install roslyn-language-server") return } - bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed csharp-ls`, { bin }) + bin = path.join(Global.Path.bin, "roslyn-language-server" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed roslyn-language-server`, { bin }) } return { - process: spawn(bin, { + process: spawn(bin, ["--stdio", "--autoLoadProjects"], { cwd: root, }), } diff --git a/packages/opencode/src/npm/config.ts b/packages/opencode/src/npm/config.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 477e99e06af..d6322d5488b 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -1,8 +1,11 @@ export * as Npm from "." import path from "path" +import { fileURLToPath } from "url" 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 { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -34,18 +37,45 @@ export interface Interface { }, ) => Effect.Effect readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect - readonly which: (pkg: string) => Effect.Effect> + readonly which: (pkg: string, bin?: string) => Effect.Effect> } export class Service extends Context.Service()("@opencode/Npm") {} const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined +const npmPath = fileURLToPath(new URL("../..", import.meta.url)) export function sanitize(pkg: string) { if (!illegal) return pkg return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } +const loadOptions = (dir: string) => + Effect.tryPromise({ + try: async () => { + const config = new Config({ + npmPath, + cwd: dir, + env: { ...process.env }, + argv: [process.execPath, process.execPath], + execPath: process.execPath, + platform: process.platform, + definitions, + flatten, + nerfDarts, + shorthands, + warn: false, + }) + await config.load() + return config.flat + }, + catch: (cause) => + new InstallFailedError({ + cause, + dir, + }), + }) + const resolveEntryPoint = (name: string, dir: string): EntryPoint => { let entrypoint: Option.Option try { @@ -81,7 +111,10 @@ export const layer = Layer.effect( Effect.gen(function* () { yield* flock.acquire(`npm-install:${input.dir}`) const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) + const add = input.add ?? [] + const npmOptions = yield* loadOptions(input.dir) const arborist = new Arborist({ + ...npmOptions, path: input.dir, binLinks: true, progress: false, @@ -91,14 +124,15 @@ export const layer = Layer.effect( return yield* Effect.tryPromise({ try: () => arborist.reify({ - add: input?.add || [], + ...npmOptions, + add, save: true, saveType: "prod", }), catch: (cause) => new InstallFailedError({ cause, - add: input?.add, + add, dir: input.dir, }), }) as Effect.Effect @@ -207,7 +241,7 @@ export const layer = Layer.effect( return }, Effect.scoped) - const which = Effect.fn("Npm.which")(function* (pkg: string) { + const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) { const dir = directory(pkg) const binDir = path.join(dir, "node_modules", ".bin") @@ -215,6 +249,9 @@ export const layer = Layer.effect( const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[]))) if (files.length === 0) return Option.none() + // Caller picked a specific bin (e.g. pyright exposes both `pyright` and + // `pyright-langserver`); trust the hint if the package provides it. + if (bin) return files.includes(bin) ? Option.some(bin) : Option.none() if (files.length === 1) return Option.some(files[0]) const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option) @@ -223,11 +260,11 @@ export const layer = Layer.effect( const parsed = pkgJson.value as { bin?: string | Record } if (parsed?.bin) { const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg - const bin = parsed.bin - if (typeof bin === "string") return Option.some(unscoped) - const keys = Object.keys(bin) + const parsedBin = parsed.bin + if (typeof parsedBin === "string") return Option.some(unscoped) + const keys = Object.keys(parsedBin) if (keys.length === 1) return Option.some(keys[0]) - return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) + return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) } } diff --git a/packages/opencode/src/npmcli-config.d.ts b/packages/opencode/src/npmcli-config.d.ts new file mode 100644 index 00000000000..c9b20517ad9 --- /dev/null +++ b/packages/opencode/src/npmcli-config.d.ts @@ -0,0 +1,43 @@ +declare module "@npmcli/config" { + type Data = Record + type Where = "default" | "builtin" | "global" | "user" | "project" | "env" | "cli" + + namespace Config { + interface Options { + definitions: Data + shorthands: Record + npmPath: string + flatten?: (input: Data, flat?: Data) => Data + nerfDarts?: string[] + argv?: string[] + cwd?: string + env?: NodeJS.ProcessEnv + execPath?: string + platform?: NodeJS.Platform + warn?: boolean + } + } + + class Config { + constructor(input: Config.Options) + + readonly data: Map + readonly flat: Data + + load(): Promise + } + + export = Config +} + +declare module "@npmcli/config/lib/definitions" { + export const definitions: Record + export const shorthands: Record + export const flatten: (input: Record, flat?: Record) => Record + export const nerfDarts: string[] + export const proxyEnv: string[] +} + +declare module "@npmcli/config/lib/definitions/index.js" { + export * from "@npmcli/config/lib/definitions" +} diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 2b3e9c20b3c..80dbf03be59 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -3,6 +3,7 @@ import * as path from "path" import * as fs from "fs/promises" import { Log } from "../util" import { Encoding } from "../kilocode/encoding" // kilocode_change +import * as Bom from "../util/bom" const log = Log.create({ service: "patch" }) @@ -305,24 +306,26 @@ export function maybeParseApplyPatch( interface ApplyPatchFileUpdate { unified_diff: string content: string + bom: boolean encoding: string // kilocode_change } export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { // Read original file content - let originalContent: string + let originalContent: ReturnType let encoding: string // kilocode_change - track detected encoding for round-trip write try { - // kilocode_change start - encoding-aware read replaces readFileSync(filePath, "utf-8") + // kilocode_change start - encoding-aware read replaces readFileSync(filePath, "utf-8"). + // Encoding.readSync strips UTF-8 BOMs so the BOM flag is derived from the encoding label. const result = Encoding.readSync(filePath) - originalContent = result.text + originalContent = { bom: result.encoding === "utf-8-bom", text: result.text } encoding = result.encoding // kilocode_change end } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error }) } - let originalLines = originalContent.split("\n") + let originalLines = originalContent.text.split("\n") // Drop trailing empty element for consistent line counting if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { @@ -337,14 +340,16 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile newLines.push("") } - const newContent = newLines.join("\n") + const next = Bom.split(newLines.join("\n")) + const newContent = next.text // Generate unified diff - const unifiedDiff = generateUnifiedDiff(originalContent, newContent) + const unifiedDiff = generateUnifiedDiff(originalContent.text, newContent) return { unified_diff: unifiedDiff, content: newContent, + bom: originalContent.bom || next.bom, encoding, // kilocode_change - include detected encoding for round-trip write } } @@ -549,13 +554,13 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise { if (hunk.move_path) { // Handle file move - await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.mkdir + fs.writeFile + await Encoding.write(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), fileUpdate.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.mkdir + fs.writeFile await fs.unlink(hunk.path) modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) } else { // Regular update - await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write replaces fs.writeFile + await Encoding.write(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), fileUpdate.encoding) // kilocode_change - encoding-aware write replaces fs.writeFile modified.push(hunk.path) log.info(`Updated file: ${hunk.path}`) } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index f8bde8a8485..1fa06397799 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -433,8 +433,18 @@ function expand(pattern: string): string { } export function fromConfig(permission: ConfigPermission.Info) { + // Sort top-level keys so wildcard permissions (`*`, `mcp_*`) come before + // specific ones. Combined with `findLast` in evaluate(), this gives the + // intuitive semantic "specific tool rules override the `*` fallback" + // regardless of the user's JSON key order. Sub-pattern order inside a + // single permission key is preserved — only top-level keys are sorted. + const entries = Object.entries(permission).sort(([a], [b]) => { + const aWild = a.includes("*") + const bWild = b.includes("*") + return aWild === bWild ? 0 : aWild ? -1 : 1 + }) const ruleset: Ruleset = [] - for (const [key, value] of Object.entries(permission)) { + for (const [key, value] of entries) { if (typeof value === "string") { ruleset.push({ permission: key, action: value, pattern: "*" }) continue @@ -459,7 +469,7 @@ export function merge(...rulesets: Ruleset[]): Ruleset { return rulesets.flat() } -const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] +const EDIT_TOOLS = ["edit", "write", "apply_patch"] export function disabled(tools: string[], ruleset: Ruleset): Set { const result = new Set() diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 6ac9389a583..4eddc6a47ad 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,8 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { Newtype } from "@/util/schema" export class PermissionID extends Newtype()( @@ -13,5 +12,5 @@ export class PermissionID extends Newtype()( return this.make(Identifier.ascending("permission", id)) } - static readonly zod = Identifier.schema("permission") as unknown as z.ZodType + static readonly zod = zod(this) } diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 731eb85bdbb..c3e2b70fb81 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -385,6 +385,8 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue if (allowedModels.has(model.api.id)) continue + const match = model.api.id.match(/^gpt-(\d+\.\d+)/) + if (match && parseFloat(match[1]) > 5.4) continue delete provider.models[modelId] } diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts index efbc400b5ee..2d486114a36 100644 --- a/packages/opencode/src/project/project.sql.ts +++ b/packages/opencode/src/project/project.sql.ts @@ -8,6 +8,7 @@ export const ProjectTable = sqliteTable("project", { vcs: text(), name: text(), icon_url: text(), + icon_url_override: text(), icon_color: text(), ...Timestamps, time_initialized: integer(), diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 3ebf7919254..23e8a32f8ff 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -61,7 +61,13 @@ type Row = typeof ProjectTable.$inferSelect export function fromRow(row: Row): Info { const icon = - row.icon_url || row.icon_color ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined + row.icon_url || row.icon_url_override || row.icon_color + ? { + url: row.icon_url ?? undefined, + override: row.icon_url_override ?? undefined, + color: row.icon_color ?? undefined, + } + : undefined return { id: row.id, worktree: row.worktree, @@ -160,8 +166,8 @@ export const layer: Layer.Layer< const scope = yield* Scope.Scope const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { - // kilocode change start - return yield* fs.readFileString(pathSvc.join(dir, "kilo")).pipe( + // kilocode change start + return yield* fs.readFileString(pathSvc.join(dir, "kilo")).pipe( // kilocode change end Effect.map((x) => x.trim()), Effect.map(ProjectID.make), @@ -210,13 +216,13 @@ export const layer: Layer.Layer< vcs: fakeVcs, } } - const worktree = (() => { - const common = resolveGitPath(sandbox, commonDir.text.trim()) - return common === sandbox ? sandbox : pathSvc.dirname(common) - })() + const common = resolveGitPath(sandbox, commonDir.text.trim()) + const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox }) + const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true" + const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common) if (id == null) { - id = yield* readCachedProjectId(pathSvc.join(worktree, ".git")) + id = yield* readCachedProjectId(common) } if (!id) { @@ -229,9 +235,7 @@ export const layer: Layer.Layer< id = roots[0] ? ProjectID.make(roots[0]) : undefined if (id) { - // kilocode_change start - yield* fs.writeFileString(pathSvc.join(worktree, ".git", "kilo"), id).pipe(Effect.ignore) - // kilocode_change end + yield* fs.writeFileString(pathSvc.join(common, "kilo"), id).pipe(Effect.ignore) // kilocode_change } } @@ -294,6 +298,7 @@ export const layer: Layer.Layer< vcs: result.vcs ?? null, name: result.name, icon_url: result.icon?.url, + icon_url_override: result.icon?.override, icon_color: result.icon?.color, time_created: result.time.created, time_updated: result.time.updated, @@ -308,6 +313,7 @@ export const layer: Layer.Layer< vcs: result.vcs ?? null, name: result.name, icon_url: result.icon?.url, + icon_url_override: result.icon?.override, icon_color: result.icon?.color, time_updated: result.time.updated, time_initialized: result.time.initialized, @@ -370,6 +376,7 @@ export const layer: Layer.Layer< .set({ name: input.name, icon_url: input.icon?.url, + icon_url_override: input.icon?.override, icon_color: input.icon?.color, commands: input.commands, time_updated: Date.now(), @@ -496,4 +503,4 @@ export function setInitialized(id: ProjectID) { const { runPromise } = makeRuntime(Service, defaultLayer) export const fromDirectory = (directory: string) => runPromise((svc) => svc.fromDirectory(directory)) export const sandboxes = (id: ProjectID) => runPromise((svc) => svc.sandboxes(id)) -// kilocode_change end \ No newline at end of file +// kilocode_change end diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index d10c82e2c3d..7708b8de1ee 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import z from "zod" +import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) @@ -10,6 +10,6 @@ export type ProjectID = typeof projectIdSchema.Type export const ProjectID = projectIdSchema.pipe( withStatics((schema: typeof projectIdSchema) => ({ global: schema.make("global"), - zod: z.string().pipe(z.custom()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 060c55055df..d858c1bfe37 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -20,6 +20,7 @@ import { zod } from "@/util/effect-zod" import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" +import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema, Types } from "effect" import { EffectBridge } from "@/effect" import { InstanceState } from "@/effect" @@ -425,6 +426,16 @@ function custom(dep: CustomDep): Record { }, }, }), + nvidia: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + }), vercel: () => Effect.succeed({ autoload: false, @@ -1529,7 +1540,10 @@ const layer: Layer.Layer< installedPath = model.api.npm } - const mod = await import(installedPath) + // `installedPath` is a local entry path or an existing `file://` URL. Normalize + // only path inputs so Node on Windows accepts the dynamic import. + const importSpec = installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href + const mod = await import(importSpec) const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] const loaded = fn({ diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index cb664820a12..aa75b0c2198 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import z from "zod" +import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) @@ -9,7 +9,7 @@ export type ProviderID = typeof providerIdSchema.Type export const ProviderID = providerIdSchema.pipe( withStatics((schema: typeof providerIdSchema) => ({ - zod: z.string().pipe(z.custom()), + zod: zod(schema), // Well-known providers kilo: schema.make("kilo"), // kilocode_change opencode: schema.make("opencode"), @@ -31,7 +31,7 @@ const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID")) export type ModelID = typeof modelIdSchema.Type export const ModelID = modelIdSchema.pipe( - withStatics((_schema: typeof modelIdSchema) => ({ - zod: z.string().pipe(z.custom()), + withStatics((schema: typeof modelIdSchema) => ({ + zod: zod(schema), })), ) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f3dee7e6ec4..4a394949521 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -428,10 +428,9 @@ export function variants(model: Provider.Model): Record ({ ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)), - zod: Identifier.schema("pty").pipe(z.custom()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 41186161d00..f7a0e096a3c 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,8 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { Newtype } from "@/util/schema" export class QuestionID extends Newtype()( @@ -13,5 +12,5 @@ export class QuestionID extends Newtype()( return this.make(Identifier.ascending("question", id)) } - static readonly zod = Identifier.schema("question") as unknown as z.ZodType + static readonly zod = zod(this) } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 2d7268b7174..d10f2cd522f 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -160,7 +160,7 @@ export const GlobalRoutes = lazy(() => description: "Get global config info", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, @@ -181,14 +181,14 @@ export const GlobalRoutes = lazy(() => description: "Successfully updated global config", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, ...errors(400), }, }), - validator("json", Config.Info), + validator("json", Config.Info.zod), async (c) => { const config = c.req.valid("json") const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index e75c1d44cbb..3bc3066a278 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -26,7 +26,7 @@ export const ConfigRoutes = lazy(() => description: "Get config info", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, @@ -49,14 +49,14 @@ export const ConfigRoutes = lazy(() => description: "Successfully updated config", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, ...errors(400), }, }), - validator("json", Config.Info), + validator("json", Config.Info.zod), async (c) => jsonRequest("ConfigRoutes.update", c, function* () { const config = c.req.valid("json") diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index 47502956524..8c8c1a507b9 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -342,7 +342,7 @@ export const ExperimentalRoutes = lazy(() => description: "File diffs", content: { "application/json": { - schema: resolver(z.array(Snapshot.FileDiff)), + schema: resolver(z.array(Snapshot.FileDiff.zod)), }, }, }, @@ -387,7 +387,7 @@ export const ExperimentalRoutes = lazy(() => description: "Diff summary items", content: { "application/json": { - schema: resolver(z.array(WorktreeDiff.Item)), + schema: resolver(z.array(WorktreeDiff.Item.zod)), }, }, }, @@ -420,7 +420,7 @@ export const ExperimentalRoutes = lazy(() => description: "Diff detail item", content: { "application/json": { - schema: resolver(WorktreeDiff.Item.nullable()), + schema: resolver(WorktreeDiff.Item.zod.nullable()), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts index bbef679a855..f92fe6e7e5f 100644 --- a/packages/opencode/src/server/routes/instance/file.ts +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -90,7 +90,7 @@ export const FileRoutes = lazy(() => description: "Symbols", content: { "application/json": { - schema: resolver(LSP.Symbol.array()), + schema: resolver(LSP.Symbol.zod.array()), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts index 14aa94f9fcf..2dfdec172a5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts @@ -9,6 +9,15 @@ export const ConfigApi = HttpApi.make("config") .add( HttpApiGroup.make("config") .add( + HttpApiEndpoint.get("get", root, { + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "config.get", + summary: "Get configuration", + description: "Retrieve the current OpenCode configuration settings and preferences.", + }), + ), HttpApiEndpoint.get("providers", `${root}/providers`, { success: Provider.ConfigProvidersResult, }).annotateMerge( @@ -36,16 +45,23 @@ export const ConfigApi = HttpApi.make("config") export const configHandlers = Layer.unwrap( Effect.gen(function* () { - const svc = yield* Provider.Service + const providerSvc = yield* Provider.Service + const configSvc = yield* Config.Service + + const get = Effect.fn("ConfigHttpApi.get")(function* () { + return yield* configSvc.get() + }) const providers = Effect.fn("ConfigHttpApi.providers")(function* () { - const providers = yield* svc.list() + const providers = yield* providerSvc.list() return { providers: Object.values(providers), default: Provider.defaultModelIDs(providers), } }) - return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers)) + return HttpApiBuilder.group(ConfigApi, "config", (handlers) => + handlers.handle("get", get).handle("providers", providers), + ) }), ).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 49a1fa5619b..8c4380d0668 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -41,6 +41,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) app.get("/permission", (c) => handler(c.req.raw, context)) app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config", (c) => handler(c.req.raw, context)) app.get("/config/providers", (c) => handler(c.req.raw, context)) app.get("/provider", (c) => handler(c.req.raw, context)) app.get("/provider/auth", (c) => handler(c.req.raw, context)) @@ -261,7 +262,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { description: "LSP server status", content: { "application/json": { - schema: resolver(LSP.Status.array()), + schema: resolver(LSP.Status.zod.array()), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 8da42c310e5..7404a5f4b08 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -471,7 +471,7 @@ export const SessionRoutes = lazy(() => description: "Successfully retrieved diff", content: { "application/json": { - schema: resolver(Snapshot.FileDiff.array()), + schema: resolver(Snapshot.FileDiff.zod.array()), }, }, }, @@ -611,7 +611,7 @@ export const SessionRoutes = lazy(() => description: "List of messages", content: { "application/json": { - schema: resolver(MessageV2.WithParts.array()), + schema: resolver(MessageV2.WithParts.zod.array()), }, }, }, @@ -701,8 +701,8 @@ export const SessionRoutes = lazy(() => "application/json": { schema: resolver( z.object({ - info: MessageV2.Info, - parts: MessageV2.Part.array(), + info: MessageV2.Info.zod, + parts: MessageV2.Part.zod.array(), }), ), }, @@ -813,7 +813,7 @@ export const SessionRoutes = lazy(() => description: "Successfully updated part", content: { "application/json": { - schema: resolver(MessageV2.Part), + schema: resolver(MessageV2.Part.zod), }, }, }, @@ -828,7 +828,7 @@ export const SessionRoutes = lazy(() => partID: PartID.zod, }), ), - validator("json", MessageV2.Part), + validator("json", MessageV2.Part.zod), async (c) => { const params = c.req.valid("param") const body = c.req.valid("json") @@ -856,8 +856,8 @@ export const SessionRoutes = lazy(() => "application/json": { schema: resolver( z.object({ - info: MessageV2.Assistant, - parts: MessageV2.Part.array(), + info: MessageV2.Assistant.zod, + parts: MessageV2.Part.zod.array(), }), ), }, @@ -882,7 +882,9 @@ export const SessionRoutes = lazy(() => const msg = await runRequest( "SessionRoutes.prompt", c, - SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), + SessionPrompt.Service.use((svc) => + svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), + ), ) void stream.write(JSON.stringify(msg)) }) @@ -915,7 +917,9 @@ export const SessionRoutes = lazy(() => void runRequest( "SessionRoutes.prompt_async", c, - SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), + SessionPrompt.Service.use((svc) => + svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), + ), ).catch((err) => { log.error("prompt_async failed", { sessionID, error: err }) void Bus.publish(Session.Event.Error, { @@ -940,8 +944,8 @@ export const SessionRoutes = lazy(() => "application/json": { schema: resolver( z.object({ - info: MessageV2.Assistant, - parts: MessageV2.Part.array(), + info: MessageV2.Assistant.zod, + parts: MessageV2.Part.zod.array(), }), ), }, @@ -976,7 +980,7 @@ export const SessionRoutes = lazy(() => description: "Created message", content: { "application/json": { - schema: resolver(MessageV2.WithParts), + schema: resolver(MessageV2.WithParts.zod), }, }, }, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 212f5fdbab8..defdb870d7d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -15,7 +15,9 @@ import { NotFoundError } from "@/storage" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect" -import { isOverflow as overflow } from "./overflow" +import { isOverflow as overflow, usable } from "./overflow" +import { makeRuntime } from "@/effect/run-service" +import { fn } from "@/util/fn" const log = Log.create({ service: "session.compaction" }) @@ -30,7 +32,154 @@ export const Event = { export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 +const TOOL_OUTPUT_MAX_CHARS = 2_000 const PRUNE_PROTECTED_TOOLS = ["skill"] +const DEFAULT_TAIL_TURNS = 2 +const MIN_PRESERVE_RECENT_TOKENS = 2_000 +const MAX_PRESERVE_RECENT_TOKENS = 8_000 +const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged: +--- +## Goal +- [single-sentence task summary] + +## Constraints & Preferences +- [user constraints, preferences, specs, or "(none)"] + +## Progress +### Done +- [completed work or "(none)"] + +### In Progress +- [current work or "(none)"] + +### Blocked +- [blockers or "(none)"] + +## Key Decisions +- [decision and why, or "(none)"] + +## Next Steps +- [ordered next actions or "(none)"] + +## Critical Context +- [important technical facts, errors, open questions, or "(none)"] + +## Relevant Files +- [file or directory path: why it matters, or "(none)"] +--- + +Rules: +- Keep every section, even when empty. +- Use terse bullets, not prose paragraphs. +- Preserve exact file paths, commands, error strings, and identifiers when known. +- Do not mention the summary process or that context was compacted.` +type Turn = { + start: number + end: number + id: MessageID +} + +type Tail = { + start: number + id: MessageID +} + +type CompletedCompaction = { + userIndex: number + assistantIndex: number + summary: string | undefined +} + +function summaryText(message: MessageV2.WithParts) { + const text = message.parts + .filter((part): part is MessageV2.TextPart => part.type === "text") + .map((part) => part.text.trim()) + .filter(Boolean) + .join("\n\n") + .trim() + return text || undefined +} + +function completedCompactions(messages: MessageV2.WithParts[]) { + const users = new Map() + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (msg.info.role !== "user") continue + if (!msg.parts.some((part) => part.type === "compaction")) continue + users.set(msg.info.id, i) + } + + return messages.flatMap((msg, assistantIndex): CompletedCompaction[] => { + if (msg.info.role !== "assistant") return [] + if (!msg.info.summary || !msg.info.finish || msg.info.error) return [] + const userIndex = users.get(msg.info.parentID) + if (userIndex === undefined) return [] + return [{ userIndex, assistantIndex, summary: summaryText(msg) }] + }) +} + +function buildPrompt(input: { previousSummary?: string; context: string[] }) { + const anchor = input.previousSummary + ? [ + "Update the anchored summary below using the conversation history above.", + "Preserve still-true details, remove stale details, and merge in the new facts.", + "", + input.previousSummary, + "", + ].join("\n") + : "Create a new anchored summary from the conversation history above." + return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") +} + +function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { + return ( + input.cfg.compaction?.preserve_recent_tokens ?? + Math.min(MAX_PRESERVE_RECENT_TOKENS, Math.max(MIN_PRESERVE_RECENT_TOKENS, Math.floor(usable(input) * 0.25))) + ) +} + +function turns(messages: MessageV2.WithParts[]) { + const result: Turn[] = [] + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (msg.info.role !== "user") continue + if (msg.parts.some((part) => part.type === "compaction")) continue + result.push({ + start: i, + end: messages.length, + id: msg.info.id, + }) + } + for (let i = 0; i < result.length - 1; i++) { + result[i].end = result[i + 1].start + } + return result +} + +function splitTurn(input: { + messages: MessageV2.WithParts[] + turn: Turn + model: Provider.Model + budget: number + estimate: (input: { messages: MessageV2.WithParts[]; model: Provider.Model }) => Effect.Effect +}) { + return Effect.gen(function* () { + if (input.budget <= 0) return undefined + if (input.turn.end - input.turn.start <= 1) return undefined + for (let start = input.turn.start + 1; start < input.turn.end; start++) { + const size = yield* input.estimate({ + messages: input.messages.slice(start, input.turn.end), + model: input.model, + }) + if (size > input.budget) continue + return { + start, + id: input.messages[start]!.info.id, + } satisfies Tail + } + return undefined + }) +} export interface Interface { readonly isOverflow: (input: { @@ -84,11 +233,70 @@ export const layer: Layer.Layer< return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model }) }) + const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: { + messages: MessageV2.WithParts[] + model: Provider.Model + }) { + const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model) + return Token.estimate(JSON.stringify(msgs)) + }) + + const select = Effect.fn("SessionCompaction.select")(function* (input: { + messages: MessageV2.WithParts[] + cfg: Config.Info + model: Provider.Model + }) { + const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS + if (limit <= 0) return { head: input.messages, tail_start_id: undefined } + const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) + const all = turns(input.messages) + if (!all.length) return { head: input.messages, tail_start_id: undefined } + const recent = all.slice(-limit) + const sizes = yield* Effect.forEach( + recent, + (turn) => + estimate({ + messages: input.messages.slice(turn.start, turn.end), + model: input.model, + }), + { concurrency: 1 }, + ) + + let total = 0 + let keep: Tail | undefined + for (let i = recent.length - 1; i >= 0; i--) { + const turn = recent[i]! + const size = sizes[i] + if (total + size <= budget) { + total += size + keep = { start: turn.start, id: turn.id } + continue + } + const remaining = budget - total + const split = yield* splitTurn({ + messages: input.messages, + turn, + model: input.model, + budget: remaining, + estimate, + }) + if (split) keep = split + else if (!keep) log.info("tail fallback", { budget, size, total }) + break + } + + if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined } + return { + head: input.messages.slice(0, keep.start), + tail_start_id: keep.id, + } + }) + // goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool // calls, then erases output of older tool calls to free context space const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) { const cfg = yield* config.get() - if (cfg.compaction?.prune === false) return + if (!cfg.compaction?.prune) return log.info("pruning") const msgs = yield* session @@ -108,17 +316,15 @@ export const layer: Layer.Layer< if (msg.info.role === "assistant" && msg.info.summary) break loop for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { const part = msg.parts[partIndex] - if (part.type === "tool") - if (part.state.status === "completed") { - if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue - if (part.state.time.compacted) break loop - const estimate = Token.estimate(part.state.output) - total += estimate - if (total > PRUNE_PROTECT) { - pruned += estimate - toPrune.push(part) - } - } + if (part.type !== "tool") continue + if (part.state.status !== "completed") continue + if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue + if (part.state.time.compacted) break loop + const estimate = Token.estimate(part.state.output) + total += estimate + if (total <= PRUNE_PROTECT) continue + pruned += estimate + toPrune.push(part) } } @@ -146,6 +352,7 @@ export const layer: Layer.Layer< throw new Error(`Compaction parent must be a user message: ${input.parentID}`) } const userMessage = parent.info + const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction") let messages = input.messages let replay: @@ -176,46 +383,29 @@ export const layer: Layer.Layer< const model = agent.model ? yield* provider.getModel(agent.model.providerID, agent.model.modelID) : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID) + const cfg = yield* config.get() + const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages + const prior = completedCompactions(history) + const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) + const previousSummary = prior.at(-1)?.summary + const selected = yield* select({ + messages: history.filter((_, index) => !hidden.has(index)), + cfg, + model, + }) // Allow plugins to inject context or replace compaction prompt. const compacting = yield* plugin.trigger( "experimental.session.compacting", { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const defaultPrompt = `Provide a detailed prompt for continuing our conversation above. -Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next. -The summary that you construct will be used so that another agent can read it and continue the work. -Do not call any tools. Respond only with the summary text. -Respond in the same language as the user's messages in the conversation. - -When constructing the summary, try to stick to this template: ---- -## Goal - -[What goal(s) is the user trying to accomplish?] - -## Instructions - -- [What important instructions did the user give you that are relevant] -- [If there is a plan or spec, include information about it so next agent can continue using it] - -## Discoveries - -[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work] - -## Accomplished - -[What work has been completed, what work is still in progress, and what work is left?] - -## Relevant files / directories - -[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.] ----` - - const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") - const msgs = structuredClone(messages) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) + const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true }) + const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + }) const ctx = yield* InstanceState.context const msg: MessageV2.Assistant = { id: MessageID.ascending(), @@ -259,7 +449,7 @@ When constructing the summary, try to stick to this template: ...modelMessages, { role: "user", - content: [{ type: "text", text: prompt }], + content: [{ type: "text", text: nextPrompt }], }, ], model, @@ -276,6 +466,13 @@ When constructing the summary, try to stick to this template: return "stop" } + if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { + yield* session.updatePart({ + ...compactionPart, + tail_start_id: selected.tail_start_id, + }) + } + if (result === "continue" && input.auto) { if (replay) { const original = replay.info @@ -409,4 +606,25 @@ export const defaultLayer = Layer.suspend(() => ), ) +const { runPromise } = makeRuntime(Service, defaultLayer) + +export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { + return runPromise((svc) => svc.isOverflow(input)) +} + +export async function prune(input: { sessionID: SessionID }) { + return runPromise((svc) => svc.prune(input)) +} + +export const create = fn( + z.object({ + sessionID: SessionID.zod, + agent: z.string(), + model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }), + auto: z.boolean(), + overflow: z.boolean().optional(), + }), + (input) => runPromise((svc) => svc.create(input)), +) + export * as SessionCompaction from "./compaction" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ff12bbda585..0687f166cc8 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -15,8 +15,11 @@ import { isMedia } from "@/util/media" import type { SystemError } from "bun" import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" -import { Effect } from "effect" import { SessionNetwork } from "./network" // kilocode_change +import { Effect, Schema, Types } from "effect" +import { zod, ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" +import { namedSchemaError } from "@/util/named-schema-error" import { EffectLogger } from "@/effect" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ @@ -29,441 +32,560 @@ interface FetchDecompressionError extends Error { export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" export { isMedia } -export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) -export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) -export const StructuredOutputError = NamedError.create( - "StructuredOutputError", - z.object({ - message: z.string(), - retries: z.number(), - }), -) -export const AuthError = NamedError.create( - "ProviderAuthError", - z.object({ - providerID: z.string(), - message: z.string(), - }), -) -export const APIError = NamedError.create( - "APIError", - z.object({ - message: z.string(), - statusCode: z.number().optional(), - isRetryable: z.boolean(), - responseHeaders: z.record(z.string(), z.string()).optional(), - responseBody: z.string().optional(), - metadata: z.record(z.string(), z.string()).optional(), - }), -) +export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) +export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = namedSchemaError("StructuredOutputError", { + message: Schema.String, + retries: Schema.Number, +}) +export const AuthError = namedSchemaError("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) +export const APIError = namedSchemaError("APIError", { + message: Schema.String, + statusCode: Schema.optional(Schema.Number), + isRetryable: Schema.Boolean, + responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), + responseBody: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) export type APIError = z.infer -export const ContextOverflowError = NamedError.create( - "ContextOverflowError", - z.object({ message: z.string(), responseBody: z.string().optional() }), -) - -export const OutputFormatText = z - .object({ - type: z.literal("text"), - }) - .meta({ - ref: "OutputFormatText", - }) - -export const OutputFormatJsonSchema = z - .object({ - type: z.literal("json_schema"), - schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }), - retryCount: z.number().int().min(0).default(2), - }) - .meta({ - ref: "OutputFormatJsonSchema", - }) - -export const Format = z.discriminatedUnion("type", [OutputFormatText, OutputFormatJsonSchema]).meta({ - ref: "OutputFormat", +export const ContextOverflowError = namedSchemaError("ContextOverflowError", { + message: Schema.String, + responseBody: Schema.optional(Schema.String), }) -export type OutputFormat = z.infer -const PartBase = z.object({ - id: PartID.zod, - sessionID: SessionID.zod, - messageID: MessageID.zod, +export class OutputFormatText extends Schema.Class("OutputFormatText")({ + type: Schema.Literal("text"), +}) { + static readonly zod = zod(this) +} + +export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ + type: Schema.Literal("json_schema"), + schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), + retryCount: Schema.Number.check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), +}) { + static readonly zod = zod(this) +} + +const _Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ + discriminator: "type", + identifier: "OutputFormat", }) +export const Format = Object.assign(_Format, { zod: zod(_Format) }) +export type OutputFormat = Schema.Schema.Type -export const SnapshotPart = PartBase.extend({ - type: z.literal("snapshot"), - snapshot: z.string(), -}).meta({ - ref: "SnapshotPart", +const partBase = { + id: PartID, + sessionID: SessionID, + messageID: MessageID, +} + +export const SnapshotPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("snapshot"), + snapshot: Schema.String, }) -export type SnapshotPart = z.infer - -export const PatchPart = PartBase.extend({ - type: z.literal("patch"), - hash: z.string(), - files: z.string().array(), -}).meta({ - ref: "PatchPart", + .annotate({ identifier: "SnapshotPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type SnapshotPart = Types.DeepMutable> + +export const PatchPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("patch"), + hash: Schema.String, + files: Schema.Array(Schema.String), }) -export type PatchPart = z.infer - -export const TextPart = PartBase.extend({ - type: z.literal("text"), - text: z.string(), - synthetic: z.boolean().optional(), - ignored: z.boolean().optional(), - time: z - .object({ - start: z.number(), - end: z.number().optional(), - }) - .optional(), - metadata: z.record(z.string(), z.any()).optional(), -}).meta({ - ref: "TextPart", + .annotate({ identifier: "PatchPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type PatchPart = Types.DeepMutable> + +export const TextPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: Schema.Number, + end: Schema.optional(Schema.Number), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), }) -export type TextPart = z.infer - -export const ReasoningPart = PartBase.extend({ - type: z.literal("reasoning"), - text: z.string(), - metadata: z.record(z.string(), z.any()).optional(), - time: z.object({ - start: z.number(), - end: z.number().optional(), + .annotate({ identifier: "TextPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type TextPart = Types.DeepMutable> + +export const ReasoningPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("reasoning"), + text: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: Schema.Number, + end: Schema.optional(Schema.Number), }), -}).meta({ - ref: "ReasoningPart", -}) -export type ReasoningPart = z.infer - -const FilePartSourceBase = z.object({ - text: z - .object({ - value: z.string(), - start: z.number().int(), - end: z.number().int(), - }) - .meta({ - ref: "FilePartSourceText", - }), }) + .annotate({ identifier: "ReasoningPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ReasoningPart = Types.DeepMutable> + +const filePartSourceBase = { + text: Schema.Struct({ + value: Schema.String, + start: Schema.Number.check(Schema.isInt()), + end: Schema.Number.check(Schema.isInt()), + }).annotate({ identifier: "FilePartSourceText" }), +} -export const FileSource = FilePartSourceBase.extend({ - type: z.literal("file"), - path: z.string(), -}).meta({ - ref: "FileSource", +export const FileSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("file"), + path: Schema.String, }) + .annotate({ identifier: "FileSource" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) -export const SymbolSource = FilePartSourceBase.extend({ - type: z.literal("symbol"), - path: z.string(), +export const SymbolSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("symbol"), + path: Schema.String, range: LSP.Range, - name: z.string(), - kind: z.number().int(), -}).meta({ - ref: "SymbolSource", + name: Schema.String, + kind: Schema.Number.check(Schema.isInt()), }) - -export const ResourceSource = FilePartSourceBase.extend({ - type: z.literal("resource"), - clientName: z.string(), - uri: z.string(), -}).meta({ - ref: "ResourceSource", + .annotate({ identifier: "SymbolSource" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + +export const ResourceSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("resource"), + clientName: Schema.String, + uri: Schema.String, }) + .annotate({ identifier: "ResourceSource" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) -export const FilePartSource = z.discriminatedUnion("type", [FileSource, SymbolSource, ResourceSource]).meta({ - ref: "FilePartSource", +const _FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ + discriminator: "type", + identifier: "FilePartSource", }) - -export const FilePart = PartBase.extend({ - type: z.literal("file"), - mime: z.string(), - filename: z.string().optional(), - url: z.string(), - source: FilePartSource.optional(), -}).meta({ - ref: "FilePart", +export const FilePartSource = Object.assign(_FilePartSource, { zod: zod(_FilePartSource) }) + +export const FilePart = Schema.Struct({ + ...partBase, + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(_FilePartSource), }) -export type FilePart = z.infer - -export const AgentPart = PartBase.extend({ - type: z.literal("agent"), - name: z.string(), - source: z - .object({ - value: z.string(), - start: z.number().int(), - end: z.number().int(), - }) - .optional(), -}).meta({ - ref: "AgentPart", + .annotate({ identifier: "FilePart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FilePart = Types.DeepMutable> + +export const AgentPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: Schema.Number.check(Schema.isInt()), + end: Schema.Number.check(Schema.isInt()), + }), + ), }) -export type AgentPart = z.infer - -export const CompactionPart = PartBase.extend({ - type: z.literal("compaction"), - auto: z.boolean(), - overflow: z.boolean().optional(), -}).meta({ - ref: "CompactionPart", + .annotate({ identifier: "AgentPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type AgentPart = Types.DeepMutable> + +export const CompactionPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("compaction"), + auto: Schema.Boolean, + overflow: Schema.optional(Schema.Boolean), + tail_start_id: Schema.optional(MessageID), }) -export type CompactionPart = z.infer - -export const SubtaskPart = PartBase.extend({ - type: z.literal("subtask"), - prompt: z.string(), - description: z.string(), - agent: z.string(), - model: z - .object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }) - .optional(), - command: z.string().optional(), -}).meta({ - ref: "SubtaskPart", + .annotate({ identifier: "CompactionPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type CompactionPart = Types.DeepMutable> + +export const SubtaskPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + }), + ), + command: Schema.optional(Schema.String), }) -export type SubtaskPart = z.infer - -export const RetryPart = PartBase.extend({ - type: z.literal("retry"), - attempt: z.number(), - error: APIError.Schema, - time: z.object({ - created: z.number(), + .annotate({ identifier: "SubtaskPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type SubtaskPart = Types.DeepMutable> + +export const RetryPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("retry"), + attempt: Schema.Number, + // APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate. + error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }), + time: Schema.Struct({ + created: Schema.Number, }), -}).meta({ - ref: "RetryPart", }) -export type RetryPart = z.infer + .annotate({ identifier: "RetryPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type RetryPart = Omit>, "error"> & { + error: APIError +} -export const StepStartPart = PartBase.extend({ - type: z.literal("step-start"), - snapshot: z.string().optional(), -}).meta({ - ref: "StepStartPart", +export const StepStartPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-start"), + snapshot: Schema.optional(Schema.String), }) -export type StepStartPart = z.infer - -export const StepFinishPart = PartBase.extend({ - type: z.literal("step-finish"), - reason: z.string(), - snapshot: z.string().optional(), - cost: z.number(), - tokens: z.object({ - total: z.number().optional(), - input: z.number(), - output: z.number(), - reasoning: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), + .annotate({ identifier: "StepStartPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type StepStartPart = Types.DeepMutable> + +export const StepFinishPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-finish"), + reason: Schema.String, + snapshot: Schema.optional(Schema.String), + cost: Schema.Number, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Number), + input: Schema.Number, + output: Schema.Number, + reasoning: Schema.Number, + cache: Schema.Struct({ + read: Schema.Number, + write: Schema.Number, }), }), -}).meta({ - ref: "StepFinishPart", }) -export type StepFinishPart = z.infer - -export const ToolStatePending = z - .object({ - status: z.literal("pending"), - input: z.record(z.string(), z.any()), - raw: z.string(), - }) - .meta({ - ref: "ToolStatePending", - }) - -export type ToolStatePending = z.infer - -export const ToolStateRunning = z - .object({ - status: z.literal("running"), - input: z.record(z.string(), z.any()), - title: z.string().optional(), - metadata: z.record(z.string(), z.any()).optional(), - time: z.object({ - start: z.number(), - }), - }) - .meta({ - ref: "ToolStateRunning", - }) -export type ToolStateRunning = z.infer - -export const ToolStateCompleted = z - .object({ - status: z.literal("completed"), - input: z.record(z.string(), z.any()), - output: z.string(), - title: z.string(), - metadata: z.record(z.string(), z.any()), - time: z.object({ - start: z.number(), - end: z.number(), - compacted: z.number().optional(), - }), - attachments: FilePart.array().optional(), - }) - .meta({ - ref: "ToolStateCompleted", - }) -export type ToolStateCompleted = z.infer - -export const ToolStateError = z - .object({ - status: z.literal("error"), - input: z.record(z.string(), z.any()), - error: z.string(), - metadata: z.record(z.string(), z.any()).optional(), - time: z.object({ - start: z.number(), - end: z.number(), - }), - }) - .meta({ - ref: "ToolStateError", - }) -export type ToolStateError = z.infer - -export const ToolState = z - .discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]) - .meta({ - ref: "ToolState", - }) - -export const ToolPart = PartBase.extend({ - type: z.literal("tool"), - callID: z.string(), - tool: z.string(), - state: ToolState, - metadata: z.record(z.string(), z.any()).optional(), -}).meta({ - ref: "ToolPart", + .annotate({ identifier: "StepFinishPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type StepFinishPart = Types.DeepMutable> + +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.Record(Schema.String, Schema.Any), + raw: Schema.String, +}) + .annotate({ identifier: "ToolStatePending" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolStatePending = Types.DeepMutable> + +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Any), + title: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: Schema.Number, + }), +}) + .annotate({ identifier: "ToolStateRunning" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolStateRunning = Types.DeepMutable> + +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Any), + output: Schema.String, + title: Schema.String, + metadata: Schema.Record(Schema.String, Schema.Any), + time: Schema.Struct({ + start: Schema.Number, + end: Schema.Number, + compacted: Schema.optional(Schema.Number), + }), + attachments: Schema.optional(Schema.Array(FilePart)), +}) + .annotate({ identifier: "ToolStateCompleted" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolStateCompleted = Types.DeepMutable> + +function truncateToolOutput(text: string, maxChars?: number) { + if (!maxChars || text.length <= maxChars) return text + const omitted = text.length - maxChars + return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]` +} + +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Any), + error: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: Schema.Number, + end: Schema.Number, + }), }) -export type ToolPart = z.infer + .annotate({ identifier: "ToolStateError" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolStateError = Types.DeepMutable> -const Base = z.object({ - id: MessageID.zod, - sessionID: SessionID.zod, +const _ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).annotate({ + discriminator: "status", + identifier: "ToolState", }) +// Cast the derived zod so downstream z.infer sees the same mutable shape that +// our exported TS types expose (the pre-migration Zod inferences were mutable). +export const ToolState = Object.assign(_ToolState, { + zod: zod(_ToolState) as unknown as z.ZodType< + ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + >, +}) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export const ToolPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("tool"), + callID: Schema.String, + tool: Schema.String, + state: _ToolState, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}) + .annotate({ identifier: "ToolPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolPart = Omit>, "state"> & { + state: ToolState +} + +const messageBase = { + id: MessageID, + sessionID: SessionID, +} -export const User = Base.extend({ - role: z.literal("user"), - time: z.object({ - created: z.number(), +export const User = Schema.Struct({ + ...messageBase, + role: Schema.Literal("user"), + time: Schema.Struct({ + created: Schema.Number, }), - format: Format.optional(), - summary: z - .object({ - title: z.string().optional(), - body: z.string().optional(), - diffs: Snapshot.FileDiff.array(), - }) - .optional(), - agent: z.string(), - model: z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - variant: z.string().optional(), + format: Schema.optional(_Format), + summary: Schema.optional( + Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), + diffs: Schema.Array(Snapshot.FileDiff), + }), + ), + agent: Schema.String, + model: Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + variant: Schema.optional(Schema.String), }), - system: z.string().optional(), - tools: z.record(z.string(), z.boolean()).optional(), + system: Schema.optional(Schema.String), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), // 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(), + 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 -}).meta({ - ref: "UserMessage", }) -export type User = z.infer - -export const Part = z - .discriminatedUnion("type", [ - TextPart, - SubtaskPart, - ReasoningPart, - FilePart, - ToolPart, - StepStartPart, - StepFinishPart, - SnapshotPart, - PatchPart, - AgentPart, - RetryPart, - CompactionPart, - ]) - .meta({ - ref: "Part", - }) -export type Part = z.infer - -export const Assistant = Base.extend({ - role: z.literal("assistant"), - time: z.object({ - created: z.number(), - completed: z.number().optional(), + .annotate({ identifier: "UserMessage" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type User = Types.DeepMutable> + +const _Part = Schema.Union([ + TextPart, + SubtaskPart, + ReasoningPart, + FilePart, + ToolPart, + StepStartPart, + StepFinishPart, + SnapshotPart, + PatchPart, + AgentPart, + RetryPart, + CompactionPart, +]).annotate({ discriminator: "type", identifier: "Part" }) +export const Part = Object.assign(_Part, { + zod: zod(_Part) as unknown as z.ZodType< + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + >, +}) +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +// Errors are still NamedError-based Zod; bridge via ZodOverride so the derived +// Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the +// error classes to Schema.TaggedErrorClass is a separate slice. +const AssistantErrorZod = z.discriminatedUnion("name", [ + AuthError.Schema, + NamedError.Unknown.Schema, + OutputLengthError.Schema, + AbortedError.Schema, + StructuredOutputError.Schema, + ContextOverflowError.Schema, + APIError.Schema, +]) +type AssistantError = z.infer + +// ── Prompt input schemas ───────────────────────────────────────────────────── +// +// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the +// ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may +// omit `id` to let the server allocate one. These Schema-Struct variants +// carry that shape, and `SessionPrompt.PromptInput` just references the +// derived `.zod` (no omit/partial gymnastics needed at the call site). + +export const TextPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: Schema.Number, + end: Schema.optional(Schema.Number), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}) + .annotate({ identifier: "TextPartInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type TextPartInput = Types.DeepMutable> + +export const FilePartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(_FilePartSource), +}) + .annotate({ identifier: "FilePartInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FilePartInput = Types.DeepMutable> + +export const AgentPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: Schema.Number.check(Schema.isInt()), + end: Schema.Number.check(Schema.isInt()), + }), + ), +}) + .annotate({ identifier: "AgentPartInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type AgentPartInput = Types.DeepMutable> + +export const SubtaskPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + }), + ), + command: Schema.optional(Schema.String), +}) + .annotate({ identifier: "SubtaskPartInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type SubtaskPartInput = Types.DeepMutable> + +export const Assistant = Schema.Struct({ + ...messageBase, + role: Schema.Literal("assistant"), + time: Schema.Struct({ + created: Schema.Number, + completed: Schema.optional(Schema.Number), }), - error: z - .discriminatedUnion("name", [ - AuthError.Schema, - NamedError.Unknown.Schema, - OutputLengthError.Schema, - AbortedError.Schema, - StructuredOutputError.Schema, - ContextOverflowError.Schema, - APIError.Schema, - ]) - .optional(), - parentID: MessageID.zod, - modelID: ModelID.zod, - providerID: ProviderID.zod, + error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })), + parentID: MessageID, + modelID: ModelID, + providerID: ProviderID, /** * @deprecated */ - mode: z.string(), - agent: z.string(), - path: z.object({ - cwd: z.string(), - root: z.string(), + mode: Schema.String, + agent: Schema.String, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, }), - summary: z.boolean().optional(), - cost: z.number(), - tokens: z.object({ - total: z.number().optional(), - input: z.number(), - output: z.number(), - reasoning: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), + summary: Schema.optional(Schema.Boolean), + cost: Schema.Number, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Number), + input: Schema.Number, + output: Schema.Number, + reasoning: Schema.Number, + cache: Schema.Struct({ + read: Schema.Number, + write: Schema.Number, }), }), - structured: z.any().optional(), - variant: z.string().optional(), - finish: z.string().optional(), -}).meta({ - ref: "AssistantMessage", + structured: Schema.optional(Schema.Any), + variant: Schema.optional(Schema.String), + finish: Schema.optional(Schema.String), }) -export type Assistant = z.infer + .annotate({ identifier: "AssistantMessage" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Assistant = Omit>, "error"> & { + error?: AssistantError +} -export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({ - ref: "Message", +const _Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) +export const Info = Object.assign(_Info, { + zod: zod(_Info) as unknown as z.ZodType, }) -export type Info = z.infer +export type Info = User | Assistant export const Event = { Updated: SyncEvent.define({ @@ -472,7 +594,7 @@ export const Event = { aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, - info: Info, + info: Info.zod, }), }), Removed: SyncEvent.define({ @@ -490,7 +612,7 @@ export const Event = { aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, - part: Part, + part: Part.zod, time: z.number(), }), }), @@ -516,11 +638,31 @@ export const Event = { }), } -export const WithParts = z.object({ - info: Info, - parts: z.array(Part), +export const WithParts = Schema.Struct({ + info: _Info, + parts: Schema.Array(_Part), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type WithParts = { + info: Info + parts: Part[] +} + +const Cursor = Schema.Struct({ + id: MessageID, + time: Schema.Number, }) -export type WithParts = z.infer +type Cursor = typeof Cursor.Type + +const decodeCursor = Schema.decodeUnknownSync(Cursor) + +export const cursor = { + encode(input: Cursor) { + return Buffer.from(JSON.stringify(input)).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} // kilocode_change start - strip bloated metadata fields from stored parts to prevent multi-MB payloads // This handles both legacy data that was stored with full file contents and keeps the API response lean. @@ -577,21 +719,6 @@ export function stripMessageMetadata(info: Info): Info { } // kilocode_change end -const Cursor = z.object({ - id: MessageID.zod, - time: z.number(), -}) -type Cursor = z.infer - -export const cursor = { - encode(input: Cursor) { - return Buffer.from(JSON.stringify(input)).toString("base64url") - }, - decode(input: string) { - return Cursor.parse(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) - }, -} - // kilocode_change - apply stripping inside helpers so all read paths are covered const info = (row: typeof MessageTable.$inferSelect) => stripMessageMetadata({ @@ -647,7 +774,7 @@ function providerMeta(metadata: Record | undefined) { export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, ) { const result: UIMessage[] = [] const toolNames = new Set() @@ -786,7 +913,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (part.type === "tool") { toolNames.add(part.tool) if (part.state.status === "completed") { - const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output + const outputText = part.state.time.compacted + ? "[Old tool result content cleared]" + : truncateToolOutput(part.state.output, options?.toolOutputMaxChars) const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) // For providers that don't support media in tool results, extract media files @@ -902,7 +1031,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } @@ -993,8 +1122,21 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() + let retain: MessageID | undefined for (const msg of msgs) { result.push(msg) + if (retain) { + if (msg.info.id === retain) break + continue + } + if (msg.info.role === "user" && completed.has(msg.info.id)) { + const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") + if (!part) continue + if (!part.tail_start_id) break + retain = part.tail_start_id + if (msg.info.id === retain) break + continue + } if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) break if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index 6f48a760df8..477b5815b2f 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -5,18 +5,22 @@ import type { MessageV2 } from "./message-v2" const COMPACTION_BUFFER = 20_000 +export function usable(input: { cfg: Config.Info; model: Provider.Model }) { + const context = input.model.limit.context + if (context === 0) return 0 + + const reserved = + input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model)) + return input.model.limit.input + ? Math.max(0, input.model.limit.input - reserved) + : Math.max(0, context - ProviderTransform.maxOutputTokens(input.model)) +} + export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { if (input.cfg.compaction?.auto === false) return false - const context = input.model.limit.context - if (context === 0) return false + if (input.model.limit.context === 0) return false const count = input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write - - const reserved = - input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model)) - const usable = input.model.limit.input - ? input.model.limit.input - reserved - : context - ProviderTransform.maxOutputTokens(input.model) - return count >= usable + return count >= usable(input) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0281aaf2816..83df0c8ed4a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -792,6 +792,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shellName = ( process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) ).toLowerCase() + const cwd = ctx.directory const invocations: Record = { nu: { args: ["-c", input.command] }, fish: { args: ["-c", input.command] }, @@ -800,12 +801,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the "-l", "-c", ` - __oc_cwd=$PWD [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - cd "$__oc_cwd" + cd -- "$1" eval ${JSON.stringify(input.command)} `, + "opencode", + cwd, ], }, bash: { @@ -813,12 +815,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the "-l", "-c", ` - __oc_cwd=$PWD shopt -s expand_aliases [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - cd "$__oc_cwd" + cd -- "$1" eval ${JSON.stringify(input.command)} `, + "opencode", + cwd, ], }, cmd: { args: ["/c", input.command] }, @@ -828,7 +831,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const args = (invocations[shellName] ?? invocations[""]).args - const cwd = ctx.directory const shellEnv = yield* plugin.trigger( "shell.env", { cwd, sessionID: input.sessionID, callID: part.callID }, @@ -1252,7 +1254,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the { message: info, parts }, ) - const parsed = MessageV2.Info.safeParse(info) + const parsed = MessageV2.Info.zod.safeParse(info) if (!parsed.success) { log.error("invalid user message before save", { sessionID: input.sessionID, @@ -1263,7 +1265,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) } parts.forEach((part, index) => { - const p = MessageV2.Part.safeParse(part) + const p = MessageV2.Part.zod.safeParse(part) if (p.success) return log.error("invalid user part before save", { sessionID: input.sessionID, @@ -1831,7 +1833,7 @@ export const PromptInput = z.object({ .record(z.string(), z.boolean()) .optional() .describe("@deprecated tools and permissions have been merged, you can set permissions on the session itself now"), - format: MessageV2.Format.optional(), + format: MessageV2.Format.zod.optional(), system: z.string().optional(), variant: z.string().optional(), // kilocode_change start @@ -1846,50 +1848,25 @@ export const PromptInput = z.object({ // kilocode_change end parts: z.array( z.discriminatedUnion("type", [ - MessageV2.TextPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "TextPartInput", - }), - MessageV2.FilePart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "FilePartInput", - }), - MessageV2.AgentPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "AgentPartInput", - }), - MessageV2.SubtaskPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "SubtaskPartInput", - }), + MessageV2.TextPartInput.zod as unknown as z.ZodObject, + MessageV2.FilePartInput.zod as unknown as z.ZodObject, + MessageV2.AgentPartInput.zod as unknown as z.ZodObject, + MessageV2.SubtaskPartInput.zod as unknown as z.ZodObject, ]), ), }) -export type PromptInput = z.infer +// `z.discriminatedUnion` erases the discriminated members' shapes back to +// `{}` because the derived `.zod` on each input is typed as an opaque +// `z.ZodType`. Restore the precise `parts` type from the exported Schema +// input types so callers see a proper tagged union. +type PartInputUnion = + | MessageV2.TextPartInput + | MessageV2.FilePartInput + | MessageV2.AgentPartInput + | MessageV2.SubtaskPartInput +export type PromptInput = Omit, "parts"> & { + parts: PartInputUnion[] +} export const LoopInput = z.object({ sessionID: SessionID.zod, @@ -1917,14 +1894,19 @@ export const CommandInput = z.object({ arguments: z.string(), command: z.string(), variant: z.string().optional(), + // Inlined (no `.meta({ ref })`) to keep the original SDK output — the + // PromptInput call site below references FilePartInput by ref via the + // Schema export in message-v2.ts. parts: z .array( z.discriminatedUnion("type", [ - MessageV2.FilePart.omit({ - messageID: true, - sessionID: true, - }).partial({ - id: true, + z.object({ + id: PartID.zod.optional(), + type: z.literal("file"), + mime: z.string(), + filename: z.string().optional(), + url: z.string(), + source: MessageV2.FilePartSource.zod.optional(), }), ]), ) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 1448aa9f35c..bd84ae563dd 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -83,8 +83,8 @@ export const layer = Layer.effect( if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) - // kilocode_change start - strip full file contents before persisting to DB - const summaryDiffs = diffs.map((d) => ({ + // kilocode_change start + const summaryDiffs: Snapshot.SummaryFileDiff[] = diffs.map((d) => ({ file: d.file, additions: d.additions, deletions: d.deletions, diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index efed280c98c..487cbcd34a7 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,15 +1,14 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( Schema.brand("SessionID"), withStatics((s) => ({ descending: (id?: string) => s.make(Identifier.descending("session", id)), - zod: Identifier.schema("session").pipe(z.custom>()), + zod: zod(s), })), ) @@ -19,7 +18,7 @@ export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.sche Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), - zod: Identifier.schema("message").pipe(z.custom>()), + zod: zod(s), })), ) @@ -29,7 +28,7 @@ export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema( Schema.brand("PartID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), - zod: Identifier.schema("part").pipe(z.custom>()), + zod: zod(s), })), ) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 011ee5fd25c..b018db64fd5 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -30,11 +30,7 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - // kilocode_change start - lightweight diff type (no file contents) - summary_diffs: text({ mode: "json" }).$type< - { file: string; additions: number; deletions: number; status?: "added" | "deleted" | "modified" }[] - >(), - // kilocode_change end + summary_diffs: text({ mode: "json" }).$type(), // kilocode_change revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), ...Timestamps, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e1e5fa088ae..b57c5660e02 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -130,17 +130,7 @@ export const Info = z additions: z.number(), deletions: z.number(), files: z.number(), - // kilocode_change start - use lightweight diff schema (without before/after file contents) - diffs: z - .object({ - file: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .array() - .optional(), - // kilocode_change end + diffs: Snapshot.SummaryFileDiff.zod.array().optional(), // kilocode_change }) .optional(), share: z @@ -254,14 +244,15 @@ export const Event = { "session.diff", z.object({ sessionID: SessionID.zod, - diff: Snapshot.FileDiff.array(), + diff: Snapshot.FileDiff.zod.array(), }), ), Error: BusEvent.define( "session.error", z.object({ sessionID: SessionID.zod.optional(), - error: MessageV2.Assistant.shape.error, + // 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), }), ), // kilocode_change start @@ -846,7 +837,7 @@ export const messages = fn(MessagesInput, (input) => runPromise((svc) => svc.mes export const children = fn(ChildrenInput, (id) => runPromise((svc) => svc.children(id))) export const remove = fn(RemoveInput, (id) => runPromise((svc) => svc.remove(id))) export async function updateMessage(msg: T): Promise { - MessageV2.Info.parse(msg) + MessageV2.Info.zod.parse(msg) // kilocode_change return runPromise((svc) => svc.updateMessage(msg)) } @@ -860,7 +851,7 @@ export const removePart = fn( ) export async function updatePart(part: T): Promise { - MessageV2.Part.parse(part) + MessageV2.Part.zod.parse(part) // kilocode_change return runPromise((svc) => svc.updatePart(part)) } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 9d961638b54..6ac5b5b633a 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,4 +1,4 @@ -import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect" +import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Struct, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { formatPatch, structuredPatch } from "diff" import path from "path" @@ -13,25 +13,32 @@ import { Global } from "../global" import { Log } from "../util" import { Flag } from "@/flag/flag" // kilocode_change import { DiffFull } from "../kilocode/snapshot/diff-full" // kilocode_change - -export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), +import { withStatics } from "@/util/schema" +import { zod } from "@/util/effect-zod" + +export const Patch = Schema.Struct({ + hash: Schema.String, + files: Schema.mutable(Schema.Array(Schema.String)), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Patch = typeof Patch.Type + +export const FileDiff = Schema.Struct({ + file: Schema.String, + patch: Schema.String, + additions: Schema.Number, + deletions: Schema.Number, + status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) -export type Patch = z.infer - -export const FileDiff = z - .object({ - file: z.string(), - patch: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "SnapshotFileDiff", - }) -export type FileDiff = z.infer + .annotate({ identifier: "SnapshotFileDiff" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FileDiff = typeof FileDiff.Type + +// kilocode_change start - lightweight FileDiff without `patch` for session.summary.diffs (keeps DB payload small) +export const SummaryFileDiff = FileDiff.mapFields(Struct.omit(["patch"])) + .annotate({ identifier: "SnapshotSummaryFileDiff" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type SummaryFileDiff = typeof SummaryFileDiff.Type +// kilocode_change end const log = Log.create({ service: "snapshot" }) const prune = "7.days" diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 12133ce4355..05588db0f67 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -168,6 +168,7 @@ export async function run(db: SQLiteBunDatabase | NodeSQLiteDatabase ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), - zod: Identifier.schema("event").pipe(z.custom>()), + zod: zod(s), })), ) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index d81511c5517..edb74a52c74 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -17,6 +17,7 @@ import { filterDiagnostics } from "./diagnostics" // kilocode_change import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change 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"), @@ -62,6 +63,7 @@ export const ApplyPatchTool = Tool.define( diff: string additions: number deletions: number + bom: boolean encoding: string // kilocode_change - preserved per-file encoding }> = [] @@ -76,11 +78,12 @@ export const ApplyPatchTool = Tool.define( const oldContent = "" const newContent = hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` - const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + const next = Bom.split(newContent) + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, next.text)) let additions = 0 let deletions = 0 - for (const change of diffLines(oldContent, newContent)) { + for (const change of diffLines(oldContent, next.text)) { if (change.added) additions += change.count || 0 if (change.removed) deletions += change.count || 0 } @@ -88,11 +91,12 @@ export const ApplyPatchTool = Tool.define( fileChanges.push({ filePath, oldContent, - newContent, + newContent: next.text, type: "add", diff, additions, deletions, + bom: next.bom, encoding: "utf-8", // kilocode_change - new files default to utf-8 }) @@ -109,14 +113,17 @@ export const ApplyPatchTool = Tool.define( ) } - const oldContent = (yield* EncodedIO.read(filePath)).text // kilocode_change - encoding-aware read + const source = yield* Bom.readFile(afs, filePath) + const oldContent = source.text let newContent = oldContent + let bom = source.bom let encoding: string // kilocode_change - filled in by the patch helper below // Apply the update chunks to get new content try { const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) newContent = fileUpdate.content + bom = fileUpdate.bom encoding = fileUpdate.encoding // kilocode_change } catch (error) { return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) @@ -143,6 +150,7 @@ export const ApplyPatchTool = Tool.define( diff, additions, deletions, + bom, encoding, // kilocode_change }) @@ -151,7 +159,7 @@ export const ApplyPatchTool = Tool.define( } case "delete": { - // kilocode_change start - encoding-aware read + // kilocode_change start - encoding-aware read so non-UTF-8 files decode without corruption const deleteRead = yield* EncodedIO.read(filePath).pipe( Effect.catch((error) => Effect.fail( @@ -162,6 +170,7 @@ export const ApplyPatchTool = Tool.define( ), ) const contentToDelete = deleteRead.text + const source = Bom.split(contentToDelete) // kilocode_change end const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) @@ -175,6 +184,7 @@ export const ApplyPatchTool = Tool.define( diff: deleteDiff, additions: 0, deletions, + bom: source.bom, encoding: deleteRead.encoding, // kilocode_change }) @@ -216,19 +226,19 @@ export const ApplyPatchTool = Tool.define( switch (change.type) { case "add": // Create parent directories (recursive: true is safe on existing/root dirs) - yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs + yield* EncodedIO.write(change.filePath, Bom.join(change.newContent, change.bom), change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs updates.push({ file: change.filePath, event: "add" }) break case "update": - yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change - encoding-aware write replaces afs.writeWithDirs + yield* EncodedIO.write(change.filePath, Bom.join(change.newContent, change.bom), change.encoding) // kilocode_change - encoding-aware write replaces afs.writeWithDirs updates.push({ file: change.filePath, event: "change" }) break case "move": if (change.movePath) { // Create parent directories (recursive: true is safe on existing/root dirs) - yield* EncodedIO.write(change.movePath!, change.newContent, change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs + yield* EncodedIO.write(change.movePath!, Bom.join(change.newContent, change.bom), change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs yield* afs.remove(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) @@ -242,7 +252,9 @@ export const ApplyPatchTool = Tool.define( } if (edited) { - yield* format.file(edited) + if (yield* format.file(edited)) { + yield* Bom.syncFile(afs, edited, change.bom) + } yield* bus.publish(File.Event.Edited, { file: edited }) } } @@ -256,7 +268,7 @@ export const ApplyPatchTool = Tool.define( for (const change of fileChanges) { if (change.type === "delete") continue const target = change.movePath ?? change.filePath - yield* lsp.touchFile(target, true) + yield* lsp.touchFile(target, "document") } const diagnostics = yield* lsp.diagnostics() diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index f7e17553b64..6af481e10ce 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -5,7 +5,7 @@ import z from "zod" import * as path from "path" -import { Effect } from "effect" +import { Effect, Semaphore } from "effect" import * as Tool from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" @@ -18,6 +18,7 @@ import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import * as Bom from "@/util/bom" import { filterDiagnostics } from "./diagnostics" // kilocode_change import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change @@ -57,6 +58,18 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { return text.replaceAll("\n", "\r\n") } +const locks = new Map() + +function lock(filePath: string) { + const resolvedFilePath = AppFileSystem.resolve(filePath) + const hit = locks.get(resolvedFilePath) + if (hit) return hit + + const next = Semaphore.makeUnsafe(1) + locks.set(resolvedFilePath, next) + 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"), @@ -94,16 +107,69 @@ export const EditTool = Tool.define( let contentOld = "" let contentNew = "" let cachedFilediff: Snapshot.FileDiff | undefined // kilocode_change - yield* Effect.gen(function* () { - if (params.oldString === "") { - const existed = yield* afs.existsSafe(filePath) - // kilocode_change start - preserve file encoding on write - const pre = existed ? yield* EncodedIO.read(filePath) : { text: "", encoding: "utf-8" } - contentOld = pre.text - const encoding = pre.encoding + yield* lock(filePath).withPermits(1)( + Effect.gen(function* () { + if (params.oldString === "") { + const existed = yield* afs.existsSafe(filePath) + // kilocode_change start - encoding-aware read; Encoding.read strips UTF-8 BOMs so + // derive the BOM flag from the detected encoding label instead of the decoded text. + const pre = existed ? yield* EncodedIO.read(filePath) : { text: "", encoding: "utf-8" } + const source = { bom: pre.encoding === "utf-8-bom", text: pre.text, encoding: pre.encoding } + // kilocode_change end + const next = Bom.split(params.newString) + const desiredBom = source.bom || next.bom + contentOld = source.text + contentNew = next.text + diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + filediff: cachedFilediff, // kilocode_change + }, + }) + yield* EncodedIO.write(filePath, Bom.join(contentNew, desiredBom), source.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } + yield* bus.publish(File.Event.Edited, { file: filePath }) + yield* bus.publish(FileWatcher.Event.Updated, { + file: filePath, + event: existed ? "change" : "add", + }) + return + } + + const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) throw new Error(`File ${filePath} not found`) + if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) + // kilocode_change start - encoding-aware read; Encoding.read strips UTF-8 BOMs so + // derive the BOM flag from the detected encoding label instead of the decoded text. + const pre = yield* EncodedIO.read(filePath) + const source = { bom: pre.encoding === "utf-8-bom", text: pre.text, encoding: pre.encoding } // kilocode_change end - contentNew = params.newString - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + contentOld = source.text + + const ending = detectLineEnding(contentOld) + const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) + const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending) + + const next = Bom.split(replace(contentOld, old, replacement, params.replaceAll)) + const desiredBom = source.bom || next.bom + contentNew = next.text + + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change yield* ctx.ask({ permission: "edit", @@ -115,68 +181,26 @@ export const EditTool = Tool.define( filediff: cachedFilediff, // kilocode_change }, }) - yield* EncodedIO.write(filePath, params.newString, encoding) // kilocode_change - preserve encoding; replaces afs.writeWithDirs - yield* format.file(filePath) + + yield* EncodedIO.write(filePath, Bom.join(contentNew, desiredBom), source.encoding) // kilocode_change - encoding-aware write replaces afs.writeWithDirs + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filePath, - event: existed ? "change" : "add", + event: "change", }) - return - } - - const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!info) throw new Error(`File ${filePath} not found`) - if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) - // kilocode_change start - preserve file encoding - const pre = yield* EncodedIO.read(filePath) - contentOld = pre.text - const encoding = pre.encoding - // kilocode_change end - - const ending = detectLineEnding(contentOld) - const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) - const next = convertToLineEnding(normalizeLineEndings(params.newString), ending) - - contentNew = replace(contentOld, old, next, params.replaceAll) - - diff = trimDiff( - createTwoFilesPatch( - filePath, - filePath, - normalizeLineEndings(contentOld), - normalizeLineEndings(contentNew), - ), - ) - cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change - yield* ctx.ask({ - permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], - always: ["*"], - metadata: { - filepath: filePath, - diff, - filediff: cachedFilediff, // kilocode_change - }, - }) - - yield* EncodedIO.write(filePath, contentNew, encoding) // kilocode_change - preserve encoding; replaces afs.writeWithDirs - yield* format.file(filePath) - yield* bus.publish(File.Event.Edited, { file: filePath }) - yield* bus.publish(FileWatcher.Event.Updated, { - file: filePath, - event: "change", - }) - contentNew = (yield* EncodedIO.read(filePath)).text // kilocode_change - re-read via encoding-aware helper; replaces afs.readFileString - diff = trimDiff( - createTwoFilesPatch( - filePath, - filePath, - normalizeLineEndings(contentOld), - normalizeLineEndings(contentNew), - ), - ) - }).pipe(Effect.orDie) + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) + }).pipe(Effect.orDie), + ) const filediff: Snapshot.FileDiff = cachedFilediff ?? buildFileDiff(filePath, contentOld, contentNew) // kilocode_change @@ -189,7 +213,7 @@ export const EditTool = Tool.define( }) let output = "Edit applied successfully." - yield* lsp.touchFile(filePath, true) + yield* lsp.touchFile(filePath, "document") const diagnostics = yield* lsp.diagnostics() const normalizedFilePath = AppFileSystem.normalizePath(filePath) const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? []) diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 263bfe81d2f..0a0edc61edd 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -55,7 +55,7 @@ export const LspTool = Tool.define( const available = yield* lsp.hasClients(file) if (!available) throw new Error("No LSP server available for this file type.") - yield* lsp.touchFile(file, true) + yield* lsp.touchFile(file, "document") const result: unknown[] = yield* (() => { switch (args.operation) { diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts deleted file mode 100644 index 004d3c870dd..00000000000 --- a/packages/opencode/src/tool/multiedit.ts +++ /dev/null @@ -1,61 +0,0 @@ -import z from "zod" -import { Effect } from "effect" -import * as Tool from "./tool" -import { EditTool } from "./edit" -import DESCRIPTION from "./multiedit.txt" -import path from "path" -import { Instance } from "../project/instance" - -export const MultiEditTool = Tool.define( - "multiedit", - Effect.gen(function* () { - const editInfo = yield* EditTool - const edit = yield* editInfo.init() - - return { - description: DESCRIPTION, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - edits: z - .array( - z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), - }), - ) - .describe("Array of edit operations to perform sequentially on the file"), - }), - execute: ( - params: { - filePath: string - edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }> - }, - ctx: Tool.Context, - ) => - Effect.gen(function* () { - const results = [] - for (const [, entry] of params.edits.entries()) { - const result = yield* edit.execute( - { - filePath: params.filePath, - oldString: entry.oldString, - newString: entry.newString, - replaceAll: entry.replaceAll, - }, - ctx, - ) - results.push(result) - } - return { - title: path.relative(Instance.worktree, params.filePath), - metadata: { - results: results.map((r) => r.metadata), - }, - output: results.at(-1)!.output, - } - }), - } - }), -) diff --git a/packages/opencode/src/tool/multiedit.txt b/packages/opencode/src/tool/multiedit.txt deleted file mode 100644 index bb4815124d1..00000000000 --- a/packages/opencode/src/tool/multiedit.txt +++ /dev/null @@ -1,41 +0,0 @@ -This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file. - -Before using this tool: - -1. Use the Read tool to understand the file's contents and context -2. Verify the directory path is correct - -To make multiple file edits, provide the following: -1. file_path: The absolute path to the file to modify (must be absolute, not relative) -2. edits: An array of edit operations to perform, where each edit contains: - - oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation) - - newString: The edited text to replace the oldString - - replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false. - -IMPORTANT: -- All edits are applied in sequence, in the order they are provided -- Each edit operates on the result of the previous edit -- All edits must be valid for the operation to succeed - if any edit fails, none will be applied -- This tool is ideal when you need to make several changes to different parts of the same file - -CRITICAL REQUIREMENTS: -1. All edits follow the same requirements as the single Edit tool -2. The edits are atomic - either all succeed or none are applied -3. Plan your edits carefully to avoid conflicts between sequential operations - -WARNING: -- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace) -- The tool will fail if edits.oldString and edits.newString are the same -- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find - -When making edits: -- Ensure all edits result in idiomatic, correct code -- Do not leave the code in a broken state -- Always use absolute file paths (starting with /) -- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. -- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. - -If you want to create a new file, use: -- A new file path, including dir name if needed -- First edit: empty oldString and the new file's contents as newString -- Subsequent edits: normal edit operations on the created content diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 9f664019aaa..a44a9e02700 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -80,7 +80,7 @@ export const ReadTool = Tool.define( }) const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) { - yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) + yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope)) }) const readSample = Effect.fn("ReadTool.readSample")(function* ( diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 58e118e4301..bd8ea084155 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -162,9 +162,9 @@ export const layer: Layer.Layer< if (matches.length) yield* config.waitForDependencies() for (const match of matches) { const namespace = path.basename(match, path.extname(match)) - const mod = yield* Effect.promise( - () => import(process.platform === "win32" ? match : pathToFileURL(match).href), - ) + // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`. + // Import it as `file://` so Node on Windows accepts the dynamic import. + const mod = yield* Effect.promise(() => import(pathToFileURL(match).href)) for (const [id, def] of Object.entries(mod)) { custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index ac41fd1606e..9ce7bece2b0 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,8 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) @@ -12,6 +11,6 @@ export type ToolID = typeof toolIdSchema.Type export const ToolID = toolIdSchema.pipe( withStatics((schema: typeof toolIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)), - zod: Identifier.schema("tool").pipe(z.custom()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 2bfcf850d26..d1d69e40c42 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -16,6 +16,7 @@ import { assertExternalDirectoryEffect } from "./external-directory" import { filterDiagnostics } from "./diagnostics" // kilocode_change import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change +import * as Bom from "@/util/bom" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -41,14 +42,18 @@ export const WriteTool = Tool.define( yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) - // kilocode_change start - preserve file encoding on write + // kilocode_change start - encoding-aware read; Encoding.read strips UTF-8 BOMs so + // derive the BOM flag from the detected encoding label instead of the decoded text. const pre = exists ? yield* EncodedIO.read(filepath) : { text: "", encoding: "utf-8" } - const contentOld = pre.text - const encoding = pre.encoding + const source = { bom: pre.encoding === "utf-8-bom", text: pre.text, encoding: pre.encoding } // kilocode_change end + const next = Bom.split(params.content) + const desiredBom = source.bom || next.bom + const contentOld = source.text + const contentNew = next.text - const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) - const filediff = buildFileDiff(filepath, contentOld, params.content) // kilocode_change + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) + const filediff = buildFileDiff(filepath, contentOld, contentNew) // kilocode_change yield* ctx.ask({ permission: "edit", patterns: [path.relative(Instance.worktree, filepath)], @@ -60,8 +65,10 @@ export const WriteTool = Tool.define( }, }) - yield* EncodedIO.write(filepath, params.content, encoding) // kilocode_change - preserve encoding; replaces fs.writeWithDirs - yield* format.file(filepath) + yield* EncodedIO.write(filepath, Bom.join(contentNew, desiredBom), source.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.writeWithDirs + if (yield* format.file(filepath)) { + yield* Bom.syncFile(fs, filepath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filepath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filepath, @@ -69,7 +76,7 @@ export const WriteTool = Tool.define( }) let output = "Wrote file successfully." - yield* lsp.touchFile(filepath, true) + yield* lsp.touchFile(filepath, "document") const diagnostics = yield* lsp.diagnostics() const normalizedFilepath = AppFileSystem.normalizePath(filepath) let projectDiagnosticsCount = 0 diff --git a/packages/opencode/src/util/bom.ts b/packages/opencode/src/util/bom.ts new file mode 100644 index 00000000000..484228f3d41 --- /dev/null +++ b/packages/opencode/src/util/bom.ts @@ -0,0 +1,31 @@ +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" + +const BOM_CODE = 0xfeff +const BOM = String.fromCharCode(BOM_CODE) + +export function split(text: string) { + if (text.charCodeAt(0) !== BOM_CODE) return { bom: false, text } + return { bom: true, text: text.slice(1) } +} + +export function join(text: string, bom: boolean) { + const stripped = split(text).text + if (!bom) return stripped + return BOM + stripped +} + +export const readFile = Effect.fn("Bom.readFile")(function* (fs: AppFileSystem.Interface, filePath: string) { + return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath))) +}) + +export const syncFile = Effect.fn("Bom.syncFile")(function* ( + fs: AppFileSystem.Interface, + filePath: string, + bom: boolean, +) { + const current = yield* readFile(fs, filePath) + if (current.bom === bom) return current.text + yield* fs.writeWithDirs(filePath, join(current.text, bom)) + return current.text +}) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index bf1caa035b0..f6d2c5e5c04 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -8,43 +8,6 @@ import z from "zod" */ export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") -/** - * Annotation key for a pre-parse transform that runs on the raw input before - * the derived Zod schema validates it. The walker emits - * `z.preprocess(fn, inner)` when this annotation is present. - * - * Models zod's `z.preprocess(fn, schema)` pattern — useful when the schema - * needs to inspect the user's raw input (e.g. to capture insertion order) - * before `Schema.Struct` canonicalises the object. - * - * TODO: This exists to paper over a missing Effect Schema feature. The - * parser canonicalises open struct output (known fields first in - * declaration order, then catchall fields) before any user-defined - * transform sees the value, and there is no pre-parse hook — so the - * user's original property insertion order is gone by the time - * `Schema.decodeTo` or `middlewareDecoding` runs. - * - * That canonicalisation is a reasonable default, but `config/permission.ts` - * encodes rule precedence in the user's JSON key order (`evaluate.ts` - * uses `findLast`, so later entries win), which the canonicalisation - * silently destroys. - * - * The cleanest upstream fix would be either: - * - * 1. A `preserveInputOrder` option on `Schema.Struct` / - * `Schema.StructWithRest` that keeps the input's insertion order in - * the parsed object (opt-in; canonical order stays default). - * 2. A generic pre-parse hook (`Schema.preprocess(schema, fn)` or a - * transformation whose decode receives the raw `unknown`). - * - * Either of those would let us delete `ZodPreprocess` and the - * `__originalKeys` hack. Alternatively, the permission model could move - * to specificity-based precedence (exact keys beat wildcards) or an - * explicit ordered array of rules, which removes the ordering - * dependency at the data-model level. - */ -export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess") - // AST nodes are immutable and frequently shared across schemas (e.g. a single // Schema.Class embedded in multiple parents). Memoizing by node identity // avoids rebuilding equivalent Zod subtrees and keeps derived children stable @@ -85,11 +48,9 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base - const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess] - const out = preprocess ? z.preprocess(preprocess, checked) : checked const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) - const described = desc ? out.describe(desc) : out + const described = desc ? checked.describe(desc) : checked return ref ? described.meta({ ref }) : described } diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index 75fef9fc9a0..fbda2dc50e0 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -26,6 +26,10 @@ export function errorMessage(error: unknown): string { return error.message } + if (isRecord(error) && isRecord(error.data) && typeof error.data.message === "string" && error.data.message) { + return error.data.message + } + const text = String(error) if (text && text !== "[object Object]") return text diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts new file mode 100644 index 00000000000..e144f2f9064 --- /dev/null +++ b/packages/opencode/src/util/named-schema-error.ts @@ -0,0 +1,54 @@ +import { Schema } from "effect" +import z from "zod" +import { zod } from "@/util/effect-zod" + +/** + * Create a Schema-backed NamedError-shaped class. + * + * Drop-in replacement for `NamedError.create(tag, zodShape)` but backed by + * `Schema.Struct` under the hood. The wire shape emitted by the derived + * `.Schema` is still `{ name: tag, data: {...fields} }` so the generated + * OpenAPI/SDK output is byte-identical to the original NamedError schema. + * + * Preserves the existing surface: + * - static `Schema` (Zod schema of the wire shape) + * - static `isInstance(x)` + * - instance `toObject()` returning `{ name, data }` + * - `new X({ ...data }, { cause })` + */ +export function namedSchemaError(tag: Tag, fields: Fields) { + // Wire shape matches the original NamedError output so the SDK stays stable. + const dataSchema = Schema.Struct(fields) + const wire = z + .object({ + name: z.literal(tag), + data: zod(dataSchema), + }) + .meta({ ref: tag }) + + type Data = Schema.Schema.Type + + class NamedSchemaError extends Error { + static readonly Schema = wire + static readonly tag = tag + public static isInstance(input: unknown): input is NamedSchemaError { + return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag + } + + public override readonly name: Tag = tag + public readonly data: Data + + constructor(data: Data, options?: ErrorOptions) { + super(tag, options) + this.data = data + } + + toObject(): { name: Tag; data: Data } { + return { name: tag, data: this.data } + } + } + + Object.defineProperty(NamedSchemaError, "name", { value: tag }) + + return NamedSchemaError +} diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 429ae496098..fe60caf7d0f 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -575,7 +575,7 @@ test("legacy tools config converts to permissions", async () => { }) }) -test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => { +test("legacy tools config maps write/edit/patch to edit permission", async () => { await using tmp = await tmpdir({ config: { agent: { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9b72865e8a7..ba8345aefec 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1494,35 +1494,6 @@ test("migrates legacy patch tool to edit permission", async () => { }) }) -test("migrates legacy multiedit tool to edit permission", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "kilo.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - agent: { - test: { - tools: { - multiedit: false, - }, - }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.agent?.["test"]?.permission).toEqual({ - edit: "deny", - }) - }, - }) -}) - test("migrates mixed legacy tools config", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -1591,11 +1562,19 @@ test("merges legacy tools with existing permission config", async () => { }) }) -// kilocode_change start — isolate from global config to prevent cross-test contamination -// (migrateBashPermission may write permission.bash to a global config file created by other -// test files running in parallel, which mergeDeep then prepends to the project permission keys) -test("permission config preserves key order", async () => { +test("permission config canonicalises known keys first, preserves rest-key insertion order", async () => { + // ConfigPermission.Info is a StructWithRest schema — the decoder reorders + // keys into declaration-order for known permission names (edit, read, + // todowrite, external_directory are declared in `config/permission.ts`), + // followed by rest keys in the user's insertion order. + // + // Rule precedence is NOT affected by this reordering: `Permission.fromConfig` + // sorts wildcards before specifics before iterating. See the + // "fromConfig - specific key beats wildcard regardless of JSON key order" + // test in test/permission/next.test.ts for the behavioural guarantee. // kilocode_change start — isolate from global config to prevent cross-test contamination + // (migrateBashPermission may write permission.bash to a global config file created by other + // test files running in parallel, which mergeDeep then prepends to the project permission keys) await using globalTmp = await tmpdir() const prev = Global.Path.config ;(Global.Path as { config: string }).config = globalTmp.path @@ -1629,12 +1608,15 @@ test("permission config preserves key order", async () => { fn: async () => { const config = await load() expect(Object.keys(config.permission!)).toEqual([ - "*", + // known fields that the user provided, in declaration order from + // config/permission.ts (read, edit, ..., external_directory, todowrite) + "read", "edit", - "write", "external_directory", - "read", "todowrite", + // rest keys (not in the known list), in user's insertion order + "*", + "write", "thoughts_*", "reasoning_model_*", "tools_*", @@ -1649,7 +1631,6 @@ test("permission config preserves key order", async () => { } // kilocode_change end }) -// kilocode_change end // MCP config merging tests @@ -2339,7 +2320,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => { test("parseManagedPlist strips MDM metadata keys", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2367,7 +2348,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => { test("parseManagedPlist parses server settings", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2387,7 +2368,7 @@ test("parseManagedPlist parses server settings", async () => { test("parseManagedPlist parses permission rules", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2417,7 +2398,7 @@ test("parseManagedPlist parses permission rules", async () => { test("parseManagedPlist parses enabled_providers", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2434,7 +2415,7 @@ test("parseManagedPlist parses enabled_providers", async () => { test("parseManagedPlist handles empty config", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })), "test:mobileconfig", diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 36992917ee9..49360d10b01 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -169,7 +169,9 @@ describe("cross-spawn spawner", () => { 'process.stderr.write("stderr\\n", done)', ].join("\n"), ) - const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)]) + const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], { + concurrency: 2, + }) expect(stdout).toBe("stdout") expect(stderr).toBe("stderr") }), diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 21dbc75b954..19d76e386de 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -676,7 +676,8 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("search()", () => { + // kilocode_change - skip on windows: address windows ci failures #9496 + describe.skipIf(process.platform === "win32")("search()", () => { async function setupSearchableRepo() { const tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8") @@ -893,7 +894,8 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("InstanceState isolation", () => { + // kilocode_change - skip on windows: address windows ci failures #9496 + describe.skipIf(process.platform === "win32")("InstanceState isolation", () => { test("two directories get independent file caches", async () => { await using one = await tmpdir({ git: true }) await using two = await tmpdir({ git: true }) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index 2bf4e899bd0..0c5788c7362 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -9,7 +9,8 @@ import { Ripgrep } from "../../src/file/ripgrep" const run = (effect: Effect.Effect) => effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) -describe("file.ripgrep", () => { +// kilocode_change - skip on windows: address windows ci failures #9496 +describe.skipIf(process.platform === "win32")("file.ripgrep", () => { test("defaults to include hidden", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index be62f96f38f..e6818009e1f 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -1,7 +1,23 @@ // Simple JSON-RPC 2.0 LSP-like fake server over stdio -// Implements a minimal LSP handshake and triggers a request upon notification let nextId = 1 +let readBuffer = Buffer.alloc(0) +let lastChange = null +let initializeParams = null +let diagnosticRequestCount = 0 +let registeredCapability = false +const pendingClientRequests = new Map() +let pullConfig = { + delayMs: 0, + registerOn: undefined, + registrations: [], + documentDiagnostics: [], + documentDiagnosticsByIdentifier: {}, + documentDelayMsByIdentifier: {}, + workspaceDiagnostics: [], + workspaceDiagnosticsByIdentifier: {}, + workspaceDelayMsByIdentifier: {}, +} function encode(message) { const json = JSON.stringify(message) @@ -14,29 +30,19 @@ function decodeFrames(buffer) { let idx while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) { const header = buffer.slice(0, idx).toString("utf8") - const m = /Content-Length:\s*(\d+)/i.exec(header) - const len = m ? parseInt(m[1], 10) : 0 + const match = /Content-Length:\s*(\d+)/i.exec(header) + const length = match ? parseInt(match[1], 10) : 0 const bodyStart = idx + 4 - const bodyEnd = bodyStart + len + const bodyEnd = bodyStart + length if (buffer.length < bodyEnd) break - const body = buffer.slice(bodyStart, bodyEnd).toString("utf8") - results.push(body) + results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8")) buffer = buffer.slice(bodyEnd) } return { messages: results, rest: buffer } } -let readBuffer = Buffer.alloc(0) - -process.stdin.on("data", (chunk) => { - readBuffer = Buffer.concat([readBuffer, chunk]) - const { messages, rest } = decodeFrames(readBuffer) - readBuffer = rest - for (const m of messages) handle(m) -}) - -function send(msg) { - process.stdout.write(encode(msg)) +function send(message) { + process.stdout.write(encode(message)) } function sendRequest(method, params) { @@ -45,6 +51,50 @@ function sendRequest(method, params) { return id } +function sendResponse(id, result) { + send({ jsonrpc: "2.0", id, result }) +} + +function sendNotification(method, params) { + send({ jsonrpc: "2.0", method, params }) +} + +function maybeRegister(method) { + if (pullConfig.registerOn !== method || registeredCapability) return + registeredCapability = true + sendRequest("client/registerCapability", { + registrations: pullConfig.registrations.map((registration, index) => ({ + id: registration.id ?? `pull-${index}`, + method: registration.method ?? "textDocument/diagnostic", + registerOptions: registration.registerOptions ?? registration, + })), + }) +} + +function delayed(id, result, delayMs = pullConfig.delayMs) { + if (!delayMs) { + sendResponse(id, result) + return + } + setTimeout(() => sendResponse(id, result), delayMs) +} + +function diagnosticsForIdentifier(identifier) { + return pullConfig.documentDiagnosticsByIdentifier[identifier] ?? pullConfig.documentDiagnostics +} + +function workspaceDiagnosticsForIdentifier(identifier) { + return pullConfig.workspaceDiagnosticsByIdentifier[identifier] ?? pullConfig.workspaceDiagnostics +} + +function documentDelayForIdentifier(identifier) { + return pullConfig.documentDelayMsByIdentifier[identifier] ?? pullConfig.delayMs +} + +function workspaceDelayForIdentifier(identifier) { + return pullConfig.workspaceDelayMsByIdentifier[identifier] ?? pullConfig.delayMs +} + function handle(raw) { let data try { @@ -52,24 +102,148 @@ function handle(raw) { } catch { return } + + if (typeof data.method === "undefined" && typeof data.id !== "undefined") { + const pending = pendingClientRequests.get(data.id) + if (!pending) return + pendingClientRequests.delete(data.id) + sendResponse(pending, data.result ?? null) + return + } + if (data.method === "initialize") { - send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } }) + initializeParams = data.params + sendResponse(data.id, { + capabilities: { + textDocumentSync: { + change: 2, + }, + }, + }) return } - if (data.method === "initialized") { + + if (data.method === "test/get-initialize-params") { + sendResponse(data.id, initializeParams) return } - if (data.method === "workspace/didChangeConfiguration") { + + if (data.method === "test/request-configuration") { + const id = sendRequest("workspace/configuration", data.params) + pendingClientRequests.set(id, data.id) + return + } + + if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") { return } + + if (data.method === "textDocument/didOpen") { + maybeRegister("didOpen") + return + } + + if (data.method === "textDocument/didChange") { + lastChange = data.params + maybeRegister("didChange") + return + } + if (data.method === "test/trigger") { const method = data.params && data.params.method + if (method === "client/registerCapability") { + sendRequest(method, { + registrations: [ + { + id: "test-diagnostic-registration", + method: "textDocument/diagnostic", + registerOptions: { identifier: "syntax" }, + }, + ], + }) + return + } + if (method === "client/unregisterCapability") { + sendRequest(method, { + unregisterations: [{ id: "test-diagnostic-registration", method: "textDocument/diagnostic" }], + }) + return + } if (method) sendRequest(method, {}) return } - if (typeof data.id !== "undefined") { - // Respond OK to any request from client to keep transport flowing - send({ jsonrpc: "2.0", id: data.id, result: null }) + + if (data.method === "test/configure-pull-diagnostics") { + pullConfig = { + delayMs: data.params?.delayMs ?? 0, + registerOn: data.params?.registerOn, + registrations: data.params?.registrations ?? [], + documentDiagnostics: data.params?.documentDiagnostics ?? [], + documentDiagnosticsByIdentifier: data.params?.documentDiagnosticsByIdentifier ?? {}, + documentDelayMsByIdentifier: data.params?.documentDelayMsByIdentifier ?? {}, + workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [], + workspaceDiagnosticsByIdentifier: data.params?.workspaceDiagnosticsByIdentifier ?? {}, + workspaceDelayMsByIdentifier: data.params?.workspaceDelayMsByIdentifier ?? {}, + } + registeredCapability = false + sendResponse(data.id, null) + return + } + + if (data.method === "test/register-configured-pull-diagnostics") { + maybeRegister(undefined) + sendResponse(data.id, null) + return + } + + if (data.method === "test/publish-diagnostics") { + sendNotification("textDocument/publishDiagnostics", data.params) + return + } + + if (data.method === "test/get-last-change") { + sendResponse(data.id, lastChange) return } + + if (data.method === "test/get-diagnostic-request-count") { + sendResponse(data.id, diagnosticRequestCount) + return + } + + if (data.method === "textDocument/diagnostic") { + diagnosticRequestCount += 1 + delayed( + data.id, + { + kind: "full", + items: diagnosticsForIdentifier(data.params?.identifier ?? ""), + }, + documentDelayForIdentifier(data.params?.identifier ?? ""), + ) + return + } + + if (data.method === "workspace/diagnostic") { + diagnosticRequestCount += 1 + delayed( + data.id, + { + items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""), + }, + workspaceDelayForIdentifier(data.params?.identifier ?? ""), + ) + return + } + + if (typeof data.id !== "undefined") { + sendResponse(data.id, null) + } } + +process.stdin.on("data", (chunk) => { + readBuffer = Buffer.concat([readBuffer, chunk]) + const { messages, rest } = decodeFrames(readBuffer) + readBuffer = rest + for (const message of messages) handle(message) +}) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 5530e195b26..2f6f235aa16 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -126,6 +126,24 @@ describe("Format", () => { it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) + it.live("file() returns false when no formatter runs", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const file = `${dir}/test.txt` + yield* Effect.promise(() => Bun.write(file, "x")) + + const formatted = yield* Format.Service.use((fmt) => fmt.file(file)) + expect(formatted).toBe(false) + }), + { + config: { + formatter: false, + }, + }, + ), + ) + it.live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), { @@ -219,7 +237,7 @@ describe("Format", () => { yield* Format.Service.use((fmt) => Effect.gen(function* () { yield* fmt.init() - yield* fmt.file(file) + expect(yield* fmt.file(file)).toBe(true) }), ) @@ -229,11 +247,21 @@ describe("Format", () => { config: { formatter: { first: { - command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')", + "$FILE", + ], extensions: [".seq"], }, second: { - command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')", + "$FILE", + ], extensions: [".seq"], }, }, diff --git a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts index 482235cec5d..eeb128eef56 100644 --- a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts +++ b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts @@ -257,7 +257,7 @@ describe("Ask agent tool disabled checks", () => { }) test("edit tools are disabled", () => { - const tools = ["edit", "write", "patch", "multiedit"] + const tools = ["edit", "write", "patch"] const result = Permission.disabled(tools, ruleset) for (const tool of tools) { expect(result.has(tool)).toBe(true) diff --git a/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts b/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts index f8245f3ea4d..cd07dec7885 100644 --- a/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts +++ b/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts @@ -4,27 +4,27 @@ import { Config } from "../../../src/config" describe("Config.Info experimental.openTelemetry default", () => { test("defaults to true when experimental is set without openTelemetry", () => { - const parsed = Config.Info.parse({ experimental: {} }) + const parsed = Config.Info.zod.parse({ experimental: {} }) expect(parsed.experimental?.openTelemetry).toBe(true) }) test("defaults to true when openTelemetry is explicitly undefined", () => { - const parsed = Config.Info.parse({ experimental: { openTelemetry: undefined } }) + const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: undefined } }) expect(parsed.experimental?.openTelemetry).toBe(true) }) test("respects explicit false", () => { - const parsed = Config.Info.parse({ experimental: { openTelemetry: false } }) + const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: false } }) expect(parsed.experimental?.openTelemetry).toBe(false) }) test("respects explicit true", () => { - const parsed = Config.Info.parse({ experimental: { openTelemetry: true } }) + const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: true } }) expect(parsed.experimental?.openTelemetry).toBe(true) }) test("experimental stays undefined when not set at all", () => { - const parsed = Config.Info.parse({}) + const parsed = Config.Info.zod.parse({}) expect(parsed.experimental).toBeUndefined() }) }) diff --git a/packages/opencode/test/kilocode/custom-provider-delete.test.ts b/packages/opencode/test/kilocode/custom-provider-delete.test.ts index 7b19c8431a3..c6ae0f27720 100644 --- a/packages/opencode/test/kilocode/custom-provider-delete.test.ts +++ b/packages/opencode/test/kilocode/custom-provider-delete.test.ts @@ -14,7 +14,7 @@ import { KilocodeConfig } from "../../src/kilocode/config/config" describe("Config.Info — null sentinels for custom provider deletes", () => { it("accepts a null model value inside a provider", () => { - const parsed = Config.Info.safeParse({ + const parsed = Config.Info.zod.safeParse({ provider: { myprovider: { name: "My Provider", @@ -28,7 +28,7 @@ describe("Config.Info — null sentinels for custom provider deletes", () => { }) it("accepts a null variant value inside a model", () => { - const parsed = Config.Info.safeParse({ + const parsed = Config.Info.zod.safeParse({ provider: { myprovider: { name: "My Provider", diff --git a/packages/opencode/test/kilocode/kilo-ripgrep-stream.test.ts b/packages/opencode/test/kilocode/kilo-ripgrep-stream.test.ts deleted file mode 100644 index 6df605cb9bc..00000000000 --- a/packages/opencode/test/kilocode/kilo-ripgrep-stream.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { KiloRipgrepStream } from "../../src/kilocode/kilo-ripgrep-stream" - -describe("KiloRipgrepStream", () => { - test("drains lines without splitting UTF-8 characters", () => { - const icon = "\u{1f600}" - const bytes = Buffer.from(`src/${icon}.ts\nnext.ts\n`) - const decoder = KiloRipgrepStream.decoder() - const lines: string[] = [] - - const first = KiloRipgrepStream.drain(decoder, "", bytes.subarray(0, 5), (line) => lines.push(line)) - const rest = KiloRipgrepStream.drain(decoder, first, bytes.subarray(5), (line) => lines.push(line)) + decoder.end() - - if (rest) lines.push(rest) - - expect(lines).toEqual([`src/${icon}.ts`, "next.ts"]) - }) -}) diff --git a/packages/opencode/test/kilocode/patch.test.ts b/packages/opencode/test/kilocode/patch.test.ts new file mode 100644 index 00000000000..8dc13ab8713 --- /dev/null +++ b/packages/opencode/test/kilocode/patch.test.ts @@ -0,0 +1,172 @@ +// Tests the kilocode-specific patch module guarantees: +// - Files retain their original encoding after an update (UTF-8 BOM, UTF-16, +// legacy single-byte, CJK). +// - Plain UTF-8 files do not gain a spurious BOM. +// - Moved files keep the original encoding at the new path. +// These round-trip through Patch.applyPatch directly so we exercise the +// encoding + BOM integration in patch/index.ts without the tool stack. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "os" +import iconv from "iconv-lite" +import { Patch } from "../../src/patch" + +const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]) +const UTF16_LE_BOM = Buffer.from([0xff, 0xfe]) + +describe("Patch encoding preservation", () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), "kilo-patch-")) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + test("preserves UTF-8 BOM through update", async () => { + const file = path.join(dir, "doc.txt") + await fs.writeFile(file, Buffer.concat([UTF8_BOM, Buffer.from("line 1\nline 2\n", "utf-8")])) + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + line 1 +-line 2 ++line 2 updated +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(bytes.subarray(0, 3).equals(UTF8_BOM)).toBe(true) + expect(bytes.subarray(3).toString("utf-8")).toBe("line 1\nline 2 updated\n") + }) + + test("does not introduce BOM for plain UTF-8 files", async () => { + const file = path.join(dir, "plain.txt") + await fs.writeFile(file, "line 1\nline 2\n", "utf-8") + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + line 1 +-line 2 ++line 2 updated +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(bytes[0]).not.toBe(0xef) + expect(bytes.toString("utf-8")).toBe("line 1\nline 2 updated\n") + }) + + test("preserves UTF-16 LE encoding through update", async () => { + const file = path.join(dir, "utf16.txt") + await fs.writeFile(file, Buffer.concat([UTF16_LE_BOM, iconv.encode("line 1\nline 2\n", "utf-16le")])) + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + line 1 +-line 2 ++line 2 updated +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(bytes.subarray(0, 2).equals(UTF16_LE_BOM)).toBe(true) + expect(iconv.decode(bytes.subarray(2), "utf-16le")).toBe("line 1\nline 2 updated\n") + }) + + test("preserves iso-8859-1 encoding through update", async () => { + const file = path.join(dir, "latin1.txt") + await fs.writeFile(file, iconv.encode("café\nñandú\n", "iso-8859-1")) + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + café +-ñandú ++águila +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(iconv.decode(bytes, "iso-8859-1")).toBe("café\náguila\n") + // á and ñ are two bytes in UTF-8, one byte in ISO-8859-1. If the file had + // been silently re-encoded as UTF-8 the byte length would differ. + expect(bytes.length).toBe("café\náguila\n".length) + }) + + test("preserves Shift_JIS encoding through update", async () => { + const file = path.join(dir, "jp.txt") + // jschardet needs enough characteristic bytes to identify Shift_JIS. A + // single 19-byte phrase looks like windows-1252, so the sample is padded + // to match the body of Japanese text the tool tests already rely on. + const sample = "こんにちは、世界!日本語のテストです。" + await fs.writeFile(file, iconv.encode(`line1\n${sample}\nline3\n`, "Shift_JIS")) + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + line1 +-${sample} ++さようなら、世界! + line3 +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(iconv.decode(bytes, "Shift_JIS")).toBe("line1\nさようなら、世界!\nline3\n") + const utf8Rendered = Buffer.from("line1\nさようなら、世界!\nline3\n", "utf-8") + expect(bytes.equals(utf8Rendered)).toBe(false) + }) + + test("preserves UTF-8 BOM when file is moved", async () => { + const from = path.join(dir, "old.txt") + const to = path.join(dir, "new.txt") + await fs.writeFile(from, Buffer.concat([UTF8_BOM, Buffer.from("original\n", "utf-8")])) + + const patch = `*** Begin Patch +*** Update File: ${from} +*** Move to: ${to} +@@ +-original ++updated +*** End Patch` + + await Patch.applyPatch(patch) + + const moved = await fs.readFile(to) + expect(moved.subarray(0, 3).equals(UTF8_BOM)).toBe(true) + expect(moved.subarray(3).toString("utf-8")).toBe("updated\n") + + const oldExists = await fs + .access(from) + .then(() => true) + .catch(() => false) + expect(oldExists).toBe(false) + }) + + test("new files added via patch are written as plain UTF-8", async () => { + const file = path.join(dir, "new.txt") + const patch = `*** Begin Patch +*** Add File: ${file} ++hello world +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(bytes[0]).not.toBe(0xef) + expect(bytes.toString("utf-8")).toBe("hello world") + }) +}) diff --git a/packages/opencode/test/kilocode/summary-file-diff.test.ts b/packages/opencode/test/kilocode/summary-file-diff.test.ts new file mode 100644 index 00000000000..c481716553c --- /dev/null +++ b/packages/opencode/test/kilocode/summary-file-diff.test.ts @@ -0,0 +1,28 @@ +import { test, expect } from "bun:test" +import { Snapshot } from "../../src/snapshot" + +test("SummaryFileDiff does not contain the `patch` field", () => { + const keys = Object.keys(Snapshot.SummaryFileDiff.fields) + expect(keys).not.toContain("patch") + expect(keys.sort()).toEqual(["additions", "deletions", "file", "status"]) +}) + +test("SummaryFileDiff parse strips `patch` when present on input", () => { + const full = { + file: "a.txt", + patch: "@@ -1 +1 @@\n-old\n+new\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const parsed = Snapshot.SummaryFileDiff.zod.parse(full) + expect(parsed).not.toHaveProperty("patch") + expect(parsed).toEqual({ file: "a.txt", additions: 1, deletions: 1, status: "modified" }) +}) + +test("SummaryFileDiff differs from FileDiff by exactly `patch`", () => { + const full = new Set(Object.keys(Snapshot.FileDiff.fields)) + const summary = new Set(Object.keys(Snapshot.SummaryFileDiff.fields)) + expect([...full].filter((k) => !summary.has(k))).toEqual(["patch"]) + expect([...summary].filter((k) => !full.has(k))).toEqual([]) +}) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index d6eaa317f94..4862f683949 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,11 +1,12 @@ -import { describe, expect, test, beforeEach } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import path from "path" +import { pathToFileURL } from "url" +import { tmpdir } from "../fixture/fixture" import { LSPClient } from "../../src/lsp" import { LSPServer } from "../../src/lsp" import { Instance } from "../../src/project/instance" import { Log } from "../../src/util" -// Minimal fake LSP server that speaks JSON-RPC over stdio function spawnFakeServer() { const { spawn } = require("child_process") const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") @@ -39,10 +40,8 @@ describe("LSPClient interop", () => { method: "workspace/workspaceFolders", }) - await new Promise((r) => setTimeout(r, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) expect(client.connection).toBeDefined() - await client.shutdown() }) @@ -64,10 +63,8 @@ describe("LSPClient interop", () => { method: "client/registerCapability", }) - await new Promise((r) => setTimeout(r, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) expect(client.connection).toBeDefined() - await client.shutdown() }) @@ -89,10 +86,397 @@ describe("LSPClient interop", () => { method: "client/unregisterCapability", }) - await new Promise((r) => setTimeout(r, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) expect(client.connection).toBeDefined() + await client.shutdown() + }) + + test("initialize does not overclaim unsupported diagnostics capabilities", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + directory: process.cwd(), + }), + }) + + const params = await client.connection.sendRequest("test/get-initialize-params", {}) + expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false) + expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false) await client.shutdown() }) + + test("workspace/configuration returns one result per requested item", async () => { + const handle = spawnFakeServer() as any + const initialization = { + alpha: { + beta: 1, + }, + gamma: true, + } + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: { + ...(handle as unknown as LSPServer.Handle), + initialization, + }, + root: process.cwd(), + directory: process.cwd(), + }), + }) + + const response = await client.connection.sendRequest("test/request-configuration", { + items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}], + }) + + expect(response).toEqual([{ beta: 1 }, 1, null, initialization]) + + await client.shutdown() + }) + + test("sends ranged didChange for incremental sync servers", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "first\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.notify.open({ path: file }) + await Bun.write(file, "second\nthird\n") + await client.notify.open({ path: file }) + + const change = await client.connection.sendRequest<{ + textDocument: { version: number } + contentChanges: { + range?: { start: { line: number; character: number }; end: { line: number; character: number } } + text: string + }[] + }>("test/get-last-change", {}) + expect(change.textDocument.version).toBe(1) + expect(change.contentChanges).toEqual([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + text: "second\nthird\n", + }, + ]) + + await client.shutdown() + }, + }) + }) + + test("document mode falls back to push diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "const x = 1\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + const version = await client.notify.open({ path: file }) + const wait = client.waitForDiagnostics({ path: file, version, mode: "document" }) + await client.connection.sendNotification("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + version, + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "push diagnostic", + severity: 1, + }, + ], + }) + await wait + + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("push diagnostic") + + const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) + expect(count).toBe(0) + + await client.shutdown() + }, + }) + }) + + test("document mode accepts matching push diagnostics published before waiting", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "const x = 1\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + const version = await client.notify.open({ path: file }) + await client.connection.sendNotification("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + version, + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "push diagnostic", + severity: 1, + }, + ], + }) + + for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + + expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic") + + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + expect(Date.now() - started).toBeLessThan(1_000) + + await client.shutdown() + }, + }) + }) + + test("document mode waits for pull diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [{ identifier: "DocumentCompilerSemantic" }], + documentDiagnosticsByIdentifier: { + DocumentCompilerSemantic: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "pull diagnostic", + severity: 1, + }, + ], + }, + }) + + const version = await client.notify.open({ path: file }) + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("pull diagnostic") + + const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) + expect(count).toBeGreaterThan(0) + + await client.shutdown() + }, + }) + }) + + test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registrations: [{ identifier: "fast" }, { identifier: "slow" }], + documentDiagnosticsByIdentifier: { + fast: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "fast diagnostic", + severity: 1, + }, + ], + slow: [], + }, + documentDelayMsByIdentifier: { + slow: 2_500, + }, + }) + + const version = await client.notify.open({ path: file }) + await client.connection.sendRequest("test/register-configured-pull-diagnostics", {}) + await new Promise((resolve) => setTimeout(resolve, 100)) + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + + expect(Date.now() - started).toBeLessThan(1_000) + expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic") + expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1) + + await client.shutdown() + }, + }) + }) + + test("full mode includes workspace pull diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + const related = path.join(tmp.path, "other.cs") + await Bun.write(file, "class C {}\n") + await Bun.write(related, "class D {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [ + { identifier: "DocumentCompilerSemantic" }, + { identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }, + ], + documentDiagnosticsByIdentifier: { + DocumentCompilerSemantic: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "current file", + severity: 1, + }, + ], + }, + workspaceDiagnosticsByIdentifier: { + WorkspaceDocumentsAndProject: [ + { + uri: pathToFileURL(related).href, + items: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "workspace file", + severity: 1, + }, + ], + }, + ], + }, + }) + + const version = await client.notify.open({ path: file }) + await client.waitForDiagnostics({ path: file, version, mode: "full" }) + + expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file") + expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file") + + await client.shutdown() + }, + }) + }) + + test("full mode treats an empty workspace pull response as handled", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }], + workspaceDiagnosticsByIdentifier: { + WorkspaceDocumentsAndProject: [], + }, + }) + + const version = await client.notify.open({ path: file }) + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "full" }) + + expect(Date.now() - started).toBeLessThan(1_000) + + await client.shutdown() + }, + }) + }) }) diff --git a/packages/opencode/test/npm.test.ts b/packages/opencode/test/npm.test.ts index 61e3ca6ddf0..a8ec92c2a7b 100644 --- a/packages/opencode/test/npm.test.ts +++ b/packages/opencode/test/npm.test.ts @@ -1,7 +1,18 @@ +import fs from "fs/promises" +import path from "path" import { describe, expect, test } from "bun:test" import { Npm } from "../src/npm" +import { tmpdir } from "./fixture/fixture" const win = process.platform === "win32" +const writePackage = (dir: string, pkg: Record) => + Bun.write( + path.join(dir, "package.json"), + JSON.stringify({ + version: "1.0.0", + ...pkg, + }), + ) describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { @@ -16,3 +27,29 @@ describe("Npm.sanitize", () => { expect(Npm.sanitize(spec)).toBe(expected) }) }) + +describe("Npm.install", () => { + test("respects omit from project .npmrc", async () => { + await using tmp = await tmpdir() + + await writePackage(tmp.path, { + name: "fixture", + dependencies: { + "prod-pkg": "file:./prod-pkg", + }, + devDependencies: { + "dev-pkg": "file:./dev-pkg", + }, + }) + await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n") + await fs.mkdir(path.join(tmp.path, "prod-pkg")) + await fs.mkdir(path.join(tmp.path, "dev-pkg")) + await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" }) + await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" }) + + await Npm.install(tmp.path) + + await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined() + await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow() + }) +}) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 9eaef2f77f4..ce8faa9cc66 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -142,6 +142,67 @@ test("fromConfig - does not expand tilde in middle of path", () => { expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }]) }) +// Top-level wildcard-vs-specific precedence semantics. +// +// fromConfig sorts top-level keys so wildcard permissions (containing "*") +// come before specific permissions. Combined with `findLast` in evaluate(), +// this gives the intuitive semantic "specific tool rules override the `*` +// fallback", regardless of the order the user wrote the keys in their JSON. +// +// Sub-pattern order inside a single permission key (e.g. `bash: { "*": "allow", "rm": "deny" }`) +// still depends on insertion order — only top-level keys are sorted. + +test("fromConfig - specific key beats wildcard regardless of JSON key order", () => { + const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" }) + const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" }) + + // Both orderings produce the same ruleset + expect(wildcardFirst).toEqual(specificFirst) + + // And both evaluate bash → allow (bash rule wins over * fallback) + expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow") + expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow") +}) + +test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => { + const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" }) + expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow") +}) + +test("fromConfig - top-level ordering: wildcards first, specifics after", () => { + const ruleset = Permission.fromConfig({ + bash: "allow", + "*": "ask", + edit: "deny", + "mcp_*": "allow", + }) + // wildcards (* and mcp_*) come before specifics (bash, edit) + const permissions = ruleset.map((r) => r.permission) + expect(permissions.slice(0, 2).sort()).toEqual(["*", "mcp_*"]) + expect(permissions.slice(2)).toEqual(["bash", "edit"]) +}) + +test("fromConfig - sub-pattern insertion order inside a tool key is preserved (only top-level sorts)", () => { + // Sub-patterns within a single tool key use the documented "`*` first, + // specific patterns after" convention (findLast picks specifics). The + // top-level sort must not touch sub-pattern ordering. + const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } }) + expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"]) + // * fallback for unknown commands + expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny") + // specific pattern wins for git commands (it's last, findLast picks it) + expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow") +}) + +test("fromConfig - canonical documented example unchanged", () => { + // Regression guard for the example in docs/permissions.mdx + const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" }) + expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow") + expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny") + expect(Permission.evaluate("read", "foo.ts", ruleset).action).toBe("ask") +}) + test("fromConfig - expands exact tilde to home directory", () => { const result = Permission.fromConfig({ external_directory: { "~": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) @@ -436,9 +497,9 @@ test("disabled - disables tool when denied", () => { expect(result.has("read")).toBe(false) }) -test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => { +test("disabled - disables edit/write/apply_patch when edit denied", () => { const result = Permission.disabled( - ["edit", "write", "apply_patch", "multiedit", "bash"], + ["edit", "write", "apply_patch", "bash"], [ { permission: "*", pattern: "*", action: "allow" }, { permission: "edit", pattern: "*", action: "deny" }, @@ -447,7 +508,6 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () expect(result.has("edit")).toBe(true) expect(result.has("write")).toBe(true) expect(result.has("apply_patch")).toBe(true) - expect(result.has("multiedit")).toBe(true) expect(result.has("bash")).toBe(false) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index f1ca5664e64..44b66e08b12 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -278,6 +278,31 @@ describe("Project.discover", () => { expect(updated).toBeDefined() expect(updated!.icon).toBeUndefined() }) + + test("should not discover favicon when override is set", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + + await run((svc) => + svc.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,override" }, + }), + ) + + const updatedProject = await run((svc) => svc.get(project.id)) + if (!updatedProject) throw new Error("Project not found") + + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + await Bun.write(path.join(tmp.path, "favicon.png"), pngData) + + await run((svc) => svc.discover(updatedProject)) + + const updated = Project.get(project.id) + expect(updated).toBeDefined() + expect(updated!.icon?.override).toBe("data:image/png;base64,override") + expect(updated!.icon?.url).toBeUndefined() + }) }) describe("Project.update", () => { @@ -332,6 +357,23 @@ describe("Project.update", () => { expect(fromDb?.icon?.color).toBe("#ff0000") }) + test("should update icon override", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + + const updated = await run((svc) => + svc.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,abc123" }, + }), + ) + + expect(updated.icon?.override).toBe("data:image/png;base64,abc123") + + const fromDb = Project.get(project.id) + expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123") + }) + test("should update commands", async () => { await using tmp = await tmpdir({ git: true }) const { project } = await run((svc) => svc.fromDirectory(tmp.path)) @@ -389,13 +431,14 @@ describe("Project.update", () => { svc.update({ projectID: project.id, name: "Multi Update", - icon: { url: "https://example.com/favicon.ico", color: "#00ff00" }, + icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, commands: { start: "make start" }, }), ) expect(updated.name).toBe("Multi Update") expect(updated.icon?.url).toBe("https://example.com/favicon.ico") + expect(updated.icon?.override).toBe("data:image/png;base64,abc123") expect(updated.icon?.color).toBe("#00ff00") expect(updated.commands?.start).toBe("make start") }) @@ -472,3 +515,89 @@ describe("Project.addSandbox and Project.removeSandbox", () => { expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) }) }) + +describe("Project.fromDirectory with bare repos", () => { + test("worktree from bare repo should cache in bare repo, not parent", async () => { + await using tmp = await tmpdir({ git: true }) + + const parentDir = path.dirname(tmp.path) + const barePath = path.join(parentDir, `bare-${Date.now()}.git`) + const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + + try { + await $`git clone --bare ${tmp.path} ${barePath}`.quiet() + await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet() + + const { project } = await run((svc) => svc.fromDirectory(worktreePath)) + + expect(project.id).not.toBe(ProjectID.global) + expect(project.worktree).toBe(barePath) + + const correctCache = path.join(barePath, "kilo") // kilocode_change + const wrongCache = path.join(parentDir, ".git", "kilo") // kilocode_change + + expect(await Bun.file(correctCache).exists()).toBe(true) + expect(await Bun.file(wrongCache).exists()).toBe(false) + } finally { + await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow() + } + }) + + test("different bare repos under same parent should not share project ID", async () => { + await using tmp1 = await tmpdir({ git: true }) + await using tmp2 = await tmpdir({ git: true }) + + const parentDir = path.dirname(tmp1.path) + const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`) + const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`) + const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`) + const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`) + + try { + await $`git clone --bare ${tmp1.path} ${bareA}`.quiet() + await $`git clone --bare ${tmp2.path} ${bareB}`.quiet() + await $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet() + await $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet() + + const { project: projA } = await run((svc) => svc.fromDirectory(worktreeA)) + const { project: projB } = await run((svc) => svc.fromDirectory(worktreeB)) + + expect(projA.id).not.toBe(projB.id) + + // kilocode_change start + const cacheA = path.join(bareA, "kilo") + const cacheB = path.join(bareB, "kilo") + const wrongCache = path.join(parentDir, ".git", "kilo") + // kilocode_change end + + expect(await Bun.file(cacheA).exists()).toBe(true) + expect(await Bun.file(cacheB).exists()).toBe(true) + expect(await Bun.file(wrongCache).exists()).toBe(false) + } finally { + await $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow() + } + }) + + test("bare repo without .git suffix is still detected via core.bare", async () => { + await using tmp = await tmpdir({ git: true }) + + const parentDir = path.dirname(tmp.path) + const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) + const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + + try { + await $`git clone --bare ${tmp.path} ${barePath}`.quiet() + await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet() + + const { project } = await run((svc) => svc.fromDirectory(worktreePath)) + + expect(project.id).not.toBe(ProjectID.global) + expect(project.worktree).toBe(barePath) + + const correctCache = path.join(barePath, "kilo") // kilocode_change + expect(await Bun.file(correctCache).exists()).toBe(true) + } finally { + await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow() + } + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index ccaf1460427..cbd7ba4c519 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2114,7 +2114,39 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) }) - test("mistral returns empty object", () => { + test("mistral with reasoning returns variants", () => { + const model = createMockModel({ + id: "mistral/mistral-small-latest", + providerID: "mistral", + api: { + id: "mistral-small-latest", + url: "https://api.mistral.com", + npm: "@ai-sdk/mistral", + }, + capabilities: { reasoning: true }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + high: { reasoningEffort: "high" }, + }) + }) + + test("mistral without reasoning returns empty object", () => { + const model = createMockModel({ + id: "mistral/mistral-large", + providerID: "mistral", + api: { + id: "mistral-large-latest", + url: "https://api.mistral.com", + npm: "@ai-sdk/mistral", + }, + capabilities: { reasoning: false }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("mistral large with reasoning returns empty object (only small supports reasoning)", () => { const model = createMockModel({ id: "mistral/mistral-large", providerID: "mistral", @@ -2123,6 +2155,7 @@ describe("ProviderTransform.variants", () => { url: "https://api.mistral.com", npm: "@ai-sdk/mistral", }, + capabilities: { reasoning: true }, }) const result = ProviderTransform.variants(model) expect(result).toEqual({}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 423409fc3dc..a0a8c226c70 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -143,6 +143,45 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string return msg } +async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "compaction", + agent: "compaction", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + summary: true, + time: { created: Date.now() }, + finish: "end_turn", + } + await svc.updateMessage(msg) + await svc.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg +} + +async function lastCompactionPart(sessionID: SessionID) { + return (await svc.messages({ sessionID })) + .at(-2) + ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") +} + function fake( input: Parameters[0], result: "continue" | "compact", @@ -167,7 +206,19 @@ function layer(result: "continue" | "compact") { ) } -function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, provider = ProviderTest.fake()) { +function cfg(compaction?: Config.Info["compaction"]) { + const base = Config.Info.zod.parse({}) + return Layer.mock(Config.Service)({ + get: () => Effect.succeed({ ...base, compaction }), + }) +} + +function runtime( + result: "continue" | "compact", + plugin = Plugin.defaultLayer, + provider = ProviderTest.fake(), + config = Config.defaultLayer, +) { const bus = Bus.layer return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer, bus).pipe( @@ -177,7 +228,7 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p Layer.provide(Agent.defaultLayer), Layer.provide(plugin), Layer.provide(bus), - Layer.provide(Config.defaultLayer), + Layer.provide(config), ), ) } @@ -222,7 +273,7 @@ function llm() { } } -function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake()) { +function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) @@ -237,11 +288,66 @@ function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fa Layer.provide(Plugin.defaultLayer), Layer.provide(status), Layer.provide(bus), - Layer.provide(Config.defaultLayer), + Layer.provide(config), ), ) } +function reply( + text: string, + capture?: (input: LLM.StreamInput) => void, +): (input: LLM.StreamInput) => Stream.Stream { + return (input) => { + capture?.(input) + return Stream.make( + { type: "start" } satisfies LLM.Event, + { type: "text-start", id: "txt-0" } satisfies LLM.Event, + { type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event, + { type: "text-end", id: "txt-0" } satisfies LLM.Event, + { + type: "finish-step", + finishReason: "stop", + rawFinishReason: "stop", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + { + type: "finish", + finishReason: "stop", + rawFinishReason: "stop", + totalUsage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + ) + } +} + function wait(ms = 50) { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -498,65 +604,13 @@ describe("session.compaction.create", () => { describe("session.compaction.prune", () => { it.live( "compacts old completed tool output", - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const compact = yield* SessionCompaction.Service - const ssn = yield* SessionNs.Service - const info = yield* ssn.create({}) - const a = yield* ssn.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: info.id, - agent: "build", - model: ref, - time: { created: Date.now() }, - }) - yield* ssn.updatePart({ - id: PartID.ascending(), - messageID: a.id, - sessionID: info.id, - type: "text", - text: "first", - }) - const b: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID: info.id, - mode: "build", - agent: "build", - path: { cwd: dir, root: dir }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID: a.id, - time: { created: Date.now() }, - finish: "end_turn", - } - yield* ssn.updateMessage(b) - yield* ssn.updatePart({ - id: PartID.ascending(), - messageID: b.id, - sessionID: info.id, - type: "tool", - callID: crypto.randomUUID(), - tool: "bash", - state: { - status: "completed", - input: {}, - output: "x".repeat(200_000), - title: "done", - metadata: {}, - time: { start: Date.now(), end: Date.now() }, - }, - }) - for (const text of ["second", "third"]) { - const msg = yield* ssn.updateMessage({ + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const a = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, @@ -566,23 +620,82 @@ describe("session.compaction.prune", () => { }) yield* ssn.updatePart({ id: PartID.ascending(), - messageID: msg.id, + messageID: a.id, sessionID: info.id, type: "text", - text, + text: "first", }) - } + const b: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID: info.id, + mode: "build", + agent: "build", + path: { cwd: dir, root: dir }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID: a.id, + time: { created: Date.now() }, + finish: "end_turn", + } + yield* ssn.updateMessage(b) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: b.id, + sessionID: info.id, + type: "tool", + callID: crypto.randomUUID(), + tool: "bash", + state: { + status: "completed", + input: {}, + output: "x".repeat(200_000), + title: "done", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + for (const text of ["second", "third"]) { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: info.id, + type: "text", + text, + }) + } - yield* compact.prune({ sessionID: info.id }) + yield* compact.prune({ sessionID: info.id }) - const msgs = yield* ssn.messages({ sessionID: info.id }) - const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") - expect(part?.type).toBe("tool") - expect(part?.state.status).toBe("completed") - if (part?.type === "tool" && part.state.status === "completed") { - expect(part.state.time.compacted).toBeNumber() - } - }), + const msgs = yield* ssn.messages({ sessionID: info.id }) + const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") + expect(part?.type).toBe("tool") + expect(part?.state.status).toBe("completed") + if (part?.type === "tool" && part.state.status === "completed") { + expect(part.state.time.compacted).toBeNumber() + } + }), + + { + config: { + compaction: { prune: true }, + }, + }, ), ) @@ -836,6 +949,273 @@ describe("session.compaction.process", () => { }) }) + test("persists tail_start_id for retained recent turns", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "first") + const keep = await user(session.id, "second") + await user(session.id, "third") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = runtime( + "continue", + Plugin.defaultLayer, + wide(), + cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }), + ) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + } finally { + await rt.dispose() + } + }, + }) + }) + + test("shrinks retained tail to fit preserve token budget", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "first") + await user(session.id, "x".repeat(2_000)) + const keep = await user(session.id, "tiny") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 100 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + } finally { + await rt.dispose() + } + }, + }) + }) + + test("falls back to full summary when even one recent turn exceeds preserve token budget", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "first") + await user(session.id, "y".repeat(2_000)) + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 20 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("yyyy") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("falls back to full summary when retained tail media exceeds preserve token budget", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older") + const recent = await user(session.id, "recent image turn") + await svc.updatePart({ + id: PartID.ascending(), + messageID: recent.id, + sessionID: session.id, + type: "file", + mime: "image/png", + filename: "big.png", + url: `data:image/png;base64,${"a".repeat(4_000)}`, + }) + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("recent image turn") + expect(captured).toContain("Attached image/png: big.png") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("retains a split turn suffix when a later message fits the preserve token budget", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older") + const recent = await user(session.id, "recent turn") + const large = await assistant(session.id, recent.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: large.id, + sessionID: session.id, + type: "text", + text: "z".repeat(2_000), + }) + const keep = await assistant(session.id, recent.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: keep.id, + sessionID: session.id, + type: "text", + text: "keep tail", + }) + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + expect(captured).toContain("zzzz") + expect(captured).not.toContain("keep tail") + + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + expect(filtered[0]?.info.id).toBe(keep.id) + expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) + } finally { + await rt.dispose() + } + }, + }) + }) + test("allows plugins to disable synthetic continue prompt", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -1199,6 +1579,276 @@ describe("session.compaction.process", () => { }, }) }) + + test("summarizes only the head while keeping recent tail out of summary input", async () => { + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older context") + await user(session.id, "keep this turn") + await user(session.id, "and this one too") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide()) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(captured).toContain("older context") + expect(captured).not.toContain("keep this turn") + expect(captured).not.toContain("and this one too") + expect(captured).not.toContain("What did we do so far?") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("anchors repeated compactions with the previous summary", async () => { + const stub = llm() + let captured = "" + stub.push(reply("summary one")) + stub.push( + reply("summary two", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older context") + await user(session.id, "keep this turn") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide()) + try { + let msgs = await svc.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + await user(session.id, "latest turn") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(captured).toContain("") + expect(captured).toContain("summary one") + expect(captured.match(/summary one/g)?.length).toBe(1) + expect(captured).toContain("## Constraints & Preferences") + expect(captured).toContain("## Progress") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("keeps recent pre-compaction turns across repeated compactions", async () => { + const stub = llm() + stub.push(reply("summary one")) + stub.push(reply("summary two")) + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const u1 = await user(session.id, "one") + const u2 = await user(session.id, "two") + const u3 = await user(session.id, "three") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 })) + try { + let msgs = await svc.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const u4 = await user(session.id, "four") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const ids = filtered.map((msg) => msg.info.id) + + expect(ids).not.toContain(u1.id) + expect(ids).not.toContain(u2.id) + expect(ids).toContain(u3.id) + expect(ids).toContain(u4.id) + expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) + expect( + filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), + ).toBe(true) + } finally { + await rt.dispose() + } + }, + }) + }) + + test("ignores previous summaries when sizing the retained tail", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older") + const keep = await user(session.id, "keep this turn") + const keepReply = await assistant(session.id, keep.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: keepReply.id, + sessionID: session.id, + type: "text", + text: "keep reply", + }) + + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id + expect(firstCompaction).toBeTruthy() + await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800)) + + const recent = await user(session.id, "recent turn") + const recentReply = await assistant(session.id, recent.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: recentReply.id, + sessionID: session.id, + type: "text", + text: "recent reply", + }) + + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + } finally { + await rt.dispose() + } + }, + }) + }) }) describe("util.token.estimate", () => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 55ae65c5602..231d58c21a9 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -585,6 +585,76 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("truncates tool output when requested", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output: "abcdefghij", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model, { toolOutputMaxChars: 4 })).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { cmd: "ls" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { + type: "text", + value: "abcd\n[Tool output truncated for compaction: omitted 6 chars]", + }, + }, + ], + }, + ]) + }) + test("converts assistant tool error into error-text tool result", async () => { const userID = "m-user" const assistantID = "m-assistant" diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 40ccacc584b..df2d18b9f12 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -107,13 +107,14 @@ async function addAssistant( return id } -async function addCompactionPart(sessionID: SessionID, messageID: MessageID) { +async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tailStartID?: MessageID) { await svc.updatePart({ id: PartID.ascending(), sessionID, messageID, type: "compaction", auto: true, + tail_start_id: tailStartID, } as any) } @@ -780,6 +781,203 @@ describe("MessageV2.filterCompacted", () => { }) }) + test("retains original tail when compaction stores tail_start_id", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await svc.create({}) + + const u1 = await addUser(session.id, "first") + const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a1, + type: "text", + text: "first reply", + }) + + const u2 = await addUser(session.id, "second") + const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a2, + type: "text", + text: "second reply", + }) + + const c1 = await addUser(session.id) + await addCompactionPart(session.id, c1, u2) + const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: s1, + type: "text", + text: "summary", + }) + + const u3 = await addUser(session.id, "third") + const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a3, + type: "text", + text: "third reply", + }) + + const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) + + expect(result.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3]) + + await svc.remove(session.id) + }, + }) + }) + + test("retains an assistant tail when compaction starts inside a turn", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await svc.create({}) + + const u1 = await addUser(session.id, "first") + const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a1, + type: "text", + text: "first reply", + }) + + const u2 = await addUser(session.id, "second") + const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a2, + type: "text", + text: "second reply", + }) + const a3 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a3, + type: "text", + text: "tail reply", + }) + + const c1 = await addUser(session.id) + await addCompactionPart(session.id, c1, a3) + const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: s1, + type: "text", + text: "summary", + }) + + const u3 = await addUser(session.id, "third") + const a4 = await addAssistant(session.id, u3, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a4, + type: "text", + text: "third reply", + }) + + const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) + + expect(result.map((item) => item.info.id)).toEqual([a3, c1, s1, u3, a4]) + + await svc.remove(session.id) + }, + }) + }) + + test("prefers latest compaction boundary when repeated compactions exist", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await svc.create({}) + + const u1 = await addUser(session.id, "first") + const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a1, + type: "text", + text: "first reply", + }) + + const u2 = await addUser(session.id, "second") + const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a2, + type: "text", + text: "second reply", + }) + + const c1 = await addUser(session.id) + await addCompactionPart(session.id, c1, u2) + const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: s1, + type: "text", + text: "summary one", + }) + + const u3 = await addUser(session.id, "third") + const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a3, + type: "text", + text: "third reply", + }) + + const c2 = await addUser(session.id) + await addCompactionPart(session.id, c2, u3) + const s2 = await addAssistant(session.id, c2, { summary: true, finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: s2, + type: "text", + text: "summary two", + }) + + const u4 = await addUser(session.id, "fourth") + const a4 = await addAssistant(session.id, u4, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a4, + type: "text", + text: "fourth reply", + }) + + const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) + + expect(result.map((item) => item.info.id)).toEqual([u3, a3, c2, s2, u4, a4]) + + await svc.remove(session.id) + }, + }) + }) + test("works with array input", () => { // filterCompacted accepts any Iterable, not just generators const id = MessageID.ascending() diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts deleted file mode 100644 index 1cf6abb779f..00000000000 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ /dev/null @@ -1,1535 +0,0 @@ -// kilocode_change - all agent: "build" references renamed to agent: "code" -import { NodeFileSystem } from "@effect/platform-node" -import { FetchHttpClient } from "effect/unstable/http" -import { expect } from "bun:test" -import { Cause, Effect, Exit, Fiber, Layer } from "effect" -import path from "path" -import { Agent as AgentSvc } from "../../src/agent/agent" -import { Bus } from "../../src/bus" -import { Command } from "../../src/command" -import { Config } from "../../src/config" -import { LSP } from "../../src/lsp" -import { MCP } from "../../src/mcp" -import { Permission } from "../../src/permission" -import { Plugin } from "../../src/plugin" -import { Provider as ProviderSvc } from "../../src/provider" -import { Env } from "../../src/env" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Question } from "../../src/question" -import { Todo } from "../../src/session/todo" -import { Session } from "../../src/session" -import { LLM } from "../../src/session/llm" -import { MessageV2 } from "../../src/session/message-v2" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { SessionCompaction } from "../../src/session/compaction" -import { SessionSummary } from "../../src/session/summary" -import { Instruction } from "../../src/session/instruction" -import { SessionProcessor } from "../../src/session/processor" -import { SessionPrompt } from "../../src/session/prompt" -import { SessionRevert } from "../../src/session/revert" -import { SessionRunState } from "../../src/session/run-state" -import { MessageID, PartID, SessionID } from "../../src/session/schema" -import { SessionStatus } from "../../src/session/status" -import { Skill } from "../../src/skill" -import { SystemPrompt } from "../../src/session/system" -import { Shell } from "../../src/shell/shell" -import { Snapshot } from "../../src/snapshot" -import { ToolRegistry } from "../../src/tool" -import { Truncate } from "../../src/tool" -import { Log } from "../../src/util" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Ripgrep } from "../../src/file/ripgrep" -import { Format } from "../../src/format" -import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" -import { testEffect } from "../lib/effect" -import { reply, TestLLMServer } from "../lib/llm-server" - -void Log.init({ print: false }) - -const summary = Layer.succeed( - SessionSummary.Service, - SessionSummary.Service.of({ - summarize: () => Effect.void, - diff: () => Effect.succeed([]), - computeDiff: () => Effect.succeed([]), - }), -) - -const ref = { - providerID: ProviderID.make("test"), - modelID: ModelID.make("test-model"), -} - -function defer() { - let resolve!: (value: T | PromiseLike) => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -function withSh(fx: () => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const prev = process.env.SHELL - process.env.SHELL = "/bin/sh" - Shell.preferred.reset() - return prev - }), - () => fx(), - (prev) => - Effect.sync(() => { - if (prev === undefined) delete process.env.SHELL - else process.env.SHELL = prev - Shell.preferred.reset() - }), - ) -} - -function toolPart(parts: MessageV2.Part[]) { - return parts.find((part): part is MessageV2.ToolPart => part.type === "tool") -} - -type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } -type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError } - -function completedTool(parts: MessageV2.Part[]) { - const part = toolPart(parts) - expect(part?.state.status).toBe("completed") - return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined -} - -function errorTool(parts: MessageV2.Part[]) { - const part = toolPart(parts) - expect(part?.state.status).toBe("error") - return part?.state.status === "error" ? (part as ErrorToolPart) : undefined -} - -const mcp = Layer.succeed( - MCP.Service, - MCP.Service.of({ - status: () => Effect.succeed({}), - clients: () => Effect.succeed({}), - tools: () => Effect.succeed({}), - prompts: () => Effect.succeed({}), - resources: () => Effect.succeed({}), - add: () => Effect.succeed({ status: { status: "disabled" as const } }), - connect: () => Effect.void, - disconnect: () => Effect.void, - getPrompt: () => Effect.succeed(undefined), - readResource: () => Effect.succeed(undefined), - startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - removeAuth: () => Effect.void, - supportsOAuth: () => Effect.succeed(false), - hasStoredTokens: () => Effect.succeed(false), - getAuthStatus: () => Effect.succeed("not_authenticated" as const), - }), -) - -const lsp = Layer.succeed( - LSP.Service, - LSP.Service.of({ - init: () => Effect.void, - status: () => Effect.succeed([]), - hasClients: () => Effect.succeed(false), - touchFile: () => Effect.void, - diagnostics: () => Effect.succeed({}), - hover: () => Effect.succeed(undefined), - definition: () => Effect.succeed([]), - references: () => Effect.succeed([]), - implementation: () => Effect.succeed([]), - documentSymbol: () => Effect.succeed([]), - workspaceSymbol: () => Effect.succeed([]), - prepareCallHierarchy: () => Effect.succeed([]), - incomingCalls: () => Effect.succeed([]), - outgoingCalls: () => Effect.succeed([]), - }), -) - -const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) -function makeHttp() { - const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - ProviderSvc.defaultLayer, - lsp, - mcp, - AppFileSystem.defaultLayer, - status, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) - const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provideMerge(deps), - ), - ).pipe(Layer.provide(summary)) -} - -const it = testEffect(makeHttp()) -const unix = process.platform !== "win32" ? it.live : it.live.skip -const unixSkip = it.live.skip // kilocode_change - TODO(#8990): skip flaky cancel tests on Linux CI - -// Config that registers a custom "test" provider with a "test-model" model -// so provider model lookup succeeds inside the loop. -const cfg = { - provider: { - test: { - name: "Test", - id: "test", - env: [], - npm: "@ai-sdk/openai-compatible", - models: { - "test-model": { - id: "test-model", - name: "Test Model", - attachment: false, - reasoning: false, - temperature: false, - tool_call: true, - release_date: "2025-01-01", - limit: { context: 100000, output: 10000 }, - cost: { input: 0, output: 0 }, - options: {}, - }, - }, - options: { - apiKey: "test-key", - baseURL: "http://localhost:1/v1", - }, - }, - }, -} - -function providerCfg(url: string) { - return { - ...cfg, - provider: { - ...cfg.provider, - test: { - ...cfg.provider.test, - options: { - ...cfg.provider.test.options, - baseURL: url, - }, - }, - }, - } -} - -const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) { - const session = yield* Session.Service - const msg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "code", - model: ref, - time: { created: Date.now() }, - }) - yield* session.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, - }) - return msg -}) - -const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) { - const session = yield* Session.Service - const msg = yield* user(sessionID, "hello") - const assistant: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - parentID: msg.id, - sessionID, - mode: "build", - agent: "code", - cost: 0, - path: { cwd: "/tmp", root: "/tmp" }, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: ref.modelID, - providerID: ref.providerID, - time: { created: Date.now() }, - ...(opts?.finish ? { finish: opts.finish } : {}), - } - yield* session.updateMessage(assistant) - yield* session.updatePart({ - id: PartID.ascending(), - messageID: assistant.id, - sessionID, - type: "text", - text: "hi there", - }) - return { user: msg, assistant } -}) - -const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) => - Effect.gen(function* () { - const session = yield* Session.Service - yield* session.updatePart({ - id: PartID.ascending(), - messageID, - sessionID, - type: "subtask", - prompt: "look into the cache key path", - description: "inspect bug", - agent: "general", - model, - }) - }) - -const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { - const prompt = yield* SessionPrompt.Service - const run = yield* SessionRunState.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create(input ?? { title: "Pinned" }) - return { prompt, run, sessions, chat } -}) - -// Loop semantics - -it.live("loop exits immediately when last assistant has stop finish", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* seed(chat.id, { finish: "stop" }) - - const result = yield* prompt.loop({ sessionID: chat.id }) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") expect(result.info.finish).toBe("stop") - expect(yield* llm.calls).toBe(0) - }), - { git: true, config: providerCfg }, - ), -) - -it.live("loop calls LLM and returns assistant message", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* prompt.prompt({ - sessionID: chat.id, - agent: "code", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - yield* llm.text("world") - - const result = yield* prompt.loop({ sessionID: chat.id }) - expect(result.info.role).toBe("assistant") - const parts = result.parts.filter((p) => p.type === "text") - expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true) - expect(yield* llm.hits).toHaveLength(1) - }), - { git: true, config: providerCfg }, - ), -) - -it.live("static loop returns assistant text through local provider", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Prompt provider", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - - yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - - yield* llm.text("world") - - const result = yield* prompt.loop({ sessionID: session.id }) - expect(result.info.role).toBe("assistant") - expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true) - expect(yield* llm.hits).toHaveLength(1) - expect(yield* llm.pending).toBe(0) - }), - { git: true, config: providerCfg }, - ), -) - -it.live("static loop consumes queued replies across turns", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Prompt provider turns", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - - yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "hello one" }], - }) - - yield* llm.text("world one") - - const first = yield* prompt.loop({ sessionID: session.id }) - expect(first.info.role).toBe("assistant") - expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true) - - yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "hello two" }], - }) - - yield* llm.text("world two") - - const second = yield* prompt.loop({ sessionID: session.id }) - expect(second.info.role).toBe("assistant") - expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true) - - expect(yield* llm.hits).toHaveLength(2) - expect(yield* llm.pending).toBe(0) - }), - { git: true, config: providerCfg }, - ), -) - -it.live("loop continues when finish is tool-calls", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* prompt.prompt({ - sessionID: session.id, - agent: "code", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - yield* llm.tool("first", { value: "first" }) - yield* llm.text("second") - - const result = yield* prompt.loop({ sessionID: session.id }) - expect(yield* llm.calls).toBe(2) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) - expect(result.info.finish).toBe("stop") - } - }), - { git: true, config: providerCfg }, - ), -) - -it.live("glob tool keeps instance context during prompt runs", () => - provideTmpdirServer( - ({ dir, llm }) => - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Glob context", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - const file = path.join(dir, "probe.txt") - yield* Effect.promise(() => Bun.write(file, "probe")) - - yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "find text files" }], - }) - yield* llm.tool("glob", { pattern: "**/*.txt" }) - yield* llm.text("done") - - const result = yield* prompt.loop({ sessionID: session.id }) - expect(result.info.role).toBe("assistant") - - const msgs = yield* MessageV2.filterCompactedEffect(session.id) - const tool = msgs - .flatMap((msg) => msg.parts) - .find( - (part): part is CompletedToolPart => - part.type === "tool" && part.tool === "glob" && part.state.status === "completed", - ) - if (!tool) return - - expect(tool.state.output).toContain(file) - expect(tool.state.output).not.toContain("No context found for instance") - expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) - }), - { git: true, config: providerCfg }, - ), -) - -it.live("loop continues when finish is stop but assistant has tool parts", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* prompt.prompt({ - sessionID: session.id, - agent: "code", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - yield* llm.push(reply().tool("first", { value: "first" }).stop()) - yield* llm.text("second") - - const result = yield* prompt.loop({ sessionID: session.id }) - expect(yield* llm.calls).toBe(2) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) - expect(result.info.finish).toBe("stop") - } - }), - { git: true, config: providerCfg }, - ), -) - -it.live("failed subtask preserves metadata on error tool state", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.tool("task", { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - }) - yield* llm.text("done") - const msg = yield* user(chat.id, "hello") - yield* addSubtask(chat.id, msg.id) - - const result = yield* prompt.loop({ sessionID: chat.id }) - expect(result.info.role).toBe("assistant") - expect(yield* llm.calls).toBe(2) - - const msgs = yield* MessageV2.filterCompactedEffect(chat.id) - const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") - expect(taskMsg?.info.role).toBe("assistant") - if (!taskMsg || taskMsg.info.role !== "assistant") return - - const tool = errorTool(taskMsg.parts) - if (!tool) return - - expect(tool.state.error).toContain("Tool execution failed") - expect(tool.state.metadata).toBeDefined() - expect(tool.state.metadata?.sessionId).toBeDefined() - expect(tool.state.metadata?.model).toEqual({ - providerID: ProviderID.make("test"), - modelID: ModelID.make("missing-model"), - }) - }), - { - git: true, - config: (url) => ({ - ...providerCfg(url), - agent: { - general: { - model: "test/missing-model", - }, - }, - }), - }, - ), -) - -it.live( - "running subtask preserves metadata after tool-call transition", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - const msg = yield* user(chat.id, "hello") - yield* addSubtask(chat.id, msg.id) - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - - const tool = yield* Effect.promise(async () => { - const end = Date.now() + 5_000 - while (Date.now() < end) { - const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) - const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") - const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool") - if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool - await new Promise((done) => setTimeout(done, 20)) - } - throw new Error("timed out waiting for running subtask metadata") - }) - - if (tool.state.status !== "running") return - expect(typeof tool.state.metadata?.sessionId).toBe("string") - expect(tool.state.title).toBeDefined() - expect(tool.state.metadata?.model).toBeDefined() - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: providerCfg }, - ), - 5_000, -) - -it.live( - "running task tool preserves metadata after tool-call transition", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* llm.tool("task", { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - }) - yield* llm.hang - yield* user(chat.id, "hello") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - - const tool = yield* Effect.promise(async () => { - const end = Date.now() + 5_000 - while (Date.now() < end) { - const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) - const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "code") // kilocode_change - const tool = assistant?.parts.find( - (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task", - ) - if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool - await new Promise((done) => setTimeout(done, 20)) - } - throw new Error("timed out waiting for running task metadata") - }) - - if (tool.state.status !== "running") return - expect(typeof tool.state.metadata?.sessionId).toBe("string") - expect(tool.state.title).toBe("inspect bug") - expect(tool.state.metadata?.model).toBeDefined() - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: providerCfg }, - ), - 10_000, -) - -it.live( - "loop sets status to busy then idle", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const status = yield* SessionStatus.Service - - yield* llm.hang - - const chat = yield* sessions.create({}) - yield* user(chat.id, "hi") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - expect((yield* status.get(chat.id)).type).toBe("busy") - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - expect((yield* status.get(chat.id)).type).toBe("idle") - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// Cancel semantics - -it.live( - "cancel interrupts loop and resolves with an assistant message", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* seed(chat.id) - - yield* llm.hang - - yield* user(chat.id, "more") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - yield* prompt.cancel(chat.id) - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - } - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -it.live( - "cancel records MessageAbortedError on interrupted process", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - yield* user(chat.id, "hello") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - yield* prompt.cancel(chat.id) - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - const info = exit.value.info - if (info.role === "assistant") { - expect(info.error?.name).toBe("MessageAbortedError") - } - } - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -it.live( - "cancel finalizes subtask tool state", - () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const ready = defer() - const aborted = defer() - const registry = yield* ToolRegistry.Service - const { task } = yield* registry.named() - const original = task.execute - task.execute = (_args, ctx) => - Effect.callback((_resume) => { - ready.resolve() - ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) - return Effect.sync(() => aborted.resolve()) - }) - yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) - - const { prompt, chat } = yield* boot() - const msg = yield* user(chat.id, "hello") - yield* addSubtask(chat.id, msg.id) - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => ready.promise) - yield* prompt.cancel(chat.id) - yield* Effect.promise(() => aborted.promise) - - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - - const msgs = yield* MessageV2.filterCompactedEffect(chat.id) - const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") - expect(taskMsg?.info.role).toBe("assistant") - if (!taskMsg || taskMsg.info.role !== "assistant") return - - const tool = toolPart(taskMsg.parts) - expect(tool?.type).toBe("tool") - if (!tool) return - - expect(tool.state.status).not.toBe("running") - expect(taskMsg.info.time.completed).toBeDefined() - expect(taskMsg.info.finish).toBeDefined() - }), - { git: true, config: cfg }, - ), - 30_000, -) - -it.live( - "cancel with queued callers resolves all cleanly", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - yield* user(chat.id, "hello") - - const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.sleep(50) - - yield* prompt.cancel(chat.id) - const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isSuccess(exitA)).toBe(true) - expect(Exit.isSuccess(exitB)).toBe(true) - if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) { - expect(exitA.value.info.id).toBe(exitB.value.info.id) - } - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// Queue semantics - -it.live("concurrent loop callers get same result", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - yield* seed(chat.id, { finish: "stop" }) - - const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { - concurrency: "unbounded", - }) - - expect(a.info.id).toBe(b.info.id) - expect(a.info.role).toBe("assistant") - yield* run.assertNotBusy(chat.id) - }), - { git: true }, - ), -) - -it.live( - "concurrent loop callers all receive same error result", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - - yield* llm.fail("boom") - yield* user(chat.id, "hello") - - const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { - concurrency: "unbounded", - }) - expect(a.info.id).toBe(b.info.id) - expect(a.info.role).toBe("assistant") - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// kilocode_change start - skip flaky test, tracked in #8990 -it.live.skip( - "prompt submitted during an active run is included in the next LLM input", - // kilocode_change end - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const gate = defer() - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - - yield* llm.hold("first", gate.promise) - yield* llm.text("second") - - const a = yield* prompt - .prompt({ - sessionID: chat.id, - agent: "code", - model: ref, - parts: [{ type: "text", text: "first" }], - }) - .pipe(Effect.forkChild) - - yield* llm.wait(1) - - const id = MessageID.ascending() - const b = yield* prompt - .prompt({ - sessionID: chat.id, - messageID: id, - agent: "code", - model: ref, - parts: [{ type: "text", text: "second" }], - }) - .pipe(Effect.forkChild) - - yield* Effect.promise(async () => { - const end = Date.now() + 5000 - while (Date.now() < end) { - const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id })) - if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return - await new Promise((done) => setTimeout(done, 20)) - } - throw new Error("timed out waiting for second prompt to save") - }) - - gate.resolve() - - const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isSuccess(ea)).toBe(true) - expect(Exit.isSuccess(eb)).toBe(true) - expect(yield* llm.calls).toBe(2) - - const msgs = yield* sessions.messages({ sessionID: chat.id }) - const assistants = msgs.filter((msg) => msg.info.role === "assistant") - expect(assistants).toHaveLength(2) - const last = assistants.at(-1) - if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") - expect(last.info.parentID).toBe(id) - expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) - - const inputs = yield* llm.inputs - expect(inputs).toHaveLength(2) - expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second") - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -it.live( - "assertNotBusy throws BusyError when loop running", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const run = yield* SessionRunState.Service - const sessions = yield* Session.Service - yield* llm.hang - - const chat = yield* sessions.create({}) - yield* user(chat.id, "hi") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - - const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) - } - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -it.live("assertNotBusy succeeds when idle", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const run = yield* SessionRunState.Service - const sessions = yield* Session.Service - - const chat = yield* sessions.create({}) - const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isSuccess(exit)).toBe(true) - }), - { git: true }, - ), -) - -// Shell semantics - -it.live( - "shell rejects with BusyError when loop running", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - yield* user(chat.id, "hi") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - - const exit = yield* prompt.shell({ sessionID: chat.id, agent: "code", command: "echo hi" }).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) - } - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -unix("shell captures stdout and stderr in completed tool output", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "code", - command: "printf out && printf err >&2", - }) - - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return - - expect(tool.state.output).toContain("out") - expect(tool.state.output).toContain("err") - expect(tool.state.metadata.output).toContain("out") - expect(tool.state.metadata.output).toContain("err") - yield* run.assertNotBusy(chat.id) - }), - { git: true, config: cfg }, - ), -) - -unix("shell completes a fast command on the preferred shell", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "code", - command: "pwd", - }) - - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return - - expect(tool.state.input.command).toBe("pwd") - expect(tool.state.output).toContain(dir) - expect(tool.state.metadata.output).toContain(dir) - yield* run.assertNotBusy(chat.id) - }), - { git: true, config: cfg }, - ), -) - -unix("shell lists files from the project directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n")) - - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "code", - command: "command ls", - }) - - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return - - expect(tool.state.input.command).toBe("command ls") - expect(tool.state.output).toContain("README.md") - expect(tool.state.metadata.output).toContain("README.md") - yield* run.assertNotBusy(chat.id) - }), - { git: true, config: cfg }, - ), -) - -unix("shell captures stderr from a failing command", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "code", - command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1", - }) - - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return - - expect(tool.state.output).toContain("not found") - expect(tool.state.metadata.output).toContain("not found") - yield* run.assertNotBusy(chat.id) - }), - { git: true, config: cfg }, - ), -) - -unix( - "shell updates running metadata before process exit", - () => - withSh(() => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const fiber = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "printf first && sleep 0.2 && printf second" }) - .pipe(Effect.forkChild) - - yield* Effect.promise(async () => { - const start = Date.now() - while (Date.now() - start < 5000) { - const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id)) - const taskMsg = msgs.find((item) => item.info.role === "assistant") - const tool = taskMsg ? toolPart(taskMsg.parts) : undefined - if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return - await new Promise((done) => setTimeout(done, 20)) - } - throw new Error("timed out waiting for running shell metadata") - }) - - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - }), - { git: true, config: cfg }, - ), - ), - 30_000, -) - -it.live( - "loop waits while shell runs and starts after shell exits", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* llm.text("after-shell") - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 0.2" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.sleep(50) - - expect(yield* llm.calls).toBe(0) - - yield* Fiber.await(sh) - const exit = yield* Fiber.await(loop) - - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true) - } - expect(yield* llm.calls).toBe(1) - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// kilocode_change start - shell process timing is unreliable on Windows CI; -// aligns with every other shell-* test in this file that uses `unix(...)`. -unix( - // kilocode_change end - "shell completion resumes queued loop callers", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* llm.text("done") - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 0.2" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.sleep(50) - - expect(yield* llm.calls).toBe(0) - - yield* Fiber.await(sh) - const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - - expect(Exit.isSuccess(ea)).toBe(true) - expect(Exit.isSuccess(eb)).toBe(true) - if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) { - expect(ea.value.info.id).toBe(eb.value.info.id) - expect(ea.value.info.role).toBe("assistant") - } - expect(yield* llm.calls).toBe(1) - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// kilocode_change start - TODO(#8990): flaky on Linux CI -unixSkip( - "cancel interrupts shell and resolves cleanly", - () => - withSh(() => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 30" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - yield* prompt.cancel(chat.id) - - const status = yield* SessionStatus.Service - expect((yield* status.get(chat.id)).type).toBe("idle") - const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isSuccess(busy)).toBe(true) - - const exit = yield* Fiber.await(sh) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - const tool = completedTool(exit.value.parts) - if (tool) { - expect(tool.state.output).toContain("User aborted the command") - } - } - }), - { git: true, config: cfg }, - ), - ), - 30_000, -) -// kilocode_change end - -// kilocode_change start - TODO(#8990): flaky on Linux CI -unixSkip( - "cancel persists aborted shell result when shell ignores TERM", - () => - withSh(() => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "trap '' TERM; sleep 30" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - yield* prompt.cancel(chat.id) - - const exit = yield* Fiber.await(sh) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - const tool = completedTool(exit.value.parts) - if (tool) { - expect(tool.state.output).toContain("User aborted the command") - } - } - }), - { git: true, config: cfg }, - ), - ), - 30_000, -) -// kilocode_change end - -unix( - "cancel finalizes interrupted bash tool output through normal truncation", - () => - provideTmpdirServer( - ({ dir, llm }) => - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Interrupted bash truncation", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - - yield* prompt.prompt({ - sessionID: chat.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "run bash" }], - }) - - yield* llm.tool("bash", { - command: - 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30', - description: "Print many lines", - timeout: 30_000, - workdir: path.resolve(dir), - }) - - const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - yield* Effect.sleep(150) - yield* prompt.cancel(chat.id) - - const exit = yield* Fiber.await(run) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isFailure(exit)) return - - const tool = completedTool(exit.value.parts) - if (!tool) return - - expect(tool.state.metadata.truncated).toBe(true) - expect(typeof tool.state.metadata.outputPath).toBe("string") - expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./) - expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/) - expect(tool.state.output).not.toContain("Tool execution aborted") - }), - { git: true, config: providerCfg }, - ), - 30_000, -) - -// kilocode_change start - TODO(#8990): flaky on Linux CI -unixSkip( - "cancel interrupts loop queued behind shell", - () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 30" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.sleep(50) - - yield* prompt.cancel(chat.id) - - const exit = yield* Fiber.await(loop) - expect(Exit.isSuccess(exit)).toBe(true) - - yield* Fiber.await(sh) - }), - { git: true, config: cfg }, - ), - 30_000, -) -// kilocode_change end - -unix( - "shell rejects when another shell is already running", - () => - withSh(() => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const a = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 30" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - const exit = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "echo hi" }) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) - } - - yield* prompt.cancel(chat.id) - yield* Fiber.await(a) - }), - { git: true, config: cfg }, - ), - ), - 30_000, -) - -// Abort signal propagation tests for inline tool execution - -/** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */ -function hangUntilAborted(tool: { execute: (...args: any[]) => any }) { - const ready = defer() - const aborted = defer() - const original = tool.execute - tool.execute = (_args: any, ctx: any) => { - ready.resolve() - ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) - return Effect.callback(() => {}) - } - const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original))) - return { ready, aborted, restore } -} - -it.live( - "interrupt propagates abort signal to read tool via file part (text/plain)", - () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const { read } = yield* registry.named() - const { ready, aborted, restore } = hangUntilAborted(read) - yield* restore - - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Abort Test" }) - - const testFile = path.join(dir, "test.txt") - yield* Effect.promise(() => Bun.write(testFile, "hello world")) - - const fiber = yield* prompt - .prompt({ - sessionID: chat.id, - agent: "build", - parts: [ - { type: "text", text: "read this" }, - { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" }, - ], - }) - .pipe(Effect.forkChild) - - yield* Effect.promise(() => ready.promise) - yield* Fiber.interrupt(fiber) - - yield* Effect.promise(() => - Promise.race([ - aborted.promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), - ), - ]), - ) - }), - { git: true, config: cfg }, - ), - 30_000, -) - -it.live( - "interrupt propagates abort signal to read tool via file part (directory)", - () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const { read } = yield* registry.named() - const { ready, aborted, restore } = hangUntilAborted(read) - yield* restore - - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Abort Test" }) - - const fiber = yield* prompt - .prompt({ - sessionID: chat.id, - agent: "build", - parts: [ - { type: "text", text: "read this" }, - { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" }, - ], - }) - .pipe(Effect.forkChild) - - yield* Effect.promise(() => ready.promise) - yield* Fiber.interrupt(fiber) - - yield* Effect.promise(() => - Promise.race([ - aborted.promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), - ), - ]), - ) - }), - { git: true, config: cfg }, - ), - 30_000, -) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 2fc2595cc22..f85eb3a7d58 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,23 +1,63 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { FetchHttpClient } from "effect/unstable/http" +import { expect } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" -import fs from "fs/promises" -import { describe, expect, test } from "bun:test" -import { NamedError } from "@opencode-ai/shared/util/error" import { fileURLToPath } from "url" -import { Effect, Layer } from "effect" -import { Instance } from "../../src/project/instance" +import { NamedError } from "@opencode-ai/shared/util/error" +import { Agent as AgentSvc } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Command } from "../../src/command" +import { Config } from "../../src/config" +import { LSP } from "../../src/lsp" +import { MCP } from "../../src/mcp" +import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" +import { Provider as ProviderSvc } from "../../src/provider" +import { Env } from "../../src/env" import { ModelID, ProviderID } from "../../src/provider/schema" +import { Question } from "../../src/question" +import { Todo } from "../../src/session/todo" import { Session } from "../../src/session" +import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { SessionCompaction } from "../../src/session/compaction" +import { SessionSummary } from "../../src/session/summary" +import { Instruction } from "../../src/session/instruction" +import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" +import { SessionRunState } from "../../src/session/run-state" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { SessionStatus } from "../../src/session/status" +import { Skill } from "../../src/skill" +import { SystemPrompt } from "../../src/session/system" +import { Shell } from "../../src/shell/shell" +import { Snapshot } from "../../src/snapshot" +import { ToolRegistry } from "../../src/tool" +import { Truncate } from "../../src/tool" import { Log } from "../../src/util" -import { tmpdir } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Ripgrep } from "../../src/file/ripgrep" +import { Format } from "../../src/format" +import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { reply, TestLLMServer } from "../lib/llm-server" void Log.init({ print: false }) +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) -function run(fx: Effect.Effect) { - return Effect.runPromise( - fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), - ) +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), } function defer() { @@ -28,687 +68,1819 @@ function defer() { return { promise, resolve } } -function chat(text: string) { - const payload = - [ - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { role: "assistant" } }], - })}`, - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { content: text } }], - })}`, - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: {}, finish_reason: "stop" }], - })}`, - "data: [DONE]", - ].join("\n\n") + "\n\n" - - const encoder = new TextEncoder() - return new ReadableStream({ - start(ctrl) { - ctrl.enqueue(encoder.encode(payload)) - ctrl.close() - }, - }) +function withSh(fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const prev = process.env.SHELL + process.env.SHELL = "/bin/sh" + Shell.preferred.reset() + return prev + }), + () => fx(), + (prev) => + Effect.sync(() => { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.preferred.reset() + }), + ) } -function hanging(ready: () => void) { - const encoder = new TextEncoder() - let timer: ReturnType | undefined - const first = `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { role: "assistant" } }], - })}\n\n` - const rest = - [ - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { content: "late" } }], - })}`, - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: {}, finish_reason: "stop" }], - })}`, - "data: [DONE]", - ].join("\n\n") + "\n\n" - - return new ReadableStream({ - start(ctrl) { - ctrl.enqueue(encoder.encode(first)) - ready() - timer = setTimeout(() => { - ctrl.enqueue(encoder.encode(rest)) - ctrl.close() - }, 10000) - }, - cancel() { - if (timer) clearTimeout(timer) +function toolPart(parts: MessageV2.Part[]) { + return parts.find((part): part is MessageV2.ToolPart => part.type === "tool") +} + +type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } +type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError } + +function completedTool(parts: MessageV2.Part[]) { + const part = toolPart(parts) + expect(part?.state.status).toBe("completed") + return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined +} + +function errorTool(parts: MessageV2.Part[]) { + const part = toolPart(parts) + expect(part?.state.status).toBe("error") + return part?.state.status === "error" ? (part as ErrorToolPart) : undefined +} + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +function makeHttp() { + const deps = Layer.mergeAll( + Session.defaultLayer, + Snapshot.defaultLayer, + LLM.defaultLayer, + Env.defaultLayer, + AgentSvc.defaultLayer, + Command.defaultLayer, + Permission.defaultLayer, + Plugin.defaultLayer, + Config.defaultLayer, + ProviderSvc.defaultLayer, + lsp, + mcp, + AppFileSystem.defaultLayer, + status, + ).pipe(Layer.provideMerge(infra)) + const question = Question.layer.pipe(Layer.provideMerge(deps)) + const todo = Todo.layer.pipe(Layer.provideMerge(deps)) + const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Format.defaultLayer), + Layer.provideMerge(todo), + Layer.provideMerge(question), + Layer.provideMerge(deps), + ) + const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) + return Layer.mergeAll( + TestLLMServer.layer, + SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(summary), + Layer.provideMerge(run), + Layer.provideMerge(compact), + Layer.provideMerge(proc), + Layer.provideMerge(registry), + Layer.provideMerge(trunc), + Layer.provide(Instruction.defaultLayer), + Layer.provide(SystemPrompt.defaultLayer), + Layer.provideMerge(deps), + ), + ).pipe(Layer.provide(summary)) +} + +const it = testEffect(makeHttp()) +const unix = process.platform !== "win32" ? it.live : it.live.skip + +// Config that registers a custom "test" provider with a "test-model" model +// so provider model lookup succeeds inside the loop. +const cfg = { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, }, - }) + }, } -describe("session.prompt missing file", () => { - test("does not fail the prompt when a file part is missing", async () => { - await using tmp = await tmpdir({ - git: true, - config: { - agent: { - build: { - model: "openai/gpt-5.2", - }, +function providerCfg(url: string) { + return { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + options: { + ...cfg.provider.test.options, + baseURL: url, }, }, + }, + } +} + +const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) { + const session = yield* Session.Service + const msg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg +}) + +const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) { + const session = yield* Session.Service + const msg = yield* user(sessionID, "hello") + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: msg.id, + sessionID, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + ...(opts?.finish ? { finish: opts.finish } : {}), + } + yield* session.updateMessage(assistant) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID, + type: "text", + text: "hi there", + }) + return { user: msg, assistant } +}) + +const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) => + Effect.gen(function* () { + const session = yield* Session.Service + yield* session.updatePart({ + id: PartID.ascending(), + messageID, + sessionID, + type: "subtask", + prompt: "look into the cache key path", + description: "inspect bug", + agent: "general", + model, }) + }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) +const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { + const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create(input ?? { title: "Pinned" }) + return { prompt, run, sessions, chat } +}) - const missing = path.join(tmp.path, "does-not-exist.ts") - const msg = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [ - { type: "text", text: "please review @does-not-exist.ts" }, - { - type: "file", - mime: "text/plain", - url: `file://${missing}`, - filename: "does-not-exist.ts", - }, - ], - }) +// Loop semantics - if (msg.info.role !== "user") throw new Error("expected user message") +it.live("loop exits immediately when last assistant has stop finish", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* seed(chat.id, { finish: "stop" }) - const hasFailure = msg.parts.some( - (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"), - ) - expect(hasFailure).toBe(true) + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") expect(result.info.finish).toBe("stop") + expect(yield* llm.calls).toBe(0) + }), + { git: true, config: providerCfg }, + ), +) - yield* sessions.remove(session.id) - }), - ), - }) - }) +it.live("loop calls LLM and returns assistant message", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.text("world") + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + const parts = result.parts.filter((p) => p.type === "text") + expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true) + expect(yield* llm.hits).toHaveLength(1) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("static loop returns assistant text through local provider", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Prompt provider", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + yield* llm.text("world") + + const result = yield* prompt.loop({ sessionID: session.id }) + expect(result.info.role).toBe("assistant") + expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true) + expect(yield* llm.hits).toHaveLength(1) + expect(yield* llm.pending).toBe(0) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("static loop consumes queued replies across turns", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Prompt provider turns", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello one" }], + }) + + yield* llm.text("world one") + + const first = yield* prompt.loop({ sessionID: session.id }) + expect(first.info.role).toBe("assistant") + expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello two" }], + }) + + yield* llm.text("world two") + + const second = yield* prompt.loop({ sessionID: session.id }) + expect(second.info.role).toBe("assistant") + expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true) + + expect(yield* llm.hits).toHaveLength(2) + expect(yield* llm.pending).toBe(0) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("loop continues when finish is tool-calls", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.tool("first", { value: "first" }) + yield* llm.text("second") + + const result = yield* prompt.loop({ sessionID: session.id }) + expect(yield* llm.calls).toBe(2) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + expect(result.info.finish).toBe("stop") + } + }), + { git: true, config: providerCfg }, + ), +) - test("keeps stored part order stable when file resolution is async", async () => { - await using tmp = await tmpdir({ +// kilocode_change - skipped: tracked in #9958 +it.live.skip("glob tool keeps instance context during prompt runs", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Glob context", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const file = path.join(dir, "probe.txt") + yield* Effect.promise(() => Bun.write(file, "probe")) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "find text files" }], + }) + yield* llm.tool("glob", { pattern: "**/*.txt" }) + yield* llm.text("done") + + const result = yield* prompt.loop({ sessionID: session.id }) + expect(result.info.role).toBe("assistant") + + const msgs = yield* MessageV2.filterCompactedEffect(session.id) + const tool = msgs + .flatMap((msg) => msg.parts) + .find( + (part): part is CompletedToolPart => + part.type === "tool" && part.tool === "glob" && part.state.status === "completed", + ) + if (!tool) return + + expect(tool.state.output).toContain(file) + expect(tool.state.output).not.toContain("No context found for instance") + expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("loop continues when finish is stop but assistant has tool parts", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.push(reply().tool("first", { value: "first" }).stop()) + yield* llm.text("second") + + const result = yield* prompt.loop({ sessionID: session.id }) + expect(yield* llm.calls).toBe(2) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + expect(result.info.finish).toBe("stop") + } + }), + { git: true, config: providerCfg }, + ), +) + +it.live("failed subtask preserves metadata on error tool state", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.tool("task", { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }) + yield* llm.text("done") + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + expect(yield* llm.calls).toBe(2) + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + expect(taskMsg?.info.role).toBe("assistant") + if (!taskMsg || taskMsg.info.role !== "assistant") return + + const tool = errorTool(taskMsg.parts) + if (!tool) return + + expect(tool.state.error).toContain("Tool execution failed") + expect(tool.state.metadata).toBeDefined() + expect(tool.state.metadata?.sessionId).toBeDefined() + expect(tool.state.metadata?.model).toEqual({ + providerID: ProviderID.make("test"), + modelID: ModelID.make("missing-model"), + }) + }), + { git: true, - config: { + config: (url) => ({ + ...providerCfg(url), agent: { - build: { - model: "openai/gpt-5.2", + general: { + model: "test/missing-model", }, }, - }, - }) + }), + }, + ), +) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) +it.live( + "running subtask preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) - const missing = path.join(tmp.path, "still-missing.ts") - const msg = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [ - { - type: "file", - mime: "text/plain", - url: `file://${missing}`, - filename: "still-missing.ts", - }, - { type: "text", text: "after-file" }, - ], - }) + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - if (msg.info.role !== "user") throw new Error("expected user message") + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running subtask metadata") + }) - const stored = MessageV2.get({ - sessionID: session.id, - messageID: msg.info.id, + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBeDefined() + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 5_000, +) + +it.live( + "running task tool preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.tool("task", { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }) + yield* llm.hang + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "code") // kilocode_change + const tool = assistant?.parts.find( + (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task", + ) + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running task metadata") + }) + + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBe("inspect bug") + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + +it.live( + "loop sets status to busy then idle", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + + yield* llm.hang + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + expect((yield* status.get(chat.id)).type).toBe("busy") + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + expect((yield* status.get(chat.id)).type).toBe("idle") + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +// Cancel semantics + +it.live( + "cancel interrupts loop and resolves with an assistant message", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* seed(chat.id) + + yield* llm.hang + + yield* user(chat.id, "more") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + } + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live( + "cancel records MessageAbortedError on interrupted process", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + const info = exit.value.info + if (info.role === "assistant") { + expect(info.error?.name).toBe("MessageAbortedError") + } + } + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live( + "cancel finalizes subtask tool state", + () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const ready = defer() + const aborted = defer() + const registry = yield* ToolRegistry.Service + const { task } = yield* registry.named() + const original = task.execute + task.execute = (_args, ctx) => + Effect.callback((_resume) => { + ready.resolve() + ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) + return Effect.sync(() => aborted.resolve()) }) - const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text) + yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) - expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true) - expect(text[1]?.includes("Read tool failed to read")).toBe(true) - expect(text[2]).toBe("after-file") + const { prompt, chat } = yield* boot() + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) - yield* sessions.remove(session.id) - }), - ), - }) - }) -}) + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + yield* prompt.cancel(chat.id) + yield* Effect.promise(() => aborted.promise) -describe("session.prompt special characters", () => { - test("handles filenames with # character", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "file#name.txt"), "special content\n") - }, - }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const template = "Read @file#name.txt" - const parts = yield* prompt.resolvePromptParts(template) - const fileParts = parts.filter((part) => part.type === "file") + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + expect(taskMsg?.info.role).toBe("assistant") + if (!taskMsg || taskMsg.info.role !== "assistant") return - expect(fileParts.length).toBe(1) - expect(fileParts[0].filename).toBe("file#name.txt") - expect(fileParts[0].url).toContain("%23") + const tool = toolPart(taskMsg.parts) + expect(tool?.type).toBe("tool") + if (!tool) return - const decodedPath = fileURLToPath(fileParts[0].url) - expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt")) + expect(tool.state.status).not.toBe("running") + expect(taskMsg.info.time.completed).toBeDefined() + expect(taskMsg.info.finish).toBeDefined() + }), + { git: true, config: cfg }, + ), + 30_000, +) - const message = yield* prompt.prompt({ - sessionID: session.id, - parts, - noReply: true, +it.live( + "cancel with queued callers resolves all cleanly", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + yield* user(chat.id, "hello") + + const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.sleep(50) + + yield* prompt.cancel(chat.id) + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) { + expect(exitA.value.info.id).toBe(exitB.value.info.id) + } + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +// Queue semantics + +it.live("concurrent loop callers get same result", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + yield* seed(chat.id, { finish: "stop" }) + + const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { + concurrency: "unbounded", + }) + + expect(a.info.id).toBe(b.info.id) + expect(a.info.role).toBe("assistant") + yield* run.assertNotBusy(chat.id) + }), + { git: true }, + ), +) + +it.live( + "concurrent loop callers all receive same error result", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* llm.fail("boom") + yield* user(chat.id, "hello") + + const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { + concurrency: "unbounded", + }) + expect(a.info.id).toBe(b.info.id) + expect(a.info.role).toBe("assistant") + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +// kilocode_change - skipped: tracked in #9958 +it.live.skip( + "prompt submitted during an active run is included in the next LLM input", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const gate = defer() + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* llm.hold("first", gate.promise) + yield* llm.text("second") + + const a = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "first" }], + }) + .pipe(Effect.forkChild) + + yield* llm.wait(1) + + const id = MessageID.ascending() + const b = yield* prompt + .prompt({ + sessionID: chat.id, + messageID: id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "second" }], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(async () => { + const end = Date.now() + 5000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id })) + if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for second prompt to save") + }) + + gate.resolve() + + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(ea)).toBe(true) + expect(Exit.isSuccess(eb)).toBe(true) + expect(yield* llm.calls).toBe(2) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + expect(assistants).toHaveLength(2) + const last = assistants.at(-1) + if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") + expect(last.info.parentID).toBe(id) + expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(2) + expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second") + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live( + "assertNotBusy throws BusyError when loop running", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service + const sessions = yield* Session.Service + yield* llm.hang + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live("assertNotBusy succeeds when idle", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const run = yield* SessionRunState.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(exit)).toBe(true) + }), + { git: true }, + ), +) + +// Shell semantics + +it.live( + "shell rejects with BusyError when loop running", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +unix("shell captures stdout and stderr in completed tool output", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "printf out && printf err >&2", + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.output).toContain("out") + expect(tool.state.output).toContain("err") + expect(tool.state.metadata.output).toContain("out") + expect(tool.state.metadata.output).toContain("err") + yield* run.assertNotBusy(chat.id) + }), + { git: true, config: cfg }, + ), +) + +unix("shell completes a fast command on the preferred shell", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "pwd", + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.input.command).toBe("pwd") + expect(tool.state.output).toContain(dir) + expect(tool.state.metadata.output).toContain(dir) + yield* run.assertNotBusy(chat.id) + }), + { git: true, config: cfg }, + ), +) + +unix("shell lists files from the project directory", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n")) + + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "command ls", + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.input.command).toBe("command ls") + expect(tool.state.output).toContain("README.md") + expect(tool.state.metadata.output).toContain("README.md") + yield* run.assertNotBusy(chat.id) + }), + { git: true, config: cfg }, + ), +) + +unix("shell captures stderr from a failing command", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1", + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.output).toContain("not found") + expect(tool.state.metadata.output).toContain("not found") + yield* run.assertNotBusy(chat.id) + }), + { git: true, config: cfg }, + ), +) + +unix( + "shell updates running metadata before process exit", + () => + withSh(() => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const fiber = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" }) + .pipe(Effect.forkChild) + + yield* Effect.promise(async () => { + const start = Date.now() + while (Date.now() - start < 5000) { + const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id)) + const taskMsg = msgs.find((item) => item.info.role === "assistant") + const tool = taskMsg ? toolPart(taskMsg.parts) : undefined + if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running shell metadata") }) - const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const textParts = stored.parts.filter((part) => part.type === "text") - const hasContent = textParts.some((part) => part.text.includes("special content")) - expect(hasContent).toBe(true) - yield* sessions.remove(session.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) }), - ), - }) - }) -}) + { git: true, config: cfg }, + ), + ), + 30_000, +) + +it.live( + "loop waits while shell runs and starts after shell exits", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.text("after-shell") + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.sleep(50) + + expect(yield* llm.calls).toBe(0) -describe("session.prompt regression", () => { - test("does not loop empty assistant turns for a simple reply", async () => { - let calls = 0 - const server = Bun.serve({ - port: 0, - fetch(req) { - const url = new URL(req.url) - if (!url.pathname.endsWith("/chat/completions")) { - return new Response("not found", { status: 404 }) + yield* Fiber.await(sh) + const exit = yield* Fiber.await(loop) + + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true) } - calls++ - return new Response(chat("packages/opencode/src/session/processor.ts"), { - status: 200, - headers: { "Content-Type": "text/event-stream" }, + expect(yield* llm.calls).toBe(1) + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live( + "shell completion resumes queued loop callers", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - }, - }) + yield* llm.text("done") - try { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - enabled_providers: ["alibaba"], - provider: { - alibaba: { - options: { - apiKey: "test-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - agent: { - build: { - model: "alibaba/qwen-plus", - }, - }, - }), - ) - }, - }) + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Prompt regression" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Where is SessionProcessor?" }], - }) - - expect(result.info.role).toBe("assistant") - expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true) - - const msgs = yield* sessions.messages({ sessionID: session.id }) - expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) - expect(calls).toBe(1) - }), - ), - }) - } finally { - void server.stop(true) - } - }) + const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.sleep(50) + + expect(yield* llm.calls).toBe(0) + + yield* Fiber.await(sh) + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - test("records aborted errors when prompt is cancelled mid-stream", async () => { - const ready = defer() - const server = Bun.serve({ - port: 0, - fetch(req) { - const url = new URL(req.url) - if (!url.pathname.endsWith("/chat/completions")) { - return new Response("not found", { status: 404 }) + expect(Exit.isSuccess(ea)).toBe(true) + expect(Exit.isSuccess(eb)).toBe(true) + if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) { + expect(ea.value.info.id).toBe(eb.value.info.id) + expect(ea.value.info.role).toBe("assistant") } - return new Response( - hanging(() => ready.resolve()), - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }, - ) - }, - }) + expect(yield* llm.calls).toBe(1) + }), + { git: true, config: providerCfg }, + ), + 3_000, +) - try { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - enabled_providers: ["alibaba"], - provider: { - alibaba: { - options: { - apiKey: "test-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - agent: { - build: { - model: "alibaba/qwen-plus", - }, - }, - }), - ) - }, - }) +unix( + "cancel interrupts shell and resolves cleanly", + () => + withSh(() => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + yield* prompt.cancel(chat.id) + + const status = yield* SessionStatus.Service + expect((yield* status.get(chat.id)).type).toBe("idle") + const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(busy)).toBe(true) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Prompt cancel regression" }) - const task = Effect.runPromise( - prompt.prompt({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Cancel me" }], - }), - ) - - yield* Effect.promise(() => ready.promise) - yield* prompt.cancel(session.id) - - const result = yield* Effect.promise(() => - Promise.race([ - task, - new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000), - ), - ]), - ) - - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.error?.name).toBe("MessageAbortedError") + const exit = yield* Fiber.await(sh) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + const tool = completedTool(exit.value.parts) + if (tool) { + expect(tool.state.output).toContain("User aborted the command") } + } + }), + { git: true, config: cfg }, + ), + ), + 30_000, +) + +unix( + "cancel persists aborted shell result when shell ignores TERM", + () => + withSh(() => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "trap '' TERM; sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + yield* prompt.cancel(chat.id) - const msgs = yield* sessions.messages({ sessionID: session.id }) - const last = msgs.findLast((msg) => msg.info.role === "assistant") - expect(last?.info.role).toBe("assistant") - if (last?.info.role === "assistant") { - expect(last.info.error?.name).toBe("MessageAbortedError") + const exit = yield* Fiber.await(sh) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + const tool = completedTool(exit.value.parts) + if (tool) { + expect(tool.state.output).toContain("User aborted the command") } - }), - ), - }) - } finally { - void server.stop(true) - } - }) -}) + } + }), + { git: true, config: cfg }, + ), + ), + 30_000, +) -describe("session.prompt agent variant", () => { - test("applies agent variant only when using agent model", async () => { - const prev = process.env.OPENAI_API_KEY - process.env.OPENAI_API_KEY = "test-openai-key" - - try { - await using tmp = await tmpdir({ - git: true, - config: { - agent: { - build: { - model: "openai/gpt-5.2", - variant: "xhigh", - }, - }, - }, - }) +unix( + "cancel finalizes interrupted bash tool output through normal truncation", + () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Interrupted bash truncation", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - - const other = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") }, - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - if (other.info.role !== "user") throw new Error("expected user message") - expect(other.info.model.variant).toBeUndefined() - - const match = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "hello again" }], - }) - if (match.info.role !== "user") throw new Error("expected user message") - expect(match.info.model).toEqual({ - providerID: ProviderID.make("openai"), - modelID: ModelID.make("gpt-5.2"), - variant: "xhigh", - }) - expect(match.info.model.variant).toBe("xhigh") - - const override = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - variant: "high", - parts: [{ type: "text", text: "hello third" }], - }) - if (override.info.role !== "user") throw new Error("expected user message") - expect(override.info.model.variant).toBe("high") - - yield* sessions.remove(session.id) - }), - ), - }) - } finally { - if (prev === undefined) delete process.env.OPENAI_API_KEY - else process.env.OPENAI_API_KEY = prev - } - }) -}) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "run bash" }], + }) -// kilocode_change start -function deferred() { - const result = {} as { promise: Promise; resolve: (value: T) => void } - result.promise = new Promise((resolve) => { - result.resolve = resolve - }) - return result + yield* llm.tool("bash", { + command: + 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30', + description: "Print many lines", + timeout: 30_000, + workdir: path.resolve(dir), + }) + + const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* Effect.sleep(150) + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(run) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isFailure(exit)) return + + const tool = completedTool(exit.value.parts) + if (!tool) return + + expect(tool.state.metadata.truncated).toBe(true) + expect(typeof tool.state.metadata.outputPath).toBe("string") + expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/) + expect(tool.state.output).not.toContain("Tool execution aborted") + }), + { git: true, config: providerCfg }, + ), + 30_000, +) + +unix( + "cancel interrupts loop queued behind shell", + () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.sleep(50) + + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(loop) + expect(Exit.isSuccess(exit)).toBe(true) + + yield* Fiber.await(sh) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +unix( + "shell rejects when another shell is already running", + () => + withSh(() => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const a = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + const exit = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "echo hi" }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } + + yield* prompt.cancel(chat.id) + yield* Fiber.await(a) + }), + { git: true, config: cfg }, + ), + ), + 30_000, +) + +// Abort signal propagation tests for inline tool execution + +/** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */ +function hangUntilAborted(tool: { execute: (...args: any[]) => any }) { + const ready = defer() + const aborted = defer() + const original = tool.execute + tool.execute = (_args: any, ctx: any) => { + ready.resolve() + ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) + return Effect.callback(() => {}) + } + const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original))) + return { ready, aborted, restore } } -describe("session.prompt abort", () => { - test("returns the interrupted assistant turn when the current prompt is cancelled", async () => { - const dir = path.dirname(fileURLToPath(import.meta.url)) - const fixtures = (await Bun.file(path.join(dir, "../tool/fixtures/models-api.json")).json()) as Record< - string, - { models: Record } & Record - > - const model = fixtures.openai.models["gpt-5.2"] - const started = deferred() - const payload = new TextEncoder().encode( - [ - `data: ${JSON.stringify({ - type: "response.created", - response: { - id: "resp-1", - created_at: Math.floor(Date.now() / 1000), - model: model.id, - service_tier: null, - }, - })}`, - "", - ].join("\n\n"), - ) - const server = Bun.serve({ - port: 0, - fetch(req: Request) { - const url = new URL(req.url) - if (!url.pathname.endsWith("/responses")) { - return new Response("unexpected request", { status: 404 }) - } - started.resolve() - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(payload) +it.live( + "interrupt propagates abort signal to read tool via file part (text/plain)", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const { read } = yield* registry.named() + const { ready, aborted, restore } = hangUntilAborted(read) + yield* restore + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Abort Test" }) + + const testFile = path.join(dir, "test.txt") + yield* Effect.promise(() => Bun.write(testFile, "hello world")) + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [ + { type: "text", text: "read this" }, + { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" }, + ], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(() => ready.promise) + yield* Fiber.interrupt(fiber) + + yield* Effect.promise(() => + Promise.race([ + aborted.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), + ), + ]), + ) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.live( + "interrupt propagates abort signal to read tool via file part (directory)", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const { read } = yield* registry.named() + const { ready, aborted, restore } = hangUntilAborted(read) + yield* restore + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Abort Test" }) + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [ + { type: "text", text: "read this" }, + { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" }, + ], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(() => ready.promise) + yield* Fiber.interrupt(fiber) + + yield* Effect.promise(() => + Promise.race([ + aborted.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), + ), + ]), + ) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +// Missing file handling + +it.live("does not fail the prompt when a file part is missing", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const missing = path.join(dir, "does-not-exist.ts") + const msg = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "please review @does-not-exist.ts" }, + { + type: "file", + mime: "text/plain", + url: `file://${missing}`, + filename: "does-not-exist.ts", }, - }), - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }, + ], + }) + + if (msg.info.role !== "user") throw new Error("expected user message") + const hasFailure = msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"), ) - }, - }) + expect(hasFailure).toBe(true) - try { - await using tmp = await tmpdir({ - git: true, - init: async (root) => { - // kilocode_change start — project config must be at root, not in .opencode/ subdirectory - await Bun.write( - path.join(root, "opencode.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - enabled_providers: ["openai"], - provider: { - openai: { - name: "OpenAI", - env: ["OPENAI_API_KEY"], - npm: "@ai-sdk/openai", - api: "https://api.openai.com/v1", - models: { - [model.id]: model, - }, - options: { - apiKey: "test-openai-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - }), - ) - }, + yield* sessions.remove(session.id) + }), + { git: true, config: cfg }, + ), +) + +it.live("keeps stored part order stable when file resolution is async", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const missing = path.join(dir, "still-missing.ts") + const msg = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [ + { + type: "file", + mime: "text/plain", + url: `file://${missing}`, + filename: "still-missing.ts", + }, + { type: "text", text: "after-file" }, + ], + }) + + if (msg.info.role !== "user") throw new Error("expected user message") + + const stored = MessageV2.get({ + sessionID: session.id, + messageID: msg.info.id, + }) + const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text) + + expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true) + expect(text[1]?.includes("Read tool failed to read")).toBe(true) + expect(text[2]).toBe("after-file") + + yield* sessions.remove(session.id) + }), + { git: true, config: cfg }, + ), +) + +// Special characters in filenames + +it.live("handles filenames with # character", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(path.join(dir, "file#name.txt"), "special content\n")) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const parts = yield* prompt.resolvePromptParts("Read @file#name.txt") + const fileParts = parts.filter((part) => part.type === "file") + + expect(fileParts.length).toBe(1) + expect(fileParts[0].filename).toBe("file#name.txt") + expect(fileParts[0].url).toContain("%23") + + const decodedPath = fileURLToPath(fileParts[0].url) + expect(decodedPath).toBe(path.join(dir, "file#name.txt")) + + const message = yield* prompt.prompt({ + sessionID: session.id, + parts, + noReply: true, + }) + const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const textParts = stored.parts.filter((part) => part.type === "text") + const hasContent = textParts.some((part) => part.text.includes("special content")) + expect(hasContent).toBe(true) + + yield* sessions.remove(session.id) + }), + { git: true, config: cfg }, + ), +) + +// Regression: empty assistant turn loop + +it.live("does not loop empty assistant turns for a simple reply", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt regression" }) + + yield* llm.text("packages/opencode/src/session/processor.ts") + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Where is SessionProcessor?" }], }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const run = SessionPrompt.prompt({ + expect(result.info.role).toBe("assistant") + expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) + expect(yield* llm.calls).toBe(1) + }), + { git: true, config: providerCfg }, + ), +) + +it.live( + "records aborted errors when prompt is cancelled mid-stream", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt cancel regression" }) + + yield* llm.hang + + const fiber = yield* prompt + .prompt({ sessionID: session.id, - model: { - providerID: ProviderID.make("openai"), - modelID: ModelID.make(model.id), - }, - parts: [{ type: "text", text: "say hello" }], + agent: "build", + parts: [{ type: "text", text: "Cancel me" }], }) + .pipe(Effect.forkChild) - await started.promise - SessionPrompt.cancel(session.id) - - const result = await run - expect(result.info.role).toBe("assistant") - if (result.info.role !== "assistant") throw new Error("expected assistant message") + yield* llm.wait(1) + yield* prompt.cancel(session.id) - // kilocode_change start — re-read from DB; the abort error is set asynchronously by the processor - const messages = await Session.messages({ sessionID: session.id }) - const assistant = messages.find((item) => item.info.role === "assistant") - expect(assistant).toBeDefined() - expect(assistant?.info.id).toBe(result.info.id) - if (assistant?.info.role === "assistant" && assistant.info.error) { - expect(assistant.info.error.name).toBe("MessageAbortedError") + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + if (exit.value.info.role === "assistant") { + expect(exit.value.info.error?.name).toBe("MessageAbortedError") } - // kilocode_change end + } + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const last = msgs.findLast((msg) => msg.info.role === "assistant") + expect(last?.info.role).toBe("assistant") + if (last?.info.role === "assistant") { + expect(last.info.error?.name).toBe("MessageAbortedError") + } + }), + { git: true, config: providerCfg }, + ), + 3_000, +) - await Session.remove(session.id) +// Agent variant + +it.live("applies agent variant only when using agent model", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const other = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") }, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + if (other.info.role !== "user") throw new Error("expected user message") + expect(other.info.model.variant).toBeUndefined() + + const match = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello again" }], + }) + if (match.info.role !== "user") throw new Error("expected user message") + expect(match.info.model).toEqual({ + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), + variant: "xhigh", + }) + expect(match.info.model.variant).toBe("xhigh") + + const override = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + variant: "high", + parts: [{ type: "text", text: "hello third" }], + }) + if (override.info.role !== "user") throw new Error("expected user message") + expect(override.info.model.variant).toBe("high") + + yield* sessions.remove(session.id) + }), + { + git: true, + config: { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + models: { + "test-model": { + ...cfg.provider.test.models["test-model"], + variants: { xhigh: {}, high: {} }, + }, + }, + }, }, - }) - } finally { - server.stop(true) - } - }, 15000) -}) -// kilocode_change end - -describe("session.agent-resolution", () => { - test("unknown agent throws typed error", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const err = yield* Effect.promise(() => - Effect.runPromise( - prompt.prompt({ - sessionID: session.id, - agent: "nonexistent-agent-xyz", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }), - ).then( - () => undefined, - (e) => e, - ), - ) - expect(err).toBeDefined() + agent: { + build: { + model: "test/test-model", + variant: "xhigh", + }, + }, + }, + }, + ), +) + +// Agent / command resolution errors + +it.live( + "unknown agent throws typed error", + () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const exit = yield* prompt + .prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) expect(err).not.toBeInstanceOf(TypeError) expect(NamedError.Unknown.isInstance(err)).toBe(true) if (NamedError.Unknown.isInstance(err)) { expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"') } - }), - ), - }) - }, 30000) - - test("unknown agent error includes available agent names", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const err = yield* Effect.promise(() => - Effect.runPromise( - prompt.prompt({ - sessionID: session.id, - agent: "nonexistent-agent-xyz", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }), - ).then( - () => undefined, - (e) => e, - ), - ) + } + }), + { git: true }, + ), + 30_000, +) + +it.live( + "unknown agent error includes available agent names", + () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const exit = yield* prompt + .prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) expect(NamedError.Unknown.isInstance(err)).toBe(true) if (NamedError.Unknown.isInstance(err)) { expect(err.data.message).toContain("code") // kilocode_change - "build" renamed to "code" } - }), - ), - }) - }, 30000) - - test("unknown command throws typed error with available names", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const err = yield* Effect.promise(() => - Effect.runPromise( - prompt.command({ - sessionID: session.id, - command: "nonexistent-command-xyz", - arguments: "", - }), - ).then( - () => undefined, - (e) => e, - ), - ) - expect(err).toBeDefined() + } + }), + { git: true }, + ), + 30_000, +) + +it.live( + "unknown command throws typed error with available names", + () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const exit = yield* prompt + .command({ + sessionID: session.id, + command: "nonexistent-command-xyz", + arguments: "", + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) expect(err).not.toBeInstanceOf(TypeError) expect(NamedError.Unknown.isInstance(err)).toBe(true) if (NamedError.Unknown.isInstance(err)) { expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"') expect(err.data.message).toContain("init") } - }), - ), - }) - }, 30000) -}) + } + }), + { git: true }, + ), + 30_000, +) diff --git a/packages/opencode/test/session/structured-output.test.ts b/packages/opencode/test/session/structured-output.test.ts index 2debfb76d57..c734a182aee 100644 --- a/packages/opencode/test/session/structured-output.test.ts +++ b/packages/opencode/test/session/structured-output.test.ts @@ -5,7 +5,7 @@ import { SessionID, MessageID } from "../../src/session/schema" describe("structured-output.OutputFormat", () => { test("parses text format", () => { - const result = MessageV2.Format.safeParse({ type: "text" }) + const result = MessageV2.Format.zod.safeParse({ type: "text" }) expect(result.success).toBe(true) if (result.success) { expect(result.data.type).toBe("text") @@ -13,7 +13,7 @@ describe("structured-output.OutputFormat", () => { }) test("parses json_schema format with defaults", () => { - const result = MessageV2.Format.safeParse({ + const result = MessageV2.Format.zod.safeParse({ type: "json_schema", schema: { type: "object", properties: { name: { type: "string" } } }, }) @@ -27,7 +27,7 @@ describe("structured-output.OutputFormat", () => { }) test("parses json_schema format with custom retryCount", () => { - const result = MessageV2.Format.safeParse({ + const result = MessageV2.Format.zod.safeParse({ type: "json_schema", schema: { type: "object" }, retryCount: 5, @@ -39,17 +39,17 @@ describe("structured-output.OutputFormat", () => { }) test("rejects invalid type", () => { - const result = MessageV2.Format.safeParse({ type: "invalid" }) + const result = MessageV2.Format.zod.safeParse({ type: "invalid" }) expect(result.success).toBe(false) }) test("rejects json_schema without schema", () => { - const result = MessageV2.Format.safeParse({ type: "json_schema" }) + const result = MessageV2.Format.zod.safeParse({ type: "json_schema" }) expect(result.success).toBe(false) }) test("rejects negative retryCount", () => { - const result = MessageV2.Format.safeParse({ + const result = MessageV2.Format.zod.safeParse({ type: "json_schema", schema: { type: "object" }, retryCount: -1, @@ -95,7 +95,7 @@ describe("structured-output.StructuredOutputError", () => { describe("structured-output.UserMessage", () => { test("user message accepts outputFormat", () => { - const result = MessageV2.User.safeParse({ + const result = MessageV2.User.zod.safeParse({ id: MessageID.ascending(), sessionID: SessionID.descending(), role: "user", @@ -111,7 +111,7 @@ describe("structured-output.UserMessage", () => { }) test("user message works without outputFormat (optional)", () => { - const result = MessageV2.User.safeParse({ + const result = MessageV2.User.zod.safeParse({ id: MessageID.ascending(), sessionID: SessionID.descending(), role: "user", @@ -140,7 +140,7 @@ describe("structured-output.AssistantMessage", () => { } test("assistant message accepts structured", () => { - const result = MessageV2.Assistant.safeParse({ + const result = MessageV2.Assistant.zod.safeParse({ ...baseAssistantMessage, structured: { company: "Anthropic", founded: 2021 }, }) @@ -151,7 +151,7 @@ describe("structured-output.AssistantMessage", () => { }) test("assistant message works without structured_output (optional)", () => { - const result = MessageV2.Assistant.safeParse(baseAssistantMessage) + const result = MessageV2.Assistant.zod.safeParse(baseAssistantMessage) expect(result.success).toBe(true) }) }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c653717c881..44636833d8a 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -195,6 +195,35 @@ describe("tool.apply_patch freeform", () => { }) }) + test("does not invent a first-line diff for BOM files", async () => { + await using fixture = await tmpdir() + const { ctx, calls } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const bom = String.fromCharCode(0xfeff) + const target = path.join(fixture.path, "example.cs") + await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8") + + const patchText = + "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(calls.length).toBe(1) + const shown = calls[0].metadata.files[0]?.patch ?? "" + expect(shown).not.toContain(bom) + expect(shown).not.toContain("-using System;") + expect(shown).not.toContain("+using System;") + + const content = await fs.readFile(target, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n") + }, + }) + }) + test("inserts lines with insert-only hunk", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 4759b8be360..82e1b4a7fd4 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -29,11 +29,6 @@ afterEach(async () => { await Instance.disposeAll() }) -async function touch(file: string, time: number) { - const date = new Date(time) - await fs.utimes(file, date, date) -} - const runtime = ManagedRuntime.make( Layer.mergeAll( LSP.defaultLayer, @@ -101,6 +96,37 @@ describe("tool.edit", () => { }) }) + test("preserves BOM when oldString is empty on existing files", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing.cs") + const bom = String.fromCharCode(0xfeff) + await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "using Up;\n", + }, + ctx, + ), + ) + + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + + const content = await fs.readFile(filepath, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }, + }) + }) + test("creates new file with nested directories", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nested", "dir", "file.txt") @@ -188,6 +214,38 @@ describe("tool.edit", () => { }) }) + test("replaces the first visible line in BOM files", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing.cs") + const bom = String.fromCharCode(0xfeff) + await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "using System;", + newString: "using Up;", + }, + ctx, + ), + ) + + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + expect(result.metadata.diff).not.toContain(bom) + + const content = await fs.readFile(filepath, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\nclass Test {}\n") + }, + }) + }) + test("throws error when file does not exist", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nonexistent.txt") @@ -639,42 +697,56 @@ describe("tool.edit", () => { }) describe("concurrent editing", () => { - test("serializes concurrent edits to same file", async () => { + test("preserves concurrent edits to different sections of the same file", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "0", "utf-8") + await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8") await Instance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() + let asks = 0 + const firstAsk = Promise.withResolvers() + const delayedCtx = { + ...ctx, + ask: () => + Effect.gen(function* () { + asks++ + if (asks !== 1) return + firstAsk.resolve() + yield* Effect.promise(() => Bun.sleep(50)) + }), + } - // Two concurrent edits const promise1 = Effect.runPromise( edit.execute( { filePath: filepath, - oldString: "0", - newString: "1", + oldString: "top = 0", + newString: "top = 1", }, - ctx, + delayedCtx, ), ) + await firstAsk.promise + const promise2 = Effect.runPromise( edit.execute( { filePath: filepath, - oldString: "0", - newString: "2", + oldString: "bottom = 0", + newString: "bottom = 2", }, - ctx, + delayedCtx, ), ) - // Both should complete without error (though one might fail due to content mismatch) const results = await Promise.allSettled([promise1, promise2]) - expect(results.some((r) => r.status === "fulfilled")).toBe(true) + expect(results[0]?.status).toBe("fulfilled") + expect(results[1]?.status).toBe("fulfilled") + expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n") }, }) }) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 330191a8fe0..3f6525e0858 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -34,8 +34,11 @@ const ctx = { ask: () => Effect.void, } +// kilocode_change - skip on windows: address windows ci failures #9496 +const unix = process.platform !== "win32" ? it.live : it.live.skip + describe("tool.glob", () => { - it.live("matches files from a directory path", () => + unix("matches files from a directory path", () => provideTmpdirInstance((dir) => Effect.gen(function* () { yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) @@ -82,7 +85,7 @@ describe("tool.glob", () => { ) // kilocode_change start - absolute glob patterns outside the project - it.live("supports absolute glob patterns outside the project", () => + unix("supports absolute glob patterns outside the project", () => provideTmpdirInstance( (_dir) => Effect.gen(function* () { diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index bae9faeb02e..84a8e2c0836 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -30,8 +30,11 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) +// kilocode_change - skip on windows: address windows ci failures #9496 +const unix = process.platform !== "win32" ? it.live : it.live.skip + describe("tool.skill", () => { - it.live("execute returns skill content block with files", () => + unix("execute returns skill content block with files", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 50d3b57527f..36131f9596a 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -114,6 +114,54 @@ describe("tool.write", () => { ), ) + it.live("preserves BOM when overwriting existing files", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ), + ) + + it.live("restores BOM after formatter strips it", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "formatted.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + { + config: { + formatter: { + stripbom: { + extensions: [".cs"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", + "$FILE", + ], + }, + }, + }, + }, + ), + ) + it.live("returns diff in metadata for existing files", () => provideTmpdirInstance((dir) => Effect.gen(function* () { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 003945b4344..70cd8f0e647 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "../../src/util/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) @@ -751,119 +751,4 @@ describe("util.effect-zod", () => { expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) }) }) - - describe("ZodPreprocess annotation", () => { - test("preprocess runs on raw input before the inner schema parses", () => { - // Models the permission.ts __originalKeys pattern: capture the original - // insertion order of a user-provided object BEFORE Schema parsing - // canonicalises the keys. - const preprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __keys: Object.keys(val), ...(val as Record) } - } - return val - } - const Inner = Schema.Struct({ - __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - a: Schema.optional(Schema.String), - b: Schema.optional(Schema.String), - }).annotate({ [ZodPreprocess]: preprocess }) - - const schema = zod(Inner) - const parsed = schema.parse({ b: "1", a: "2" }) as { - __keys?: string[] - a?: string - b?: string - } - expect(parsed.__keys).toEqual(["b", "a"]) - expect(parsed.a).toBe("2") - expect(parsed.b).toBe("1") - }) - - test("preprocess does not transform already-shaped input", () => { - // When the user passes an object that already has __keys, preprocess - // returns it unchanged because spreading preserves any existing key. - const preprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !("__keys" in val)) { - return { __keys: Object.keys(val), ...(val as Record) } - } - return val - } - const Inner = Schema.Struct({ - __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - a: Schema.optional(Schema.String), - }).annotate({ [ZodPreprocess]: preprocess }) - - const schema = zod(Inner) - const parsed = schema.parse({ __keys: ["existing"], a: "hi" }) as { - __keys?: string[] - a?: string - } - expect(parsed.__keys).toEqual(["existing"]) - }) - - test("preprocess composes with a union (either object or string)", () => { - // Mirrors permission.ts exactly: input can be either an object (with - // preprocess injecting metadata) or a plain string action. - const Action = Schema.Literals(["ask", "allow", "deny"]) - const Obj = Schema.Struct({ - __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - read: Schema.optional(Action), - write: Schema.optional(Action), - }) - const preprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __keys: Object.keys(val), ...(val as Record) } - } - return val - } - const Inner = Schema.Union([Obj, Action]).annotate({ [ZodPreprocess]: preprocess }) - const schema = zod(Inner) - - // String branch — passes through preprocess unchanged - expect(schema.parse("allow")).toBe("allow") - - // Object branch — __keys injected, preserves order - const parsed = schema.parse({ write: "allow", read: "deny" }) as { - __keys?: string[] - read?: string - write?: string - } - expect(parsed.__keys).toEqual(["write", "read"]) - expect(parsed.write).toBe("allow") - expect(parsed.read).toBe("deny") - }) - - test("JSON Schema output comes from the inner schema — preprocess is runtime-only", () => { - const Inner = Schema.Struct({ - a: Schema.optional(Schema.String), - b: Schema.optional(Schema.Number), - }).annotate({ [ZodPreprocess]: (v: unknown) => v }) - const shape = json(zod(Inner)) as any - expect(shape.type).toBe("object") - expect(shape.properties.a.type).toBe("string") - expect(shape.properties.b.type).toBe("number") - }) - - test("identifier + description propagate through the preprocess wrapper", () => { - const Inner = Schema.Struct({ - x: Schema.optional(Schema.String), - }).annotate({ - identifier: "WithPreproc", - description: "A schema with preprocess", - [ZodPreprocess]: (v: unknown) => v, - }) - const schema = zod(Inner) - expect(schema.meta()?.ref).toBe("WithPreproc") - expect(schema.meta()?.description).toBe("A schema with preprocess") - }) - - test("preprocess inside a struct field applies only to that field", () => { - const Inner = Schema.String.annotate({ - [ZodPreprocess]: (v: unknown) => (typeof v === "number" ? String(v) : v), - }) - const schema = zod(Schema.Struct({ name: Inner, raw: Schema.Number })) - expect(schema.parse({ name: 42, raw: 7 })).toEqual({ name: "42", raw: 7 }) - }) - }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ee97f89f120..c6797a7c80f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -34,8 +34,8 @@ } }, "devDependencies": { - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "typescript": "catalog:", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 4a483239811..b3c6a9b980a 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -49,7 +49,7 @@ export type WorkspaceAdaptor = { name: string description: string configure(config: WorkspaceInfo): WorkspaceInfo | Promise - create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise + create(config: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise remove(config: WorkspaceInfo): Promise target(config: WorkspaceInfo): WorkspaceTarget | Promise } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fa3eed65a62..3be20d868e4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1044,6 +1044,7 @@ export type CompactionPart = { type: "compaction" auto: boolean overflow?: boolean + tail_start_id?: string } export type Part = @@ -1078,6 +1079,13 @@ export type EventMessagePartRemoved = { } } +export type SnapshotSummaryFileDiff = { + file: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -1099,12 +1107,7 @@ export type Session = { additions: number deletions: number files: number - diffs?: Array<{ - file: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> + diffs?: Array } share?: { url: string @@ -1231,12 +1234,7 @@ export type SyncEventSessionUpdated = { additions: number deletions: number files: number - diffs?: Array<{ - file: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> + diffs?: Array } | null share?: { url: string | null @@ -1384,8 +1382,8 @@ export type PermissionObjectConfig = { export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig export type PermissionConfig = + | PermissionActionConfig | { - __originalKeys?: Array read?: PermissionRuleConfig edit?: PermissionRuleConfig glob?: PermissionRuleConfig @@ -1402,9 +1400,8 @@ export type PermissionConfig = lsp?: PermissionRuleConfig doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig - [key: string]: PermissionRuleConfig | Array | PermissionActionConfig | undefined + [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined } - | PermissionActionConfig export type AgentConfig = { model?: string | null @@ -1840,6 +1837,14 @@ export type Config = { * Enable pruning of old tool outputs (default: true) */ prune?: boolean + /** + * Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2) + */ + tail_turns?: number + /** + * Maximum number of tokens from recent turns to preserve verbatim after compaction + */ + preserve_recent_tokens?: number /** * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. */ @@ -2082,12 +2087,7 @@ export type GlobalSession = { additions: number deletions: number files: number - diffs?: Array<{ - file: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> + diffs?: Array } share?: { url: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1a13f65118f..d68ae5ced5d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -13260,6 +13260,10 @@ }, "overflow": { "type": "boolean" + }, + "tail_start_id": { + "type": "string", + "pattern": "^msg.*" } }, "required": ["id", "sessionID", "messageID", "type", "auto"] @@ -14361,15 +14365,12 @@ }, "PermissionConfig": { "anyOf": [ + { + "$ref": "#/components/schemas/PermissionActionConfig" + }, { "type": "object", "properties": { - "__originalKeys": { - "type": "array", - "items": { - "type": "string" - } - }, "read": { "$ref": "#/components/schemas/PermissionRuleConfig" }, @@ -14422,9 +14423,6 @@ "additionalProperties": { "$ref": "#/components/schemas/PermissionRuleConfig" } - }, - { - "$ref": "#/components/schemas/PermissionActionConfig" } ] }, @@ -15310,6 +15308,18 @@ "description": "Enable pruning of old tool outputs (default: true)", "type": "boolean" }, + "tail_turns": { + "description": "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "preserve_recent_tokens": { + "description": "Maximum number of tokens from recent turns to preserve verbatim after compaction", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "reserved": { "description": "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", "type": "integer", diff --git a/packages/shared/src/types.d.ts b/packages/shared/src/types.d.ts index b5d667f1d91..60e1639adb2 100644 --- a/packages/shared/src/types.d.ts +++ b/packages/shared/src/types.d.ts @@ -5,6 +5,7 @@ declare module "@npmcli/arborist" { progress?: boolean savePrefix?: string ignoreScripts?: boolean + [key: string]: unknown } export interface ArboristNode { @@ -24,6 +25,7 @@ declare module "@npmcli/arborist" { add?: string[] save?: boolean saveType?: "prod" | "dev" | "optional" | "peer" + [key: string]: unknown } export class Arborist { diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index fe32c28fa9d..7ab72baaa53 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -388,7 +388,7 @@ export const SessionReview = (props: SessionReviewProps) => { const file = diff.file // binary files have empty diffs that we can't render - const diffCanRender = () => diff.additions !== 0 && diff.deletions !== 0 + const diffCanRender = () => diff.additions !== 0 || diff.deletions !== 0 const expanded = createMemo(() => open().includes(file)) const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file))) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 3e8e7f1ca1f..7542c930774 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -280,14 +280,12 @@ export function SessionTurn( if (!msg) return emptyAssistant const messages = allMessages() ?? emptyMessages - const index = messageIndex() - if (index < 0) return emptyAssistant + if (messageIndex() < 0) return emptyAssistant const result: AssistantMessage[] = [] - for (let i = index + 1; i < messages.length; i++) { + for (let i = 0; i < messages.length; i++) { const item = messages[i] if (!item) continue - if (item.role === "user") break if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage) } return result diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index 5f8378201fa..15fe8eacaed 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -318,7 +318,7 @@ const TOOL_SAMPLES = { tool: "bash", input: { command: "bun test --filter session", description: "Run session tests" }, output: - "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", + "bun test v1.3.13\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", title: "Run session tests", metadata: { command: "bun test --filter session" }, }, diff --git a/patches/@npmcli%2Fagent@4.0.0.patch b/patches/@npmcli%2Fagent@4.0.0.patch new file mode 100644 index 00000000000..a3506a90e3d --- /dev/null +++ b/patches/@npmcli%2Fagent@4.0.0.patch @@ -0,0 +1,13 @@ +diff --git a/lib/agents.js b/lib/agents.js +index 45ec59c4c13757379095131c4f0a5ea6f7284f45..0763b031e355a755ec6a26f98461aa3f63b8339b 100644 +--- a/lib/agents.js ++++ b/lib/agents.js +@@ -32,7 +32,7 @@ module.exports = class Agent extends AgentBase { + } + + get proxy () { +- return this.#proxy ? { url: this.#proxy } : {} ++ return this.#proxy ? { url: this.#proxy.toString() } : {} + } + + #getProxy (options) { diff --git a/script/beta.ts b/script/beta.ts index b243aac79ac..e095e5e98d9 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -61,7 +61,7 @@ async function typecheck() { console.log(" Running typecheck...") try { - await $`bun typecheck`.cwd("packages/opencode") + await $`bun typecheck` return true } catch (err) { console.log(`Typecheck failed: ${err}`) @@ -113,7 +113,7 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n "If bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.", "If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.", "If a PR already changed an import, keep that change.", - "After resolving the conflicts, run `bun typecheck` in `packages/opencode`.", + "After resolving the conflicts, run `bun typecheck` at the repo root.", "If typecheck fails, you may also update any files reported by typecheck.", "Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.", "Fix any merge-caused typecheck errors before finishing.", @@ -149,7 +149,7 @@ async function smoke(prs: PR[], applied: number[]) { const prompt = [ "The beta merge batch is complete.", `Merged PRs on HEAD:\n${done}`, - "Run `bun typecheck` in `packages/opencode`.", + "Run `bun typecheck` at the repo root.", "Run `./script/build.ts --single` in `packages/opencode`.", "Fix any merge-caused issues until both commands pass.", "Do not create a commit.", @@ -295,10 +295,7 @@ async function main() { } if (applied.length > 0) { - const ok = await smoke(prs, applied) - if (!ok) { - throw new Error("Final smoke check failed") - } + console.log("\nSkipping final smoke check") } console.log("\nChecking if beta branch has changes...")