From 4472e6d7693bfcdad0f39463f4db7fddb7e192d0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 2 Jun 2026 13:51:23 +0200 Subject: [PATCH] refactor: kilo compat for v1.14.46 --- .opencode-version | 2 +- bun.lock | 34 +- package.json | 2 +- packages/core/package.json | 2 +- .../src/util => core/src}/effect-zod.ts | 0 packages/core/src/flag/flag.ts | 11 + .../{opencode/src/util => core/src}/schema.ts | 0 packages/extensions/zed/extension.toml | 12 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- .../migration.sql | 2 +- packages/opencode/package.json | 6 +- packages/opencode/specs/effect/migration.md | 4 +- packages/opencode/specs/effect/schema.md | 6 +- .../specs/openapi-translation-cleanup.md | 204 ++ .../opencode/specs/v2/tui-command-shim.md | 67 + packages/opencode/src/acp/agent.ts | 130 +- packages/opencode/src/agent/agent.ts | 4 +- .../src/agent/subagent-permissions.ts | 33 + packages/opencode/src/auth/index.ts | 4 +- packages/opencode/src/cli/cmd/export.ts | 6 +- .../cli/cmd/tui/context/aggregate-failures.ts | 34 + .../opencode/src/cli/cmd/tui/context/sync.tsx | 30 +- packages/opencode/src/cli/cmd/tui/event.ts | 2 +- .../cmd/tui/feature-plugins/sidebar/lsp.tsx | 4 +- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 7 +- .../src/cli/cmd/tui/plugin/command-shim.ts | 109 + .../src/cli/cmd/tui/plugin/runtime.ts | 3 + packages/opencode/src/command/index.ts | 4 +- packages/opencode/src/config/agent.ts | 4 +- packages/opencode/src/config/command.ts | 4 +- packages/opencode/src/config/config.ts | 6 +- packages/opencode/src/config/console-state.ts | 4 +- packages/opencode/src/config/formatter.ts | 4 +- packages/opencode/src/config/layout.ts | 4 +- packages/opencode/src/config/lsp.ts | 4 +- packages/opencode/src/config/mcp.ts | 4 +- packages/opencode/src/config/model-id.ts | 4 +- packages/opencode/src/config/parse.ts | 2 +- packages/opencode/src/config/permission.ts | 4 +- packages/opencode/src/config/plugin.ts | 4 +- packages/opencode/src/config/provider.ts | 7 +- packages/opencode/src/config/reference.ts | 4 +- packages/opencode/src/config/server.ts | 4 +- packages/opencode/src/config/skills.ts | 4 +- packages/opencode/src/control-plane/schema.ts | 4 +- packages/opencode/src/control-plane/types.ts | 2 +- packages/opencode/src/file/index.ts | 4 +- packages/opencode/src/file/ripgrep.ts | 4 +- packages/opencode/src/format/index.ts | 4 +- packages/opencode/src/lsp/lsp.ts | 4 +- packages/opencode/src/markdown.d.ts | 4 + packages/opencode/src/mcp/index.ts | 52 +- packages/opencode/src/permission/index.ts | 4 +- packages/opencode/src/permission/schema.ts | 4 +- packages/opencode/src/project/project.ts | 4 +- packages/opencode/src/project/schema.ts | 4 +- packages/opencode/src/project/vcs.ts | 17 +- packages/opencode/src/provider/auth.ts | 4 +- .../opencode/src/provider/model-status.ts | 9 + packages/opencode/src/provider/models.ts | 3 +- packages/opencode/src/provider/provider.ts | 21 +- packages/opencode/src/provider/schema.ts | 4 +- packages/opencode/src/pty/index.ts | 4 +- packages/opencode/src/pty/schema.ts | 4 +- packages/opencode/src/pty/ticket.ts | 2 +- packages/opencode/src/question/index.ts | 4 +- packages/opencode/src/question/schema.ts | 4 +- .../server/routes/instance/httpapi/event.ts | 2 + .../routes/instance/httpapi/groups/config.ts | 5 +- .../instance/httpapi/groups/experimental.ts | 29 +- .../routes/instance/httpapi/groups/file.ts | 11 +- .../instance/httpapi/groups/instance.ts | 18 +- .../routes/instance/httpapi/groups/mcp.ts | 10 +- .../instance/httpapi/groups/permission.ts | 4 +- .../routes/instance/httpapi/groups/project.ts | 6 +- .../instance/httpapi/groups/provider.ts | 6 +- .../routes/instance/httpapi/groups/pty.ts | 19 +- .../routes/instance/httpapi/groups/query.ts | 8 + .../instance/httpapi/groups/question.ts | 5 +- .../routes/instance/httpapi/groups/session.ts | 47 +- .../routes/instance/httpapi/groups/sync.ts | 8 +- .../routes/instance/httpapi/groups/tui.ts | 15 +- .../instance/httpapi/groups/v2/message.ts | 55 +- .../instance/httpapi/groups/v2/session.ts | 84 +- .../instance/httpapi/groups/workspace.ts | 9 +- .../instance/httpapi/handlers/config.ts | 2 +- .../instance/httpapi/handlers/experimental.ts | 2 +- .../instance/httpapi/handlers/provider.ts | 2 +- .../instance/httpapi/handlers/v2/message.ts | 1 + .../instance/httpapi/handlers/v2/session.ts | 27 + .../instance/httpapi/middleware/error.ts | 26 - .../httpapi/middleware/workspace-routing.ts | 14 +- .../server/routes/instance/httpapi/public.ts | 61 +- packages/opencode/src/session/message-v2.ts | 32 +- packages/opencode/src/session/message.ts | 14 +- packages/opencode/src/session/prompt.ts | 6 +- packages/opencode/src/session/revert.ts | 4 +- packages/opencode/src/session/schema.ts | 4 +- packages/opencode/src/session/session.ts | 12 +- packages/opencode/src/session/status.ts | 4 +- packages/opencode/src/session/summary.ts | 5 +- packages/opencode/src/session/todo.ts | 4 +- packages/opencode/src/skill/index.ts | 24 +- .../src/skill/prompt/customize-opencode.md | 354 +++ packages/opencode/src/snapshot/index.ts | 15 +- packages/opencode/src/storage/storage.ts | 2 +- packages/opencode/src/sync/index.ts | 2 +- packages/opencode/src/sync/schema.ts | 4 +- packages/opencode/src/tool/read.ts | 4 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/schema.ts | 4 +- packages/opencode/src/tool/shell/prompt.ts | 2 +- packages/opencode/src/tool/task.ts | 37 +- .../opencode/src/util/named-schema-error.ts | 2 +- packages/opencode/src/v2/auth.ts | 2 +- packages/opencode/src/v2/event.ts | 2 +- packages/opencode/src/v2/model.ts | 5 +- packages/opencode/src/v2/session-event.ts | 16 +- packages/opencode/src/v2/session.ts | 2 +- .../test/acp/event-subscription.test.ts | 181 +- .../agent/plan-mode-subagent-bypass.test.ts | 141 + .../cli/cmd/tui/aggregate-failures.test.ts | 55 + .../test/cli/cmd/tui/sync-fixture.tsx | 120 + .../cmd/tui/sync-undefined-messages.test.tsx | 47 + .../opencode/test/cli/cmd/tui/sync.test.tsx | 121 +- packages/opencode/test/mcp/lifecycle.test.ts | 68 +- packages/opencode/test/preload.ts | 5 + .../test/provider/model-status.test.ts | 61 + .../test/server/httpapi-config.test.ts | 37 + .../test/server/httpapi-exercise/index.ts | 114 +- .../httpapi-promptasync-context.test.ts | 189 ++ .../test/server/httpapi-provider.test.ts | 185 ++ .../server/httpapi-query-schema-drift.test.ts | 282 ++ .../opencode/test/server/httpapi-sdk.test.ts | 10 +- .../test/server/httpapi-session.test.ts | 37 + .../opencode/test/server/httpapi-sync.test.ts | 2 +- .../server/negative-tokens-regression.test.ts | 97 + .../test/server/sdk-error-shape.test.ts | 74 + .../server/session-diff-missing-patch.test.ts | 81 + .../test/server/session-messages.test.ts | 25 +- .../test/session/schema-decoding.test.ts | 20 + .../test/session/snapshot-tool-race.test.ts | 2 +- .../storage/workspace-time-migration.test.ts | 43 + .../opencode/test/tool/parameters.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 15 +- .../opencode/test/util/effect-zod.test.ts | 2 +- packages/plugin/package.json | 2 +- packages/plugin/src/tui.ts | 43 + packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/sdk/js/src/client.ts | 2 + packages/sdk/js/src/error-interceptor.ts | 51 + packages/sdk/js/src/gen/types.gen.ts | 4 +- packages/sdk/js/src/v2/client.ts | 21 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 20 + packages/sdk/js/src/v2/gen/types.gen.ts | 24 +- packages/sdk/openapi.json | 2312 +++++++++-------- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- packages/ui/src/components/session-diff.ts | 3 +- packages/ui/src/components/session-review.tsx | 9 +- packages/ui/src/components/session-turn.tsx | 11 +- 163 files changed, 4730 insertions(+), 1799 deletions(-) rename packages/{opencode/src/util => core/src}/effect-zod.ts (100%) rename packages/{opencode/src/util => core/src}/schema.ts (100%) create mode 100644 packages/opencode/specs/openapi-translation-cleanup.md create mode 100644 packages/opencode/specs/v2/tui-command-shim.md create mode 100644 packages/opencode/src/agent/subagent-permissions.ts create mode 100644 packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts create mode 100644 packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts create mode 100644 packages/opencode/src/markdown.d.ts create mode 100644 packages/opencode/src/provider/model-status.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/query.ts create mode 100644 packages/opencode/src/skill/prompt/customize-opencode.md create mode 100644 packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts create mode 100644 packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts create mode 100644 packages/opencode/test/cli/cmd/tui/sync-fixture.tsx create mode 100644 packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx create mode 100644 packages/opencode/test/provider/model-status.test.ts create mode 100644 packages/opencode/test/server/httpapi-promptasync-context.test.ts create mode 100644 packages/opencode/test/server/httpapi-query-schema-drift.test.ts create mode 100644 packages/opencode/test/server/negative-tokens-regression.test.ts create mode 100644 packages/opencode/test/server/sdk-error-shape.test.ts create mode 100644 packages/opencode/test/server/session-diff-missing-patch.test.ts create mode 100644 packages/opencode/test/storage/workspace-time-migration.test.ts create mode 100644 packages/sdk/js/src/error-interceptor.ts diff --git a/.opencode-version b/.opencode-version index 5ceca955de..dfbc2e6537 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.14.42 +v1.14.46 diff --git a/bun.lock b/bun.lock index 2d216eed60..9dcad8f126 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.42", + "version": "1.14.46", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.42", + "version": "1.14.46", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.42", + "version": "1.14.46", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/package.json b/package.json index fb162e559d..2c24470b0c 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.3.18", + "version": "7.3.22", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 3875f8a167..ddfaf9d4af 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.18", + "version": "7.3.22", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/core/src/effect-zod.ts similarity index 100% rename from packages/opencode/src/util/effect-zod.ts rename to packages/core/src/effect-zod.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index e58962aa3c..a05485abfd 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,13 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels where new experiments default to ON (unstable / internal users). +// Stable channels (`prod`, `latest`) stay opt-in. +const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"]) +function unstableDefault(key: string) { + return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel)) +} + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -48,6 +56,9 @@ export const Flag = { KILO_DISABLE_CLAUDE_CODE_PROMPT: KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT"), KILO_DISABLE_CLAUDE_CODE_SKILLS, KILO_DISABLE_EXTERNAL_SKILLS: truthy("KILO_DISABLE_EXTERNAL_SKILLS"), + // Default-on for dev/beta/local; opt-in for stable. Set + // KILO_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on. + KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"), KILO_FAKE_VCS: process.env["KILO_FAKE_VCS"], KILO_SERVER_PASSWORD: process.env["KILO_SERVER_PASSWORD"], KILO_SERVER_USERNAME: process.env["KILO_SERVER_USERNAME"], diff --git a/packages/opencode/src/util/schema.ts b/packages/core/src/schema.ts similarity index 100% rename from packages/opencode/src/util/schema.ts rename to packages/core/src/schema.ts diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index aba2476b2c..6a2272ebf3 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 = "1.14.42" +version = "7.3.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/v1.14.42/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.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/v1.14.42/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.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/v1.14.42/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.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/v1.14.42/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index e884ada770..6c852c38d3 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.18", + "version": "7.3.22", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 9d86bfe212..65d8c39d16 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.18", + "version": "7.3.22", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql index bdeed6bce1..c865526a88 100644 --- a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql +++ b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql @@ -1 +1 @@ -ALTER TABLE `workspace` ADD `time_used` integer NOT NULL; \ No newline at end of file +ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0; diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8cb9da2893..c4ad3e1f58 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.18", + "version": "7.3.22", "name": "@kilocode/cli", "type": "module", "license": "MIT", @@ -130,6 +130,7 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", @@ -190,8 +191,7 @@ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5", - "@opentui/keymap": "catalog:" + "zod-to-json-schema": "3.24.5" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 947eef5a15..01af9da6ce 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -59,10 +59,10 @@ Rules: ## Schema → Zod interop -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`: +When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: ```ts -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union ``` diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index e755457e61..e20605c3bc 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims. Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors. Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@/util/effect-zod`. +schema via `@opencode-ai/core/effect-zod`. The long-term driver is `specs/effect/http-api.md` — once the HTTP server moves to `@effect/platform`, every Schema-first DTO can flow through @@ -97,7 +97,7 @@ creating a parallel schema source of truth. ## Escape hatches -The walker in `@/util/effect-zod` exposes two explicit escape hatches for +The walker in `@opencode-ai/core/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. @@ -389,7 +389,7 @@ piecewise. ## Notes -- Use `@/util/effect-zod` for all Schema → Zod conversion. +- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. - Keep the migration incremental. Converting the domain model first is more diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md new file mode 100644 index 0000000000..55e4c7268d --- /dev/null +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -0,0 +1,204 @@ +# OpenAPI Translation Cleanup Plan + +## Goal + +Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface. + +The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation. + +## Current Culprit + +`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility. + +The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime. + +## Non-Negotiables + +- Do not break the generated JavaScript SDK without an explicit versioned migration plan. +- Runtime route schemas are the source of truth for accepted params, payloads, and responses. +- `/doc`, generated SDK types, and runtime validation must agree for every endpoint. +- Prefer endpoint or schema annotations over post-generation spec surgery. +- Remove one category of rewrite at a time, with focused compatibility checks. + +## PR Checklist + +Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started. + +Current combined PR scope: + +- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes. +- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs. + +### PR 1: Add OpenAPI/Runtime Query Drift Tests + +- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` Import `OpenApi.fromApi` and `PublicApi`. +- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`. +- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes. +- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema. +- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params. +- `[x]` Keep this part test-only. + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 2: Delete Spec-Only Workspace Query Injection + +- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- `[x]` Delete `InstanceQueryParameters`. +- `[x]` Delete the `isInstanceRoute` constant. +- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation. +- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`. +- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes. +- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only. + +Notes: + +- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params. +- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing. + +Expected code shape: + +```ts +for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`) +``` + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `[x]` `./packages/sdk/js/script/build.ts` from repo root. +- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers + +- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time. +- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`. +- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`. +- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet. + +Concrete first targets: + +- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. +- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter. +- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. + +Verification: + +- Focused HTTP tests for changed query fields. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK request param types before deleting each override. +- `bun typecheck` from `packages/opencode`. + +### PR 4: Move Path Parameter Patterns Into ID Schemas + +- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. +- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. +- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Delete one path override only after generated OpenAPI is unchanged for that param. + +Concrete first targets: + +- `sessionID` +- `messageID` +- `partID` +- `permissionID` +- `ptyID` + +Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated path param types and patterns. +- `bun typecheck` from `packages/opencode`. + +### PR 5: Replace Built-In Error Rewrites With Declared API Errors + +- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`. +- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there. +- Update handlers to fail with the declared API errors at the boundary. +- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible. +- Do this group by group, starting with one small route group. + +Concrete first targets: + +- `groups/config.ts` `PATCH /config` bad request. +- `groups/session.ts` endpoints that already translate domain not-found errors. +- `groups/file.ts` if any handler currently relies on built-in error shape. + +Verification: + +- Focused HTTP tests asserting response body shape for changed error paths. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect SDK error union diff. +- `bun typecheck` from `packages/opencode`. + +### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It + +- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`. +- Decide whether SDK should expose auth in generated operation metadata. +- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code. +- If removing it, update SDK generation expectations and docs in the same PR. + +Verification: + +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated client call signatures and error unions. +- Do not merge if auth churn changes normal SDK call ergonomics unintentionally. + +### PR 7: Tackle Component Shape Rewrites One At A Time + +- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`. +- For each rewrite, make a tiny PR that removes or narrows only that rewrite. +- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first. + +Concrete first targets: + +- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially. +- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source. +- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK type-name and optionality diffs. + +## Upstream Middleware Query Support + +Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation. + +Target in `effect-smol`: + +- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation. +- Make runtime request decoding include middleware query schemas. +- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware. + +Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`. + +## Suggested PR Order + +1. Add drift detection tests only. +2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas. +3. Convert query type overrides into route/schema-level helpers where possible. +4. Convert path parameter overrides into schema annotations or upstream fixes. +5. Replace built-in error response rewrites with explicit declared API errors by route group. +6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable. + +## Verification Checklist Per PR + +- Focused HTTP tests for changed routes. +- OpenAPI drift tests. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK diff for public API churn. +- `bun typecheck` from `packages/opencode`. diff --git a/packages/opencode/specs/v2/tui-command-shim.md b/packages/opencode/specs/v2/tui-command-shim.md new file mode 100644 index 0000000000..5afade2a96 --- /dev/null +++ b/packages/opencode/specs/v2/tui-command-shim.md @@ -0,0 +1,67 @@ +# TUI Command Shim Removal + +Problem: + +- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization +- v2 should expose only the keymap command API +- tests and fixtures should not encode legacy command behavior as expected behavior + +## Remove Public Types + +In `packages/plugin/src/tui.ts`, remove: + +- `TuiCommand` +- `TuiCommandApi` +- `TuiPluginApi.command` + +Keep `api.keymap` as the only TUI command registration and execution surface. + +## Remove Runtime Shim + +Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`. + +In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `createTuiApi(...)` + +In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `pluginApi(...)` + +## Migration Target + +Plugin authors should replace old calls with keymap calls: + +```ts +api.keymap.registerLayer({ + commands: [ + { + name: "plugin.command", + title: "Plugin Command", + namespace: "palette", + slashName: "plugin", + run() { + api.ui.dialog.clear() + }, + }, + ], + bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }], +}) +``` + +Direct replacements: + +- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })` +- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)` +- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")` +- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure + +## Verification + +After removal, run from package directories: + +- `bun typecheck` in `packages/plugin` +- `bun typecheck` in `packages/opencode` +- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index cfca3a9270..7d3e13a93e 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -347,33 +347,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -413,10 +387,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((error) => { @@ -860,33 +831,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -926,10 +871,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((err) => { @@ -1655,6 +1597,70 @@ function toLocations(toolName: string, input: Record): { path: stri } } +function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] { + if (part.state.status !== "completed") return [] + + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + content.push(...imageContents(part.state.attachments ?? [])) + return content +} + +function completedToolRawOutput(part: ToolPart) { + if (part.state.status !== "completed") return {} + return { + output: part.state.output, + metadata: part.state.metadata, + ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}), + } +} + +function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] { + return attachments.flatMap((attachment): ToolCallContent[] => { + const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? attachment.mime + if (!mime.startsWith("image/")) return [] + const data = match?.[2] + if (data === undefined) return [] + return [ + { + type: "content" as const, + content: { + type: "image" as const, + mimeType: mime, + data, + }, + }, + ] + }) +} + async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { const sdk = config.sdk const configured = config.defaultModel diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 3a7ffc2e3f..bdf9fcf345 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -24,8 +24,8 @@ import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" -import { zod } from "@/util/effect-zod" -import { withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" type ReferenceEntry = NonNullable[string] type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts new file mode 100644 index 0000000000..1174ec31ad --- /dev/null +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -0,0 +1,33 @@ +import type { Permission } from "../permission" +import type { Agent } from "./agent" + +/** + * Build the `permission` ruleset for a subagent's session when it's spawned + * via the task tool. Combines: + * + * 1. The parent **agent's** deny rules — Plan Mode and other agent-level + * restrictions live on the agent ruleset, not on the session, so a + * subagent that only inherited the parent SESSION's permission would + * silently bypass them. (#26514) + * 2. The parent **session's** deny rules and external_directory rules — + * same forwarding the original code already did. + * 3. Default `todowrite` and `task` denies if the subagent's own ruleset + * doesn't already permit them. + */ +export function deriveSubagentSessionPermission(input: { + parentSessionPermission: Permission.Ruleset + parentAgent: Agent.Info | undefined + subagent: Agent.Info +}): Permission.Ruleset { + const canTask = input.subagent.permission.some((rule) => rule.permission === "task") + const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") + const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? [] + return [ + ...parentAgentDenies, + ...input.parentSessionPermission.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), + ...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), + ...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]), + ] +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 0445e91839..94f8e7537c 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index bf73ce941e..9eb1faffea 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -23,11 +23,11 @@ function span(id: string, value: { value: string; start: number; end: number }) } } -function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) { +function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) { return diffs?.map((item, i) => ({ ...item, - file: redact(`${kind}-file`, String(i), item.file), - patch: redact(`${kind}-patch`, String(i), item.patch), + file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file), + patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts new file mode 100644 index 0000000000..63b3fb4487 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts @@ -0,0 +1,34 @@ +/** + * Aggregate Promise.allSettled results into a single Error that names every + * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap + * boundaries so a single 4xx doesn't drown its parallel siblings as + * unhandled rejections — every failure surfaces in one labeled message. + */ +export type LabeledSettled = { + name: string + result: PromiseSettledResult +} + +export function aggregateFailures(labeled: LabeledSettled[]): Error | null { + const failed = labeled.filter( + (x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected", + ) + if (failed.length === 0) return null + + const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ") + const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` + const err = new Error(summary) + err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) } + return err +} + +function reasonMessage(reason: unknown): string { + if (reason instanceof Error) return reason.message + if (typeof reason === "string") return reason + if (reason && typeof reason === "object") { + const obj = reason as { message?: unknown; name?: unknown } + if (typeof obj.message === "string") return obj.message + if (typeof obj.name === "string") return obj.name + } + return String(reason) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index ef11922eca..7ade7c71bb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import path from "path" import { useKV } from "./kv" +import { aggregateFailures } from "./aggregate-failures" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -391,16 +392,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) - const blockingRequests: Promise[] = [ - providersPromise, - providerListPromise, - agentsPromise, - configPromise, - projectPromise, - ...(args.continue ? [sessionListPromise] : []), + const blockingRequests: { name: string; promise: Promise }[] = [ + { name: "config.providers", promise: providersPromise }, + { name: "provider.list", promise: providerListPromise }, + { name: "app.agents", promise: agentsPromise }, + { name: "config.get", promise: configPromise }, + { name: "project.sync", promise: projectPromise }, + ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), ] - await Promise.all(blockingRequests) + await Promise.allSettled(blockingRequests.map((r) => r.promise)) + .then((settled) => { + // Surface every failed endpoint in one labeled message instead of + // letting the first rejection drown its siblings as unhandled + // rejections. + const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] }))) + if (failure) throw failure + }) .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) @@ -526,10 +534,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + const infos: (typeof draft.message)[string] = [] + for (const message of messages.data ?? []) { + infos.push(message.info) draft.part[message.info.id] = message.parts } + draft.message[sessionID] = infos draft.session_diff[sessionID] = diff.data ?? [] }), ) diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index fbe5ce7f9f..bebb1fc6aa 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx index bbf3802bbb..cd7d4a1ec7 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx @@ -22,9 +22,7 @@ function View(props: { api: TuiPluginApi }) { - - {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} - + {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} {(item) => ( diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 76e464fc84..ba67a1b6cd 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Keymap from "../keymap" +import { createCommandShim } from "./command-shim" type RouteEntry = { key: symbol @@ -147,7 +148,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.session.length }, diff(sessionID) { - return sync.data.session_diff[sessionID] ?? [] + return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => + item.file === undefined ? [] : [{ ...item, file: item.file }], + ) }, todo(sessionID) { return sync.data.todo[sessionID] ?? [] @@ -200,6 +203,8 @@ export function createTuiApi(input: Input): TuiPluginApi { } return { app: appApi(), + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds), keys: { formatSequence(parts) { return Keymap.formatKeySequence(parts, input.tuiConfig) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts new file mode 100644 index 0000000000..734cb772f0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts @@ -0,0 +1,109 @@ +// Legacy `api.command` bridge for v1 plugins; remove in v2. +import type { TuiCommand, TuiPluginApi } from "@kilocode/plugin/tui" +import { TuiKeybind } from "../config/keybind" +import type { DialogContext } from "../ui/dialog" + +const COMMAND_PALETTE_SHOW = "command.palette.show" +const warned = new Set() + +type Warn = (api: string, replacement: string) => void +type LegacyDialog = TuiPluginApi["ui"]["dialog"] +type CommandShimDialog = DialogContext | LegacyDialog +type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"] + +function warnCommandShim(api: string, replacement: string) { + // Warn v1 plugins about deprecated `api.command`; remove this shim path in v2. + console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement }) +} + +function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog { + if (!("stack" in dialog)) return dialog + return { + replace(render, onClose) { + dialog.replace(render, onClose) + }, + clear() { + dialog.clear() + }, + setSize(size) { + dialog.setSize(size) + }, + get size() { + return dialog.size + }, + get depth() { + return dialog.stack.length + }, + get open() { + return dialog.stack.length > 0 + }, + } +} + +function warnOnce(api: string, replacement: string, warn: Warn) { + if (warned.has(api)) return + warned.add(api) + warn(api, replacement) +} + +function toCommand(item: TuiCommand, dialog: LegacyDialog) { + return { + namespace: "palette", + name: item.value, + title: item.title, + desc: item.description, + category: item.category, + suggested: item.suggested, + hidden: item.hidden, + enabled: item.enabled, + slashName: item.slash?.name, + slashAliases: item.slash?.aliases, + run() { + return item.onSelect?.(dialog) + }, + } +} + +function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) { + return commands.flatMap((item) => + item.keybind + ? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + ? keybinds + .get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + .map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title })) + : [ + { + key: item.keybind, + cmd: item.value, + desc: item.title, + }, + ] + : [], + ) +} + +export function createCommandShim( + keymap: TuiPluginApi["keymap"], + dialog: CommandShimDialog, + keybinds: LegacyKeybinds, +): TuiPluginApi["command"] { + const shimDialog = createCommandShimDialog(dialog) + return { + register(cb) { + warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim) + const commands = cb() + return keymap.registerLayer({ + commands: commands.map((item) => toCommand(item, shimDialog)), + bindings: toBindings(commands, keybinds), + }) + }, + trigger(value) { + warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim) + keymap.dispatchCommand(value) + }, + show() { + warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim) + keymap.dispatchCommand(COMMAND_PALETTE_SHOW) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index e430d11e53..3d4f998c30 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" +import { createCommandShim } from "./command-shim" ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) @@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, keymap, route, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 140d2b8a7a..e26c4068b1 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index e72f658728..94c8d8fe00 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent" import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 4d0fec6872..c611f3c198 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -5,8 +5,8 @@ import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 88962161c5..e89f28236a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -22,8 +22,8 @@ import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath } from "../project/instance-context" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" @@ -307,7 +307,7 @@ export const Info = Schema.Struct({ })), ) -// Uses the shared `DeepMutable` from `@/util/schema`. See the definition +// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition // there for why the local variant is needed over `Types.DeepMutable` from // effect-smol (the upstream version collapses `unknown` to `{}`). export type Info = DeepMutable> & { diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 0d4f20df91..485e334167 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 8c1f09a247..222a750057 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,8 +1,8 @@ export * as ConfigFormatter from "./formatter" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Entry = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts index 49c34b6639..a5299ea955 100644 --- a/packages/opencode/src/config/layout.ts +++ b/packages/opencode/src/config/layout.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Layout = Schema.Literals(["auto", "stretch"]) .annotate({ identifier: "LayoutConfig" }) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 1cf93177e4..accfbee3b2 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,8 +1,8 @@ export * as ConfigLSP from "./lsp" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as LSPServer from "../lsp/server" export const Disabled = Schema.Struct({ diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index fc31ba356f..bb4fd88f04 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 3ad9e035ce..26fa2e0b34 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" // The original Zod schema carried an external $ref pointing at the models.dev // JSON schema. That external reference is not a named SDK component — it is a diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index 9351047894..f964ed4e15 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -3,7 +3,7 @@ export * as ConfigParse from "./parse" import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect" import z from "zod" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { InvalidError, JsonError } from "./error" type ZodSchema = z.ZodType diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 5326b007bd..8c5f854996 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,7 +1,7 @@ export * as ConfigPermission from "./permission" import { Schema, SchemaGetter } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Action = Schema.Literals(["ask", "allow", "deny"]) .annotate({ identifier: "PermissionActionConfig" }) diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 9667dbb59a..b1e3ec6f42 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -2,8 +2,8 @@ import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import path from "path" export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 7821bca5a9..af9aac6964 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,6 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" export const Model = Schema.Struct({ id: Schema.optional(Schema.String), @@ -49,7 +50,7 @@ export const Model = Schema.Struct({ }), ), experimental: Schema.optional(Schema.Boolean), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(ModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index eea3d998c1..36a8faff7e 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,8 +1,8 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const Git = Schema.Struct({ repository: Schema.String.annotate({ diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 3f13698269..159ba0ce5a 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Server = Schema.Struct({ port: Schema.optional(PositiveInt).annotate({ diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index f29d854f50..f707e922ee 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ paths: Schema.optional(Schema.Array(Schema.String)).annotate({ diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 5a0850a249..dd4c325490 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( Schema.brand("WorkspaceID"), diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index ba5a778fac..e78d728e04 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,7 +1,7 @@ import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4dd6a3ae7a..52f2b8486d 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -14,8 +14,8 @@ import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ path: Schema.String, diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 27fd5f2323..8459dd9ac1 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -11,8 +11,8 @@ import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index a61eb7be29..c9ab433f11 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -7,8 +7,8 @@ import { mergeDeep } from "remeda" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import * as Formatter from "./formatter" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "format" }) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 2f62cadc59..2f6f509900 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,8 +13,8 @@ import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod, ZodOverride } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) diff --git a/packages/opencode/src/markdown.d.ts b/packages/opencode/src/markdown.d.ts new file mode 100644 index 0000000000..eb3e3b92d6 --- /dev/null +++ b/packages/opencode/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string + export default content +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index fe71802388..ed74c648ad 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -30,12 +31,21 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod as effectZod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod as effectZod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 +const TolerantToolSchema = ToolSchema.extend({ + outputSchema: z.unknown().optional(), +}) + +const TolerantListToolsResultSchema = z.looseObject({ + tools: z.array(TolerantToolSchema), + nextCursor: z.string().optional(), +}) + export const Resource = Schema.Struct({ name: Schema.String, uri: Schema.String, @@ -119,6 +129,38 @@ function remoteURL(key: string, value: string) { log.warn("invalid remote mcp url", { key }) } +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + +function listTools(key: string, client: MCPClient, timeout: number) { + return Effect.tryPromise({ + try: () => client.listTools(undefined, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => result.tools), + Effect.catch((error) => { + if (!isOutputSchemaValidationError(error)) return Effect.fail(error) + + log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error }) + return Effect.tryPromise({ + try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => + result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + ) + }), + ) +} + // Convert MCP tool definition to AI SDK Tool type function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { const inputSchema = mcpTool.inputSchema @@ -151,11 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number } function defs(key: string, client: MCPClient, timeout?: number) { - return Effect.tryPromise({ - try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe( - Effect.map((result) => result.tools), + return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe( Effect.catch((err) => { log.error("failed to get tools from client", { key, error: err }) return Effect.succeed(undefined) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d93670709e..f4bd2e2cc1 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,9 +7,9 @@ import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index e8bdb2ea20..725030935d 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( "PermissionID", diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 38f6543c90..7cb765ba0d 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -18,8 +18,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index 7708b8de1e..c6cff94fde 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 02173453db..092444c444 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,8 +6,8 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod, zodObject } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, zodObject } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 @@ -230,9 +230,12 @@ export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, - patch: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + // Mirrors Snapshot.FileDiff (see #26574). The current producer always + // populates patch, but loosening matches the sibling schema so a + // future code path that omits it can't crash /instance/vcs/diff. + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) @@ -241,8 +244,8 @@ export type FileDiff = Schema.Schema.Type export const FileStatus = Schema.Struct({ file: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "VcsFileStatus" }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 22f21a9fde..e19eb8792b 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,9 +1,9 @@ import type { AuthOAuthResult, Hooks } from "@kilocode/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" diff --git a/packages/opencode/src/provider/model-status.ts b/packages/opencode/src/provider/model-status.ts new file mode 100644 index 0000000000..468b59ce39 --- /dev/null +++ b/packages/opencode/src/provider/model-status.ts @@ -0,0 +1,9 @@ +import { Schema } from "effect" + +export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) +export type CatalogModelStatus = typeof CatalogModelStatus.Type + +export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +export type ModelStatus = typeof ModelStatus.Type + +export * as ProviderModelStatus from "./model-status" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 39dd77aae3..d0507ff38f 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -8,6 +8,7 @@ import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" +import { CatalogModelStatus } from "./model-status" const Cost = Schema.Struct({ input: Schema.Finite, @@ -71,7 +72,7 @@ export const Model = Schema.Struct({ ), }), ), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(CatalogModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c6c9ecaf9f..ecf34eba6b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" @@ -24,10 +24,11 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" +import { ModelStatus } from "./model-status" const log = Log.create({ service: "provider" }) @@ -897,7 +898,7 @@ export const Model = Schema.Struct({ capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, @@ -935,6 +936,16 @@ export const ConfigProvidersResult = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type ConfigProvidersResult = Types.DeepMutable> +export function toPublicInfo(provider: Info): Info { + return JSON.parse( + JSON.stringify(provider, (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }), + ) +} + export function defaultModelIDs }>(providers: Record) { return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) } @@ -1152,7 +1163,7 @@ const layer: Layer.Layer< const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) + const next = await models(toPublicInfo(provider), { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, @@ -1299,7 +1310,7 @@ const layer: Layer.Layer< const options = yield* Effect.promise(() => plugin.auth!.loader!( () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - database[plugin.auth!.provider], + toPublicInfo(database[plugin.auth!.provider]), ), ) const opts = options ?? {} diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index ea3cac3424..757b70f3ff 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 9ec46ebac7..806ee424c2 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -10,8 +10,8 @@ import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "pty" }) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 6b4d779f26..0f1d6996df 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts index b5e5747c51..0978e52083 100644 --- a/packages/opencode/src/pty/ticket.ts +++ b/packages/opencode/src/pty/ticket.ts @@ -3,7 +3,7 @@ export * as PtyTicket from "./ticket" import { WorkspaceID } from "@/control-plane/schema" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { PtyID } from "@/pty/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" const DEFAULT_TTL = Duration.seconds(60) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index d52f353da9..c041462ad4 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -3,9 +3,9 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { QuestionID } from "./schema" const log = Log.create({ service: "question" }) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 7dade9cdfa..c18eca3e23 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class QuestionID extends Newtype()( "QuestionID", diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index a5c328ac0e..8113c76f51 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream" import { HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" +import { WorkspaceRoutingQuery } from "./middleware/workspace-routing" const log = Log.create({ service: "server" }) @@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add( HttpApiGroup.make("event") .add( HttpApiEndpoint.get("subscribe", EventPaths.event, { + query: WorkspaceRoutingQuery, success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index fa77785a9b..a86845beff 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/config" @@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config") HttpApiGroup.make("config") .add( HttpApiEndpoint.get("get", root, { + query: WorkspaceRoutingQuery, success: described(Config.Info, "Get config info"), }).annotateMerge( OpenApi.annotations({ @@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.patch("update", root, { + query: WorkspaceRoutingQuery, payload: Config.Info, success: described(Config.Info, "Successfully updated config"), error: HttpApiError.BadRequest, @@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.get("providers", `${root}/providers`, { + query: WorkspaceRoutingQuery, success: described(Provider.ConfigProvidersResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 3488d2616c..99a8a21a9e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -3,13 +3,18 @@ import { MCP } from "@/mcp" import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" import { Worktree } from "@/worktree" -import { NonNegativeInt } from "@/util/schema" -import { Schema, SchemaGetter } from "effect" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" +import { QueryBoolean } from "./query" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), @@ -43,19 +48,14 @@ const ToolListItem = Schema.Struct({ }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) export const ToolListQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, provider: ProviderID, model: ModelID, }) -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) const WorktreeList = Schema.Array(Schema.String) export const SessionListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), + ...WorkspaceRoutingQueryFields, roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), cursor: Schema.optional(Schema.NumberFromString), @@ -81,6 +81,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiGroup.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { + query: WorkspaceRoutingQuery, success: described(ConsoleStateResponse, "Active Console provider metadata"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -91,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { + query: WorkspaceRoutingQuery, success: described(ConsoleOrgList, "Switchable Console orgs"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -101,6 +103,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { + query: WorkspaceRoutingQuery, payload: ConsoleSwitchPayload, success: described(Schema.Boolean, "Switch success"), error: HttpApiError.BadRequest, @@ -124,6 +127,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { + query: WorkspaceRoutingQuery, success: described(ToolIDs, "Tool IDs"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -135,6 +139,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, success: described(WorktreeList, "List of worktree directories"), }).annotateMerge( OpenApi.annotations({ @@ -144,6 +149,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Schema.optional(Worktree.CreateInput), success: described(Worktree.Info, "Worktree created"), error: HttpApiError.BadRequest, @@ -155,6 +161,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Worktree.RemoveInput, success: described(Schema.Boolean, "Worktree removed"), error: HttpApiError.BadRequest, @@ -166,6 +173,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { + query: WorkspaceRoutingQuery, payload: Worktree.ResetInput, success: described(Schema.Boolean, "Worktree reset"), error: HttpApiError.BadRequest, @@ -188,6 +196,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index b950adb383..c636e583d7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -5,18 +5,25 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, path: Schema.String, }) export const FindTextQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, pattern: Schema.String, }) export const FindFileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -26,6 +33,7 @@ export const FindFileQuery = Schema.Struct({ }) export const FindSymbolQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, }) @@ -93,6 +101,7 @@ export const FileApi = HttpApi.make("file") }), ), HttpApiEndpoint.get("status", FilePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(File.Info), "File status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index f2b0504a05..ea8db35035 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -8,7 +8,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -20,6 +24,7 @@ const PathInfo = Schema.Struct({ }).annotate({ identifier: "Path" }) export const VcsDiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, mode: Vcs.Mode, }) @@ -54,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance") HttpApiGroup.make("instance") .add( HttpApiEndpoint.post("dispose", InstancePaths.dispose, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Instance disposed"), }).annotateMerge( OpenApi.annotations({ @@ -63,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("path", InstancePaths.path, { + query: WorkspaceRoutingQuery, success: PathInfo, }).annotateMerge( OpenApi.annotations({ @@ -73,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcs", InstancePaths.vcs, { + query: WorkspaceRoutingQuery, success: described(Vcs.Info, "VCS info"), }).annotateMerge( OpenApi.annotations({ @@ -83,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Vcs.FileStatus), "VCS status"), }).annotateMerge( OpenApi.annotations({ @@ -102,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { + query: WorkspaceRoutingQuery, success: described( Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), "Raw VCS diff", @@ -114,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { + query: WorkspaceRoutingQuery, payload: Vcs.ApplyInput, success: described(Vcs.ApplyResult, "VCS patch applied"), error: ApiVcsApplyError, @@ -125,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("command", InstancePaths.command, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( OpenApi.annotations({ @@ -134,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("agent", InstancePaths.agent, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Agent.Info), "List of agents"), }).annotateMerge( OpenApi.annotations({ @@ -143,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("skill", InstancePaths.skill, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Skill.Info), "List of skills"), }).annotateMerge( OpenApi.annotations({ @@ -152,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("lsp", InstancePaths.lsp, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(LSP.Status), "LSP server status"), }).annotateMerge( OpenApi.annotations({ @@ -161,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("formatter", InstancePaths.formatter, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Format.Status), "Formatter status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index b30714c196..c7ed4a9b95 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" export const AddPayload = Schema.Struct({ @@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiGroup.make("mcp") .add( HttpApiEndpoint.get("status", McpPaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), }).annotateMerge( OpenApi.annotations({ @@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp") }), ), HttpApiEndpoint.post("add", McpPaths.status, { + query: WorkspaceRoutingQuery, payload: AddPayload, success: described(StatusMap, "MCP server added successfully"), error: HttpApiError.BadRequest, @@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authStart", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthStartResponse, "OAuth flow started"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, payload: AuthCallbackPayload, success: described(MCP.Status, "OAuth authentication completed"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(MCP.Status, "OAuth authentication completed"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.delete("authRemove", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthRemoveResponse, "OAuth credentials removed"), error: HttpApiError.NotFound, }).annotateMerge( @@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("connect", McpPaths.connect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server connected successfully"), }).annotateMerge( OpenApi.annotations({ @@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server disconnected successfully"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index 22c4d6f6d3..5326596d39 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/permission" @@ -18,6 +18,7 @@ export const PermissionApi = HttpApi.make("permission") HttpApiGroup.make("permission") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Permission.Request), "List of pending permissions"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const PermissionApi = HttpApi.make("permission") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: PermissionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 1a2084547d..f95199eb01 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/project" @@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project") HttpApiGroup.make("project") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Project.Info), "List of projects"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.get("current", `${root}/current`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Current project information"), }).annotateMerge( OpenApi.annotations({ @@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.post("initGit", `${root}/git/init`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Project information after git initialization"), }).annotateMerge( OpenApi.annotations({ @@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project") ), HttpApiEndpoint.patch("update", `${root}/:projectID`, { params: { projectID: ProjectID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Project.Info, "Updated project information"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 4a9bbffc54..49792898df 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -5,7 +5,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/provider" @@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider") HttpApiGroup.make("provider") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Provider.ListResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ @@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider") }), ), HttpApiEndpoint.get("auth", `${root}/auth`, { + query: WorkspaceRoutingQuery, success: described(ProviderAuth.Methods, "Provider auth methods"), }).annotateMerge( OpenApi.annotations({ @@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.AuthorizeInput, success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), error: HttpApiError.BadRequest, @@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.CallbackInput, success: described(Schema.Boolean, "OAuth callback processed successfully"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index ad513e0ad4..1391d2a919 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -5,13 +5,20 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/pty" export const Params = Schema.Struct({ ptyID: PtyID }) -export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) }) +export const CursorQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + cursor: Schema.optional(Schema.String), +}) export const ShellItem = Schema.Struct({ path: Schema.String, name: Schema.String, @@ -34,6 +41,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiGroup.make("pty") .add( HttpApiEndpoint.get("shells", PtyPaths.shells, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(ShellItem), "List of shells"), }).annotateMerge( OpenApi.annotations({ @@ -43,6 +51,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.get("list", PtyPaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Pty.Info), "List of sessions"), }).annotateMerge( OpenApi.annotations({ @@ -52,6 +61,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.post("create", PtyPaths.create, { + query: WorkspaceRoutingQuery, payload: Pty.CreateInput, success: described(Pty.Info, "Created session"), error: HttpApiError.BadRequest, @@ -64,6 +74,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.get("get", PtyPaths.get, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Pty.Info, "Session info"), error: ApiNotFoundError, }).annotateMerge( @@ -75,6 +86,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.put("update", PtyPaths.update, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, payload: Pty.UpdateInput, success: described(Pty.Info, "Updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -87,6 +99,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.delete("remove", PtyPaths.remove, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session removed"), error: ApiNotFoundError, }).annotateMerge( @@ -98,6 +111,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(PtyTicket.ConnectToken, "WebSocket connect token"), error: [HttpApiError.Forbidden, ApiNotFoundError], }).annotateMerge( @@ -126,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( .add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Connected session"), error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts new file mode 100644 index 0000000000..d5b10d1800 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts @@ -0,0 +1,8 @@ +import { Schema, SchemaGetter } from "effect" + +export const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index de2d4fca8e..35cd3314b5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/question" @@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question") HttpApiGroup.make("question") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Question.Request), "List of pending questions"), }).annotateMerge( OpenApi.annotations({ @@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Question answered successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Question rejected successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 967cc80206..ea68e76caf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -10,23 +10,22 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" -import { Schema, SchemaGetter, Struct } from "effect" +import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" +import { QueryBoolean } from "./query" const root = "/session" -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) export const ListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), + ...WorkspaceRoutingQueryFields, scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), @@ -34,8 +33,12 @@ export const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) -export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const DiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), +}) export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) @@ -112,6 +115,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.get("status", SessionPaths.status, { + query: WorkspaceRoutingQuery, success: described(StatusMap, "Get session status"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -123,6 +127,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Get session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -134,6 +139,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("children", SessionPaths.children, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Session.Info), "List of children"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -145,6 +151,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("todo", SessionPaths.todo, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Todo.Info), "Todo list"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -179,6 +186,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("message", SessionPaths.message, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(MessageV2.WithParts, "Message"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -189,6 +197,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.post("create", SessionPaths.create, { + query: WorkspaceRoutingQuery, payload: [HttpApiSchema.NoContent, Session.CreateInput], success: described(Session.Info, "Successfully created session"), error: HttpApiError.BadRequest, @@ -201,6 +210,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("remove", SessionPaths.remove, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -212,6 +222,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("update", SessionPaths.update, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Session.Info, "Successfully updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -224,6 +235,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ForkPayload, success: described(Session.Info, "200"), error: ApiNotFoundError, @@ -236,6 +248,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("abort", SessionPaths.abort, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Aborted session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -247,6 +260,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("init", SessionPaths.init, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: InitPayload, success: described(Schema.Boolean, "200"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -260,6 +274,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully shared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -271,6 +286,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully unshared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -282,6 +298,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("summarize", SessionPaths.summarize, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: SummarizePayload, success: described(Schema.Boolean, "Summarized session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -294,6 +311,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("prompt", SessionPaths.prompt, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -306,6 +324,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(HttpApiSchema.NoContent, "Prompt accepted"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -319,6 +338,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("command", SessionPaths.command, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -331,6 +351,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("shell", SessionPaths.shell, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ShellPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -343,6 +364,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("revert", SessionPaths.revert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: RevertPayload, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -356,6 +378,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -367,6 +390,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { params: { sessionID: SessionID, permissionID: PermissionID }, + query: WorkspaceRoutingQuery, payload: PermissionResponsePayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -380,6 +404,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -392,6 +417,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -402,6 +428,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, payload: MessageV2.Part, success: described(MessageV2.Part, "Successfully updated part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 442e656554..38a93240eb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,10 +1,10 @@ -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/sync" @@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync") HttpApiGroup.make("sync") .add( HttpApiEndpoint.post("start", SyncPaths.start, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Workspace sync started"), }).annotateMerge( OpenApi.annotations({ @@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("replay", SyncPaths.replay, { + query: WorkspaceRoutingQuery, payload: ReplayPayload, success: described(ReplayResponse, "Replayed sync events"), error: HttpApiError.BadRequest, @@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("steal", SyncPaths.steal, { + query: WorkspaceRoutingQuery, payload: SessionPayload, success: described(SessionPayload, "Session stolen into workspace"), error: HttpApiError.BadRequest, @@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("history", SyncPaths.history, { + query: WorkspaceRoutingQuery, payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 3676fc1964..3cf3de5b8e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -54,6 +54,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiGroup.make("tui") .add( HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { + query: WorkspaceRoutingQuery, payload: TuiEvent.PromptAppend.properties, success: described(Schema.Boolean, "Prompt processed successfully"), error: HttpApiError.BadRequest, @@ -65,6 +66,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Help dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -74,6 +76,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -83,6 +86,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Theme dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -92,6 +96,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openModels", TuiPaths.openModels, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Model dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -101,6 +106,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt submitted successfully"), }).annotateMerge( OpenApi.annotations({ @@ -110,6 +116,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt cleared successfully"), }).annotateMerge( OpenApi.annotations({ @@ -119,6 +126,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(Schema.Boolean, "Command executed successfully"), error: HttpApiError.BadRequest, @@ -130,6 +138,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("showToast", TuiPaths.showToast, { + query: WorkspaceRoutingQuery, payload: TuiEvent.ToastShow.properties, success: described(Schema.Boolean, "Toast notification shown successfully"), }).annotateMerge( @@ -140,6 +149,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("publish", TuiPaths.publish, { + query: WorkspaceRoutingQuery, payload: TuiPublishPayload, success: described(Schema.Boolean, "Event published successfully"), error: HttpApiError.BadRequest, @@ -151,6 +161,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { + query: WorkspaceRoutingQuery, payload: TuiEvent.SessionSelect.properties, success: described(Schema.Boolean, "Session selected successfully"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -162,6 +173,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { + query: WorkspaceRoutingQuery, success: described(TuiRequestPayload, "Next TUI request"), }).annotateMerge( OpenApi.annotations({ @@ -171,6 +183,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { + query: WorkspaceRoutingQuery, payload: Schema.Unknown, success: described(Schema.Boolean, "Response submitted successfully"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 3b0b2fa5b1..060c6c8a83 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -3,46 +3,31 @@ import { SessionMessage } from "@/v2/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" + +export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + ), +}).annotate({ identifier: "V2SessionMessagesQuery" }) export const MessageGroup = HttpApiGroup.make("v2.message") .add( HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { params: { sessionID: SessionID }, - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Message order for the first page. Use desc for newest first or asc for oldest first.", - }), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionMessagesQuery" }), + query: MessagesQuery, success: Schema.Struct({ items: Schema.Array(SessionMessage.Message), cursor: Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 17ddcaeda3..231f1915bb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,67 +1,39 @@ -import { WorkspaceID } from "@/control-plane/schema" import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" import { Prompt } from "@/v2/session-prompt" import { SessionV2 } from "@/v2/session" -import { Schema, SchemaGetter } from "effect" +import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" +import { QueryBoolean } from "../query" + +export const SessionsQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + path: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + start: Schema.optional(Schema.NumberFromString), + search: Schema.optional(Schema.String), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.", + }), + ), +}).annotate({ identifier: "V2SessionsQuery" }) export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("sessions", "/api/session", { - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - directory: Schema.String.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - workspace: WorkspaceID.pipe(Schema.optional), - roots: Schema.Literals(["true", "false"]) - .pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), - ) - .pipe(Schema.optional), - start: Schema.NumberFromString.pipe(Schema.optional), - search: Schema.String.pipe(Schema.optional), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - directory: Schema.optional(Schema.Never), - path: Schema.optional(Schema.Never), - workspace: Schema.optional(Schema.Never), - roots: Schema.optional(Schema.Never), - start: Schema.optional(Schema.Never), - search: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionsQuery" }), + query: SessionsQuery, success: Schema.Struct({ items: Schema.Array(SessionV2.Info), cursor: Schema.Struct({ @@ -82,6 +54,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: Schema.Struct({ prompt: Prompt, delivery: SessionV2.Delivery.pipe(Schema.optional), @@ -98,6 +71,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -110,6 +84,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -122,6 +97,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: Schema.Array(SessionMessage.Message), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 1a9732800d..1c40ae3cb8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { ApiVcsApplyError } from "./instance" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" @@ -40,6 +40,7 @@ export const WorkspaceApi = HttpApi.make("workspace") HttpApiGroup.make("workspace") .add( HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), }).annotateMerge( OpenApi.annotations({ @@ -49,6 +50,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("list", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.Info), "Workspaces"), }).annotateMerge( OpenApi.annotations({ @@ -58,6 +60,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("create", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, payload: CreatePayload, success: described(Workspace.Info, "Workspace created"), error: HttpApiError.BadRequest, @@ -69,6 +72,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, { + query: WorkspaceRoutingQuery, success: described(HttpApiSchema.NoContent, "Workspace list synced"), }).annotateMerge( OpenApi.annotations({ @@ -78,6 +82,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("status", WorkspacePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"), }).annotateMerge( OpenApi.annotations({ @@ -88,6 +93,7 @@ export const WorkspaceApi = HttpApi.make("workspace") ), HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { params: { id: Workspace.Info.fields.id }, + query: WorkspaceRoutingQuery, success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -98,6 +104,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + query: WorkspaceRoutingQuery, payload: WarpPayload, success: described(HttpApiSchema.NoContent, "Session warped"), error: [ApiWorkspaceWarpError, ApiVcsApplyError], diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 753ba03138..3d0e8a06c0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -24,7 +24,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h const providers = Effect.fn("ConfigHttpApi.providers")(function* () { const providers = yield* providerSvc.list() return { - providers: Object.values(providers), + providers: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), } }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index fa91b44117..55272fc2f2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -6,7 +6,7 @@ import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@/util/effect-zod" +import * as EffectZod from "@opencode-ai/core/effect-zod" import { Worktree } from "@/worktree" import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 15049fcc55..7027e666ca 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -30,7 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" connected, ) return { - all: Object.values(providers), + all: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), connected: Object.keys(connected), } diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index 3485d80fd6..92e37142b4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -34,6 +34,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message return handlers.handle( "messages", Effect.fn(function* (ctx) { + if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 558e34dd18..275fa2956c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -22,6 +22,31 @@ type SessionCursor = typeof SessionCursor.Type const decodeCursor = Schema.decodeUnknownSync(SessionCursor) +function hasCursorFilter(query: { + readonly order?: unknown + readonly path?: unknown + readonly roots?: unknown + readonly start?: unknown + readonly search?: unknown +}) { + return ( + query.order !== undefined || + query.path !== undefined || + query.roots !== undefined || + query.start !== undefined || + query.search !== undefined + ) +} + +function hasCursorRoutingMismatch( + query: { readonly directory?: string; readonly workspace?: string }, + decoded: SessionCursor | undefined, +) { + if (!decoded) return false + if (query.directory !== undefined && query.directory !== decoded.directory) return true + return query.workspace !== undefined && query.workspace !== decoded.workspaceID +} + const sessionCursor = { encode( session: SessionV2.Info, @@ -46,10 +71,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "sessions", Effect.fn(function* (ctx) { + if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), }) + if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({}) const order = decoded?.order ?? ctx.query.order ?? "desc" const filters = decoded ?? { directory: ctx.query.directory, diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 5a018f16da..6f3c33a647 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -6,39 +6,13 @@ import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" import { Cause, Effect } from "effect" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" -import { HttpApiError } from "effect/unstable/httpapi" -import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError" const log = Log.create({ service: "server" }) -function badRequestResponse() { - return HttpServerResponse.jsonUnsafe( - { - data: {}, - errors: [], - success: false, - }, - { status: 400 }, - ) -} - -function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) { - if (response.status !== 400 || response.body._tag !== "Empty") return response - return badRequestResponse() -} - // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => effect.pipe( - Effect.catch((error) => { - if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse()) - return Effect.fail(error) - }), - Effect.map(normalizeEmptyBadRequest), Effect.catchCause((cause) => { - const schemaError = cause.reasons.filter(Cause.isDieReason).find((reason) => HttpApiSchemaError.is(reason.defect)) - if (schemaError) return Effect.succeed(badRequestResponse()) - const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false if (HttpServerError.isHttpServerError(reason.defect)) return false diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 017a70b91a..0343ccfc87 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -9,11 +9,23 @@ import * as Fence from "@/server/shared/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" -import { Context, Data, Effect, Layer } from "effect" +import { Context, Data, Effect, Layer, Schema } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" +// Query fields this middleware reads from the URL. Spread into every +// endpoint query schema in groups that apply WorkspaceRoutingMiddleware, +// otherwise HttpApi rejects requests carrying these params with 400. +// HttpApiMiddleware in effect-smol cannot declare query params today — +// remove this once upstream supports middleware-declared query schemas. +export const WorkspaceRoutingQueryFields = { + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} + +export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields) + type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index b2ac719a2a..156ebf6834 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -51,47 +51,37 @@ type OpenApiResponse = { content?: Record } -// Instance routes use middleware for directory/workspace resolution, but HttpApi -// doesn't surface middleware query params in the spec. Inject them explicitly. -const InstanceQueryParameters = [ - { - name: "directory", - in: "query", - required: false, - schema: { type: "string" }, - }, - { - name: "workspace", - in: "query", - required: false, - schema: { type: "string" }, - }, -] satisfies OpenApiParameter[] - // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) -const QueryParameterSchemas = { +const QueryParameterSchemas: Record = { + "GET /experimental/session start": { type: "number" }, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, + "GET /experimental/session cursor": { type: "number" }, + "GET /experimental/session limit": { type: "number" }, + "GET /session start": { type: "number" }, + "GET /session limit": { type: "number" }, "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, -} satisfies Record + "GET /api/session limit": { type: "number" }, + "GET /api/session start": { type: "number" }, + "GET /api/session/{sessionID}/message limit": { type: "number" }, +} -const PathParameterSchemas = { +const PathParameterSchemas: Record = { sessionID: { type: "string", pattern: "^ses.*" }, messageID: { type: "string", pattern: "^msg.*" }, partID: { type: "string", pattern: "^prt.*" }, permissionID: { type: "string", pattern: "^per.*" }, ptyID: { type: "string", pattern: "^pty.*" }, -} satisfies Record +} -const LegacyComponentDescriptions = { +const LegacyComponentDescriptions: Record = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", LayoutConfig: "@deprecated Always uses stretch layout.", -} satisfies Record +} function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec @@ -122,7 +112,6 @@ function matchLegacyOpenApi(input: Record) { delete spec.components?.securitySchemes for (const [path, item] of Object.entries(spec.paths ?? {})) { - const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue @@ -183,14 +172,8 @@ function matchLegacyOpenApi(input: Record) { }, } } - if (!isInstanceRoute) continue - operation.parameters = [ - ...InstanceQueryParameters, - ...(operation.parameters ?? []).filter( - (param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"), - ), - ] - for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`) + const route = `${method.toUpperCase()} ${path}` + for (const param of operation.parameters ?? []) normalizeParameter(param, route) } } return input @@ -292,7 +275,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) { function normalizeComponentDescriptions(spec: OpenApiSpec) { for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { - const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions] + const description = LegacyComponentDescriptions[name] if (description) { schema.description = description continue @@ -438,7 +421,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { } } // Simplest fix: generate the raw spec (without transform) to get correct schemas - const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec + const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi) const rawSchemas = raw.components?.schemas if (!rawSchemas) return for (const name of selfRefs) { @@ -507,15 +490,11 @@ function normalizeParameter(param: OpenApiParameter, route: string) { return } if (param.in === "query") { - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + const override = QueryParameterSchemas[`${route} ${param.name}`] if (override) { param.schema = override return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } if (QueryBooleanParameters.has(param.name)) { param.schema = { anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], @@ -527,7 +506,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) { } function pathParameterSchema(route: string, name: string) { - if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] + if (name in PathParameterSchemas) return PathParameterSchemas[name] if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 85f6124505..3eb6f07b82 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -23,8 +23,8 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" -import { zod, ZodOverride } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable { const safe = (value: number) => { if (!Number.isFinite(value)) return 0 - return value + return Math.max(0, value) } const inputTokens = safe(input.usage.inputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index abe51bcd61..1dd36ec53a 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -2,8 +2,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f1709d5a2f..e39bd85e9a 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,8 +2,8 @@ import { Effect, Layer, Context, Schema } from "effect" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" @@ -134,6 +134,7 @@ export const layer = Layer.effect( .read(["session_diff", input.sessionID]) .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) const next = diffs.map((item) => { + if (item.file === undefined) return item const file = unquoteGitPath(item.file) if (file === item.file) return item return { ...item, file } diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 32a8370464..9b7daf7f0c 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,8 +1,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" import { Database } from "@/storage/db" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 9a7a68a451..c5dac9b194 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -2,8 +2,8 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, Context, Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -17,6 +17,7 @@ import { ConfigMarkdown } from "@/config/markdown" import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" +import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -25,6 +26,15 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const KILO_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" +// Built-in skill that ships with opencode. The model's intuition for what an +// opencode.json should look like is often wrong, and opencode hard-fails on +// invalid config, so users hit cryptic startup errors. Loading this skill +// when the model is asked to touch opencode's own config files gives it the +// actual schemas instead of guesses. +const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -230,6 +240,16 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Skill.state")(function* () { const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + if (Flag.KILO_EXPERIMENTAL_CUSTOMIZE_SKILL) { + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s }), diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md new file mode 100644 index 0000000000..aa067d7880 --- /dev/null +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -0,0 +1,354 @@ + + +# Customizing opencode + +opencode validates its own config strictly and refuses to start when a field +is wrong. The shapes below are the accepted shapes. When in doubt, fetch +`https://opencode.ai/config.json` (the JSON Schema) and validate against it. + +Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"` +so the user's editor catches mistakes as they type. + +## Where files live + +| Scope | Path | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | +| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | +| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | +| Global agents | `~/.config/opencode/agent(s)/.md` | +| Project skills | `.opencode/skill(s)//SKILL.md` | +| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | +| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | + +Configs from each scope are deep-merged. Project overrides global. Unknown +top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. + +## opencode.json + +Every field is optional. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "username": "string", + "model": "provider/model-id", + "small_model": "provider/model-id", + "default_agent": "agent-name", + "shell": "/bin/zsh", + "logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR", + "share": "manual" | "auto" | "disabled", + "autoupdate": true | false | "notify", + "snapshot": true, + "instructions": ["AGENTS.md", "docs/style.md"], + + "skills": { + "paths": [".opencode/skills", "/abs/path/to/skills"], + "urls": ["https://example.com/.well-known/skills/"] + }, + + "agent": { + "my-agent": { + "model": "anthropic/claude-sonnet-4-6", + "mode": "subagent", + "description": "...", + "permission": { "edit": "deny" } + } + }, + + "command": { + "deploy": { "description": "...", "prompt": "..." } + }, + + "provider": { + "anthropic": { "options": { "apiKey": "..." } } + }, + "disabled_providers": ["openai"], + "enabled_providers": ["anthropic"], + + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": {} + }, + "remote-thing": { + "type": "remote", + "url": "https://...", + "headers": { "Authorization": "Bearer ..." } + } + }, + + "plugin": [ + "opencode-gemini-auth", + "opencode-foo@1.2.3", + "./local-plugin.ts", + ["opencode-bar", { "option": "value" }] + ], + + "permission": { + "edit": "deny", + "bash": { "git *": "allow", "*": "ask" } + }, + + "formatter": false, + "lsp": false, + + "experimental": { + "primary_tools": ["edit"], + "mcp_timeout": 30000 + }, + + "tool_output": { "max_lines": 200, "max_bytes": 8192 }, + + "compaction": { "auto": true, "tail_turns": 15 } +} +``` + +Shape notes worth being explicit about: + +- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`. +- `skills` is an object with `paths` and/or `urls`, not an array. +- `agent` is an object keyed by agent name, not an array. +- `plugin` is an array of strings or `[name, options]` tuples, not an object. +- `mcp[name].command` is an array of strings, never a single string. `type` is required. +- `permission` is either a string action or an object keyed by tool name. + +## Skills + +opencode's skill loader scans for `**/SKILL.md` inside skill directories. The +file is named `SKILL.md` exactly, and lives in its own folder named after the +skill: + +``` +.opencode/skills/my-skill/SKILL.md +``` + +Frontmatter: + +```markdown +--- +name: my-skill +description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say. +--- + +# My Skill + +(skill body in markdown: instructions, examples, references) +``` + +- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. +- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. +- Optional: `license`, `compatibility`, `metadata` (string-string map). + +Register skills from non-default locations via `skills.paths` (scanned +recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of +skills). + +## Agents + +Two ways to define an agent. Use the file form for anything non-trivial. + +### Inline (in `opencode.json`) + +```json +{ + "agent": { + "my-reviewer": { + "description": "Reviews PRs for style violations.", + "mode": "subagent", + "model": "anthropic/claude-sonnet-4-6", + "permission": { "edit": "deny", "bash": "ask" }, + "prompt": "You are a strict PR reviewer..." + } + } +} +``` + +### File + +``` +.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md +``` + +```markdown +--- +description: Reviews PRs for style violations. +mode: subagent +model: anthropic/claude-sonnet-4-6 +permission: + edit: deny + bash: ask +--- + +You are a strict PR reviewer. Focus on... +``` + +The file body becomes the agent's `prompt`. Do not also put `prompt:` in the +frontmatter. + +`mode` is one of `"primary"`, `"subagent"`, `"all"`. + +Allowed top-level frontmatter fields: `name, model, variant, description, mode, +hidden, color, steps, options, permission, disable, temperature, top_p`. Any +unknown field is silently routed into `options`. + +To disable a built-in agent: `agent: { build: { disable: true } }`, or in a +file, `disable: true` in frontmatter. + +`default_agent` must point to a non-hidden, primary-mode agent. + +### Built-in agents + +opencode ships with `build`, `plan`, `general`, `explore`, plus optionally +`scout` (gated on `KILO_EXPERIMENTAL_SCOUT`). Hidden internal agents: +`compaction`, `title`, `summary`. To override a built-in's fields, define the +same key in `agent: { : { ... } }`. + +## Plugins + +`plugin:` is an array. Each entry is one of: + +```json +"plugin": [ + "opencode-gemini-auth", // npm spec, latest + "opencode-foo@1.2.3", // npm spec, pinned + "./local-plugin.ts", // file path, relative to the declaring config + "file:///abs/path/plugin.js", // file URL + ["opencode-bar", { "key": "val" }] // tuple form with options +] +``` + +Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in +`.opencode/plugin/` or `.opencode/plugins/`. + +A plugin module exports `default` (or any named export) of type +`Plugin = (input: PluginInput, options?) => Promise`. The export is a +function, not a plain object literal, and the function returns an object +(return `{}` if there is nothing to register). + +```ts +import type { Plugin } from "@kilocode/plugin" + +export default (async ({ client, project, directory, $ }) => { + return { + config: (cfg) => { + // cfg is the live merged config; mutate fields here. + }, + "tool.execute.before": async (input, output) => { + // mutate output.args before the tool runs + }, + } +}) satisfies Plugin +``` + +Hook surface (mutate `output` in place; return `void`): + +- `event(input)`: every bus event +- `config(cfg)`: once on init with the merged config +- `chat.message`, `chat.params`, `chat.headers` +- `tool.execute.before`, `tool.execute.after` +- `tool.definition` +- `command.execute.before` +- `shell.env` +- `permission.ask` +- `experimental.chat.messages.transform`, `experimental.chat.system.transform`, + `experimental.session.compacting`, `experimental.compaction.autocontinue`, + `experimental.text.complete` + +Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, +`auth: { ... }`, `provider: { ... }`. + +## MCP servers + +`mcp:` is an object keyed by server name. Each server is discriminated by +`type`: + +```json +{ + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": { "BROWSER": "chromium" } + }, + "github": { + "type": "remote", + "url": "https://...", + "enabled": true, + "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } + }, + "old-server": { "enabled": false } + } +} +``` + +`command` is an array of strings. `type` is required. Use `enabled: false` to +disable a server inherited from a parent config. + +## Permissions + +```json +"permission": { + "edit": "deny", + "bash": { "git *": "allow", "rm *": "deny", "*": "ask" }, + "external_directory": { "~/secrets/**": "deny", "*": "allow" } +} +``` + +Actions: `"allow"`, `"ask"`, `"deny"`. + +Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an +object `{ pattern: action }`. Within an object, **insertion order matters**. +opencode evaluates the LAST matching rule, so put broad rules first and narrow +rules last. + +`permission: "allow"` (a string at the top level) is shorthand for "allow +everything" and is rarely what the user wants. + +Known permission keys: `read, edit, glob, grep, list, bash, task, +external_directory, todowrite, question, webfetch, websearch, codesearch, +repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, +question, webfetch, websearch, codesearch, doom_loop`) only accept a flat +action, not a per-pattern object. + +`external_directory` patterns are filesystem paths (use `~/`, absolute paths, +or globs like `~/projects/**`). + +Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on +the `plan` agent's permission ruleset (`edit: deny *`). + +## Escape hatches + +When a user's config is broken and opencode won't start, these env vars help: + +- `KILO_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` + and start from globals only. Run from the project directory, opencode loads, + the user edits the broken file, then they restart without the flag. +- `KILO_CONFIG=/path/to/file.json`: load an additional explicit config. +- `KILO_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: + inject inline JSON as a final local-scope merge. +- `KILO_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. +- `KILO_PURE=1`: skip external plugins entirely. +- `KILO_DISABLE_EXTERNAL_SKILLS=1`, + `KILO_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under + `~/.claude/` and `~/.agents/`. + +## When proposing edits + +- Validate against the schema before writing. If you are unsure of a field's + exact shape, fetch `https://opencode.ai/config.json` rather than guessing. +- Preserve `$schema` and any existing fields the user did not ask to change. +- For agent, skill, and plugin definitions, prefer creating new files in the + correct location over inlining everything in `opencode.json`. +- If the user's existing config is malformed, point them at the env-var escape + hatch above so they can edit from inside opencode without breaking their + session. +- opencode hard-fails on invalid config by design. There is no graceful + degradation, so get the shape right the first time. diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ea30f5afc7..848a067c3d 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,8 +10,8 @@ import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod } from "@opencode-ai/core/effect-zod" export const Patch = Schema.Struct({ hash: Schema.String, @@ -20,10 +20,13 @@ export const Patch = Schema.Struct({ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ - file: Schema.String, - patch: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + // Optional because legacy/imported `summary_diffs` on disk may omit + // file details and patch text. Required Schema rejected the whole + // session response and broke session loading on Desktop. + file: Schema.optional(Schema.String), + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "SnapshotFileDiff" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 5b2df1e899..bc4d8b8f17 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,7 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Git } from "@/git" const log = Log.create({ service: "storage" }) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 6f5f157cb8..8a50c8f16d 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -9,7 +9,7 @@ import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index e714b86ae0..e4e2e75b73 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( Schema.brand("EventID"), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index bf01fc7d2d..7ade166c5f 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,5 +1,5 @@ import { Effect, Option, Schema, Scope } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { createReadStream } from "fs" import * as path from "path" import { createInterface } from "readline" @@ -178,7 +178,7 @@ export const ReadTool = Tool.define( yield* ctx.ask({ permission: "read", - patterns: [filepath], + patterns: [path.relative(instance.worktree, filepath)], always: ["*"], metadata: {}, }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index e57cb1c289..350c16664b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -17,7 +17,7 @@ import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@kilocode/plugin" import { Schema } from "effect" import z from "zod" -import { ZodOverride } from "@/util/effect-zod" +import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 9ce7bece2b..b6c263a4ce 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 45c637863a..f26e364b61 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import DESCRIPTION from "./shell.txt" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { ShellID } from "./id" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 22e4e5671c..c4d5bf7f4a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,6 +4,7 @@ import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" +import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { Effect, Exit, Schema } from "effect" @@ -58,41 +59,25 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } - const canTask = next.permission.some((rule) => rule.permission === id) - const canTodo = next.permission.some((rule) => rule.permission === "todowrite") - const taskID = params.task_id const session = taskID ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined const parent = yield* sessions.get(ctx.sessionID) + const parentAgent = parent.agent + ? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined const nextSession = session ?? (yield* sessions.create({ parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, permission: [ - ...(parent.permission ?? []).filter( - (rule) => rule.permission === "external_directory" || rule.action === "deny", - ), - ...(canTodo - ? [] - : [ - { - permission: "todowrite" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(canTask - ? [] - : [ - { - permission: id, - pattern: "*" as const, - action: "deny" as const, - }, - ]), + ...deriveSubagentSessionPermission({ + parentSessionPermission: parent.permission ?? [], + parentAgent, + subagent: next, + }), ...(cfg.experimental?.primary_tools?.map((item) => ({ pattern: "*", action: "allow" as const, @@ -144,8 +129,8 @@ export const TaskTool = Tool.define( }, agent: next.name, tools: { - ...(canTodo ? {} : { todowrite: false }), - ...(canTask ? {} : { task: false }), + ...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }), + ...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }), ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), }, parts, diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index d9b92a23cc..d87e1dcdb5 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import z from "zod" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" /** * Create a Schema-backed NamedError-shaped class. diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts index 8d0ce9174f..9ae5ee5a94 100644 --- a/packages/opencode/src/v2/auth.ts +++ b/packages/opencode/src/v2/auth.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" import { Identifier } from "@opencode-ai/core/util/identifier" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index c7c13890f2..83ca437efe 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -1,6 +1,6 @@ import { Identifier } from "@/id/id" import { SyncEvent } from "@/sync" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Flag } from "@opencode-ai/core/flag/flag" import * as Schema from "effect/Schema" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts index db66199a59..56357ab400 100644 --- a/packages/opencode/src/v2/model.ts +++ b/packages/opencode/src/v2/model.ts @@ -1,4 +1,5 @@ -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" @@ -114,7 +115,7 @@ export class Info extends Schema.Class("Model.Info")({ released: DateTimeUtcFromMillis, }), cost: Cost.pipe(Schema.Array), - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, limit: Schema.Struct({ context: Schema.Int, input: Schema.Int.pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 7c768bd551..fa211bd8c4 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,5 +1,5 @@ import { SessionID } from "@/session/schema" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { EventV2 } from "./event" import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" @@ -118,12 +118,12 @@ export namespace Step { finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), snapshot: Schema.String.pipe(Schema.optional), @@ -305,7 +305,7 @@ export namespace Tool { export const RetryError = Schema.Struct({ message: Schema.String, - statusCode: NonNegativeInt.pipe(Schema.optional), + statusCode: Schema.Finite.pipe(Schema.optional), isRetryable: Schema.Boolean, responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), responseBody: Schema.String.pipe(Schema.optional), @@ -320,7 +320,7 @@ export const Retried = EventV2.define({ aggregate: "sessionID", schema: { ...Base, - attempt: NonNegativeInt, + attempt: Schema.Finite, error: RetryError, }, }) diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index bb86f039b2..b3da6009f6 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -10,7 +10,7 @@ import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" -import { optionalOmitUndefined } from "@/util/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Modelv2 } from "./model" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index d3e6f4047a..168ebb51c3 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test } from "bun:test" import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" -import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@kilocode/sdk/v2" -import { Instance } from "../../src/project/instance" +import type { + Event, + EventMessagePartUpdated, + ToolStateCompleted, + ToolStatePending, + ToolStateRunning, +} from "@kilocode/sdk/v2" import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" @@ -36,6 +41,14 @@ function isToolCallUpdate( return update.sessionUpdate === "tool_call_update" } +function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) { + return sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update) + .filter(isToolCallUpdate) + .find((u) => u.toolCallId === callID && u.status === "completed") +} + function toolEvent( sessionId: string, cwd: string, @@ -78,6 +91,46 @@ function toolEvent( return { directory: cwd, payload } } +function completedToolEvent( + sessionId: string, + cwd: string, + opts: { + callID: string + tool: string + input: Record + output: string + attachments?: ToolStateCompleted["attachments"] + }, +): GlobalEventEnvelope { + const state: ToolStateCompleted = { + status: "completed", + input: opts.input, + output: opts.output, + title: opts.tool, + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + ...(opts.attachments && { attachments: opts.attachments }), + } + const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, + type: "message.part.updated", + properties: { + sessionID: sessionId, + time: Date.now(), + part: { + id: `part_${opts.callID}`, + sessionID: sessionId, + messageID: `msg_${opts.callID}`, + type: "tool", + callID: opts.callID, + tool: opts.tool, + state, + }, + }, + } + return { directory: cwd, payload } +} + function createEventStream() { const queue: GlobalEventEnvelope[] = [] const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] @@ -616,6 +669,130 @@ describe("acp.agent event subscription", () => { }) }) + test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("image-data").toString("base64") + + controller.push( + completedToolEvent(sessionId, cwd, { + callID: "call_image", + tool: "read", + input: { filePath: "/tmp/image.png" }, + output: "Image read successfully", + attachments: [ + { + id: "part_image", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "image/png", + filename: "image.png", + url: `data:image/png;base64,${data}`, + }, + { + id: "part_text", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,Zm9v", + }, + ], + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image read successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/png", data }, + }) + expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false) + expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2) + + stop() + }, + }) + }) + + test("replays completed tool image attachments as ACP tool content blocks", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, sessionUpdates, stop, sdk } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("replay-image").toString("base64") + + sdk.session.messages = async () => ({ + data: [ + { + info: { + role: "assistant", + sessionID: sessionId, + }, + parts: [ + { + id: "part_replay", + sessionID: sessionId, + messageID: "msg_replay", + type: "tool", + callID: "call_replay_image", + tool: "webfetch", + state: { + status: "completed", + input: { url: "https://example.com/image.png" }, + output: "Image fetched successfully", + title: "webfetch", + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + attachments: [ + { + id: "part_replay_image", + sessionID: sessionId, + messageID: "msg_replay", + type: "file", + mime: "image/jpeg", + filename: "image.jpg", + url: `data:image/jpeg;base64,${data}`, + }, + ], + }, + }, + ], + }, + ], + }) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image fetched successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/jpeg", data }, + }) + + stop() + }, + }) + }) + test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() await WithInstance.provide({ diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts new file mode 100644 index 0000000000..5ba6b54834 --- /dev/null +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -0,0 +1,141 @@ +/** + * Reproducer for opencode issue #26514: + * + * In Plan Mode (the `plan` agent), the main agent's edit/write tools are + * blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`). + * However, when the plan agent spawns a subagent via the `task` tool, the + * subagent retains full file modification capabilities — a security bypass. + * + * This test replicates the permission ruleset that would govern a + * `general` subagent when launched from a `plan` parent session, mirroring + * the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime + * subagent agent permissions, evaluated as in `session/prompt.ts`). + * + * The expected (secure) behavior is that the subagent inherits the plan + * mode read-only restriction and `edit`/`write` resolve to `deny`. On + * origin/dev this assertion fails because the parent **agent** permissions + * are not propagated to the subagent — only the parent **session** + * permissions are passed through, and Plan Mode's restrictions live on the + * agent, not the session. + */ +import { test, expect, afterEach } from "bun:test" +import { Effect } from "effect" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { Agent } from "../../src/agent/agent" +import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" +import { Permission } from "../../src/permission" + +afterEach(async () => { + await disposeAllInstances() +}) + +function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { + return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +} + +// `deriveSubagentSessionPermission` is imported from production. The test +// exercises the actual helper that task.ts uses to build the subagent's +// session permission, so any regression in that helper trips this test. + +test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const generalAgent = await load(tmp.path, (svc) => svc.get("general")) + + expect(planAgent).toBeDefined() + expect(generalAgent).toBeDefined() + // Sanity: the plan agent itself blocks edit. (Note: `write` and + // `apply_patch` route through the `edit` permission at the runtime + // tool layer — see Permission.disabled / EDIT_TOOLS.) + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + + // Simulate the plan-mode parent session: in real flow the plan + // session's `permission` field is empty (Plan Mode lives on the agent + // ruleset, not the session). So we pass [] through as the parent + // session permission, exactly like the actual code path. + const parentSessionPermission: Permission.Ruleset = [] + + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: generalAgent!, + }) + + // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): + // ruleset: Permission.merge(agent.permission, session.permission ?? []) + const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) + + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] explore subagent launched from plan mode also stays read-only", async () => { + // Sibling check: even though `explore` is intrinsically read-only, the + // bug surface is the same. Including this case to document that the fix + // should propagate the parent **agent** permissions, not just deny edit + // when the subagent happens to already deny it. + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const explore = await load(tmp.path, (svc) => svc.get("explore")) + expect(planAgent).toBeDefined() + expect(explore).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: explore!, + }) + const effective = Permission.merge(explore!.permission, subagentSessionPermission) + + // Already deny — sanity check. + expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => { + // The most damaging case: a user-defined subagent with default + // permissions (allow-by-default, like `general`). The subagent must NOT + // be able to edit when the parent agent is `plan`. + await using tmp = await tmpdir({ + config: { + agent: { + my_subagent: { + description: "A user-defined subagent", + mode: "subagent", + }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const my = await load(tmp.path, (svc) => svc.get("my_subagent")) + expect(planAgent).toBeDefined() + expect(my).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: my!, + }) + const effective = Permission.merge(my!.permission, subagentSessionPermission) + + // BUG: on origin/dev edit resolves to "allow" because the plan + // agent's `edit: deny *` rule never reaches the subagent. + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + }, + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts new file mode 100644 index 0000000000..c9b3551d9a --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -0,0 +1,55 @@ +/** + * Regression test for the TUI bootstrap aggregation helper. Replaces the + * pre-fix Promise.all behavior where the first rejection drowned every + * sibling endpoint's failure as an unhandled rejection. + */ +import { describe, expect, test } from "bun:test" +import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures" + +describe("aggregateFailures", () => { + test("returns null when every result is fulfilled", () => { + expect( + aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { name: "providers", result: { status: "fulfilled", value: 2 } }, + ]), + ).toBeNull() + }) + + test("names the failed endpoint when one rejects", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { + name: "providers", + result: { status: "rejected", reason: new Error("Service unavailable") }, + }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("1 of 2") + expect(err!.message).toContain("providers: Service unavailable") + }) + + test("names every failed endpoint when multiple reject", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } }, + { name: "providers", result: { status: "fulfilled", value: 1 } }, + { name: "agents", result: { status: "rejected", reason: { message: "boom" } } }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("2 of 3") + expect(err!.message).toContain("config: 400 Bad Request") + expect(err!.message).toContain("agents: boom") + }) + + test("attaches structured failure list under .cause", () => { + const reason = new Error("nope") + const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }]) + const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } + expect(cause.failures).toEqual([{ name: "providers", reason }]) + }) + + test("falls back to String() for opaque reasons", () => { + const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }]) + expect(err!.message).toContain("x: 42") + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx new file mode 100644 index 0000000000..d9ecdbe9d5 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -0,0 +1,120 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" +import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" +import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" + +export const worktree = "/tmp/opencode" +export const directory = `${worktree}/packages/opencode` + +export async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +export function json(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, + }) +} + +export function eventSource(): EventSource { + return { subscribe: async () => () => {} } +} + +type FetchHandler = (url: URL) => Response | Promise | undefined + +export function createFetch(override?: FetchHandler) { + const session = [] as URL[] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/session") session.push(url) + + const overridden = await override?.(url) + if (overridden) return overridden + + switch (url.pathname) { + case "/agent": + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + return json([]) + case "/config": + case "/experimental/resource": + case "/mcp": + case "/provider/auth": + case "/session/status": + return json({}) + case "/config/providers": + return json({ providers: {}, default: {} }) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + case "/path": + return json({ home: "", state: "", config: "", worktree, directory }) + case "/project/current": + return json({ id: "proj_test" }) + case "/provider": + return json({ all: [], default: {}, connected: [] }) + case "/session": + return json([]) + case "/vcs": + return json({ branch: "main" }) + } + + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + return { fetch, session } +} + +type Ctx = { kv: ReturnType; sync: ReturnType } + +export async function mount(override?: FetchHandler) { + const calls = createFetch(override) + let sync!: ReturnType + let kv!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + const ctx: Ctx = { kv: useKV(), sync: useSync() } + onMount(() => { + sync = ctx.sync + kv = ctx.kv + done() + }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + + + )) + + await ready + await wait(() => sync.status === "complete") + return { app, kv, sync, session: calls.session } +} diff --git a/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx new file mode 100644 index 0000000000..5fb7ece94d --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx @@ -0,0 +1,47 @@ +/** @jsxImportSource @opentui/solid */ +/** + * Reproducer for #26560 — TUI crashes with + * `TypeError: undefined is not an object (evaluating 'f.data.map')` + * when entering a session whose messages endpoint returns a non-2xx. + * The failure path is `sync.tsx#sync.session.sync` reading + * `messages.data!` while the SDK leaves `data` undefined on error. + */ +import { describe, expect, test } from "bun:test" +import { Global } from "@opencode-ai/core/global" +import { tmpdir } from "../../../fixture/fixture" +import { directory, json, mount } from "./sync-fixture" + +const sessionID = "ses_undef" + +describe("tui sync (#26560)", () => { + test("entering a session whose messages endpoint errors does not crash sync", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const sessionPayload = { + id: sessionID, + title: "broken", + time: { created: 0, updated: 0 }, + version: "1.14.42", + directory, + project_id: "proj_test", + } + const { app, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(sessionPayload) + if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 }) + if (url.pathname === `/session/${sessionID}/todo`) return json([]) + if (url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname === "/session") return json([sessionPayload]) + return undefined + }) + + try { + await expect(sync.session.sync(sessionID)).resolves.toBeUndefined() + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index 993484d3ca..f67257f6ce 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -1,127 +1,8 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" -import { testRender } from "@opentui/solid" -import { onMount } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" -import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" -import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" -import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" -import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" -import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" import { tmpdir } from "../../../fixture/fixture" - -const worktree = "/tmp/opencode" -const directory = `${worktree}/packages/opencode` - -async function wait(fn: () => boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -function json(data: unknown) { - return new Response(JSON.stringify(data), { - headers: { "content-type": "application/json" }, - }) -} - -function eventSource(): EventSource { - return { - subscribe: async () => () => {}, - } -} - -function createFetch() { - const session = [] as URL[] - const fetch = (async (input: RequestInfo | URL) => { - const url = new URL(input instanceof Request ? input.url : String(input)) - if (url.pathname === "/session") session.push(url) - - switch (url.pathname) { - case "/agent": - case "/command": - case "/experimental/workspace": - case "/experimental/workspace/status": - case "/formatter": - case "/lsp": - return json([]) - case "/config": - case "/experimental/resource": - case "/mcp": - case "/provider/auth": - case "/session/status": - return json({}) - case "/config/providers": - return json({ providers: {}, default: {} }) - case "/experimental/console": - return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) - case "/path": - return json({ home: "", state: "", config: "", worktree, directory }) - case "/project/current": - return json({ id: "proj_test" }) - case "/provider": - return json({ all: [], default: {}, connected: [] }) - case "/session": - return json([]) - case "/vcs": - return json({ branch: "main" }) - } - - throw new Error(`unexpected request: ${url.pathname}`) - }) as typeof globalThis.fetch - - return { fetch, session } -} - -async function mount() { - const calls = createFetch() - let sync!: ReturnType - let kv!: ReturnType - let done!: () => void - const ready = new Promise((resolve) => { - done = resolve - }) - - const app = await testRender(() => ( - - - - - - - { - sync = ctx.sync - kv = ctx.kv - done() - }} - /> - - - - - - - )) - - await ready - await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } -} - -function Probe(props: { onReady: (ctx: { kv: ReturnType; sync: ReturnType }) => void }) { - const kv = useKV() - const sync = useSync() - - onMount(() => { - props.onReady({ kv, sync }) - }) - - return -} +import { mount } from "./sync-fixture" describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 10547c9f08..5afc85e3b5 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -7,8 +7,9 @@ import type { MCP as MCPNS } from "../../src/mcp/index" // Per-client state for controlling mock behavior interface MockClientState { - tools: Array<{ name: string; description?: string; inputSchema: object }> + tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number + requestCalls: number listToolsShouldFail: boolean listToolsError: string listPromptsShouldFail: boolean @@ -36,6 +37,7 @@ function getOrCreateClientState(name?: string): MockClientState { state = { tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], listToolsCalls: 0, + requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", listPromptsShouldFail: false, @@ -139,6 +141,12 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { tools: this._state?.tools ?? [] } } + async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) { + if (this._state) this._state.requestCalls++ + if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] }) + throw new Error(`unsupported request: ${request.method}`) + } + async listPrompts() { if (this._state?.listPromptsShouldFail) { throw new Error("listPrompts failed") @@ -205,6 +213,11 @@ function withInstance( } } +function statusName(status: Record | MCPNS.Status, server: string) { + if ("status" in status) return status.status + return status[server]?.status +} + // ======================================================================== // Test: tools() are cached after connect // ======================================================================== @@ -433,6 +446,59 @@ test( ), ) +test( + "falls back when MCP output schema refs fail SDK tool discovery", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "stitch-like-server" + const serverState = getOrCreateClientState("stitch-like-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #" + serverState.tools = [ + { + name: "render_screen", + description: "renders a screen", + inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, + outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, + }, + ] + + const addResult = yield* mcp.add("stitch-like-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "stitch-like-server")).toBe("connected") + + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(1) + }), + ), +) + +test( + "does not fall back for non-schema MCP tool discovery errors", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "broken-server" + const serverState = getOrCreateClientState("broken-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "transport closed" + + const addResult = yield* mcp.add("broken-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "broken-server")).toBe("failed") + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(0) + }), + ), +) + // ======================================================================== // Test: disabled server via config // ======================================================================== diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 52434a6211..312214ddaa 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -35,6 +35,11 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["KILO_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["KILO_EXPERIMENTAL_EVENT_SYSTEM"] = "true" +// Tests assert exact skill counts from disk discovery; the built-in +// customize-opencode skill is opt-in for stable channels and on by default +// for unstable channels (including "local" where CI runs). Disable it here +// so disk-discovery tests aren't off-by-one. +process.env["KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills diff --git a/packages/opencode/test/provider/model-status.test.ts b/packages/opencode/test/provider/model-status.test.ts new file mode 100644 index 0000000000..e6fa645e71 --- /dev/null +++ b/packages/opencode/test/provider/model-status.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigProvider } from "@/config/provider" +import { CatalogModelStatus, ModelStatus } from "@/provider/model-status" +import { ModelsDev } from "@/provider/models" +import { Provider } from "@/provider/provider" + +describe("provider model status schemas", () => { + test("keeps catalog status separate from normalized provider status", () => { + expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated") + expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow() + expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active") + }) + + test("accepts active status across public provider schemas", () => { + expect(Schema.decodeUnknownSync(ConfigProvider.Model)({ status: "active" }).status).toBe("active") + expect( + Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "test-model", + name: "Test Model", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }).status, + ).toBeUndefined() + expect( + Schema.decodeUnknownSync(Provider.Model)({ + id: "test-model", + providerID: "test-provider", + api: { + id: "test-model", + url: "", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + }).status, + ).toBe("active") + }) +}) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index ef069e435b..a7c6e23d39 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -47,4 +47,41 @@ describe("config HttpApi", () => { lsp: false, }) }) + + test("serves config with active provider model status", async () => { + await using tmp = await tmpdir({ + config: { + formatter: false, + lsp: false, + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }, + }) + + const response = await app().request("/config", { + headers: { + "x-kilo-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }) + }) }) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 8e6a4c1c53..cacdfd23a3 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -38,6 +38,10 @@ import { type Scenario } from "./types" void (await import("@opencode-ai/core/util/log")).init({ print: false }) +function cursor(input: Record) { + return Buffer.from(JSON.stringify(input)).toString("base64url") +} + const scenarios: Scenario[] = [ http.protected .get("/global/health", "global.health") @@ -520,7 +524,10 @@ const scenarios: Scenario[] = [ yield* ctx.worktreeRemove(ctx.state.directory) }), ), - http.protected.get("/experimental/session", "experimental.session.list").json(200, array), + http.protected + .get("/experimental/session", "experimental.session.list") + .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) + .json(200, array), http.protected.get("/experimental/resource", "experimental.resource.list").json(), http.protected .post("/sync/history", "sync.history.list") @@ -598,6 +605,64 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session", "v2.session.list.filters") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + order: "asc", + path: ".", + roots: "false", + start: "0", + search: "missing", + directory: ctx.directory ?? "", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ + id: "ses_httpapi_missing", + time: 0, + order: "desc", + direction: "next", + directory: ctx.directory, + }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor.invalid") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }), + search: "not-allowed-with-cursor", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ @@ -620,6 +685,53 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.params") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + order: "asc", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + order: "asc", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .at((ctx) => ({ diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts new file mode 100644 index 0000000000..6cb36c3bf3 --- /dev/null +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -0,0 +1,189 @@ +// Regression coverage for issue #26526's claim that promptAsync's +// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not — +// forkIn preserves Context.Reference values via standard fiber inheritance. +// +// The companion claim that the streaming prompt handler "captures and +// provides" those services is true and load-bearing: Stream.fromEffect's +// body runs detached from the request fiber's context, so the explicit +// Effect.provideService calls there are required, not defensive duplication. + +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Scope } from "effect" +import * as Stream from "effect/Stream" +import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { mkdir } from "node:fs/promises" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() + }), + ) + }), +) + +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + InstanceLayer.layer, + Project.defaultLayer, + workspaceLayer, + ), +) + +const instanceContextTestLayer = instanceRouterMiddleware + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const setupWorkspace = (kind: string) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id))) + registerAdapter(projectID, kind, localAdapter(dir)) + const workspace = yield* Workspace.Service.use((svc) => + svc.create({ type: kind, branch: null, extra: null, projectID }), + ) + return { dir, workspace } + }) + +type Capture = { directory?: string; workspaceID?: string } + +const captureInstance = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { directory: instance?.directory, workspaceID } satisfies Capture +}) + +describe("HttpApi handler context inheritance", () => { + // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits + // the request's Context — including InstanceRef and WorkspaceRef provided + // by InstanceContextMiddleware — without any explicit re-provide. + it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-fork") + const capture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/fork-probe", + Effect.gen(function* () { + const scope = yield* Scope.Scope + yield* Effect.gen(function* () { + yield* Deferred.succeed(capture, yield* captureInstance) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return HttpServerResponse.empty({ status: 204 }) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.post( + `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`, + ) + expect(response.status).toBe(204) + + const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds")) + expect(observed.directory).toBe(dir) + expect(observed.workspaceID).toBe(workspace.id) + }), + ) + + // Mirrors handlers/session.ts:255 prompt — the streaming handler reads + // InstanceRef/WorkspaceRef in the request fiber and re-provides them to + // the Stream.fromEffect body. This test locks in why the explicit + // provides are required: without them the stream body sees undefined. + it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-stream") + const withoutCapture = yield* Deferred.make() + const withCapture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/stream-probe-without", + Effect.gen(function* () { + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withoutCapture, yield* captureInstance) + return "" + }), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + yield* HttpRouter.add( + "POST", + "/stream-probe-with", + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withCapture, yield* captureInstance) + return "" + }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}` + const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`) + yield* responseWithout.text + const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`) + yield* responseWith.text + + const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds")) + expect(without.directory).toBeUndefined() + expect(without.workspaceID).toBeUndefined() + + const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds")) + expect(withProvide.directory).toBe(dir) + expect(withProvide.workspaceID).toBe(workspace.id) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 0dbebec053..74a9150ef6 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -21,6 +21,54 @@ function app() { return Server.Default().app } +function providerListHasFetch(list: unknown) { + if (!Array.isArray(list)) return false + return list.some((item: unknown) => { + if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false + if (item.id !== "google") return false + if (typeof item.options !== "object" || item.options === null) return false + return "fetch" in item.options + }) +} + +function hasProviderWithFetch(input: unknown, key: "all" | "providers") { + if (typeof input !== "object" || input === null) return false + if (key === "all") return "all" in input && providerListHasFetch(input.all) + return "providers" in input && providerListHasFetch(input.providers) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function providerList(input: unknown, key: "all" | "providers") { + if (!isRecord(input)) return [] + if (!Array.isArray(input[key])) return [] + return input[key] +} + +function providerByID(input: unknown, key: "all" | "providers", id: string) { + return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id) +} + +function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider) || !isRecord(provider.models)) return false + return Object.values(provider.models).some((model) => { + if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false + return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some( + (cost) => typeof cost === "number" && cost > 0, + ) + }) +} + +function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider)) return false + if (provider.name === "mutated-provider") return true + return isRecord(provider.options) && provider.options.mutatedByPlugin === true +} + function requestAuthorize(input: { app: ReturnType providerID: string @@ -76,6 +124,73 @@ function writeProviderAuthPlugin(dir: string) { }) } +function writeFunctionOptionsPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-function-options.ts"), + [ + "export default {", + ' id: "test.provider-function-options",', + " server: async () => ({", + " auth: {", + ' provider: "google",', + " loader: async (_getAuth, provider) => {", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return {", + ' apiKey: "",', + " fetch: async (input, init) => fetch(input, init),", + " }", + " },", + " methods: [{ type: 'api', label: 'API key' }],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + +function writeProviderModelsMutationPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"), + [ + "export default {", + ' id: "test.provider-models-mutation",', + " server: async () => ({", + " provider: {", + ' id: "google",', + " models: async (provider) => {", + " const models = Object.fromEntries(", + " Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),", + " )", + ' provider.name = "mutated-provider"', + " provider.options = { ...provider.options, mutatedByPlugin: true }", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return models", + " },", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function withProviderProject(self: (dir: string) => Effect.Effect) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -136,4 +251,74 @@ describe("provider HttpApi", () => { }), ), ) + + it.live("serves provider lists when auth loaders add runtime fetch options", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + const previous = process.env.KILO_AUTH_CONTENT + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeFunctionOptionsPlugin(dir) + yield* Effect.sync(() => { + process.env.KILO_AUTH_CONTENT = JSON.stringify({ + google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 }, + }) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) delete process.env.KILO_AUTH_CONTENT + if (previous !== undefined) process.env.KILO_AUTH_CONTENT = previous + }), + ) + const headers = { "x-kilo-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderWithFetch(providerBody, "all")).toBe(false) + expect(hasProviderWithFetch(configBody, "providers")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true) + }), + ) + + it.live("keeps provider.models hook input mutations out of provider state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeProviderModelsMutationPlugin(dir) + + const headers = { "x-kilo-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false) + expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + }), + ) }) diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts new file mode 100644 index 0000000000..959c5fc3e8 --- /dev/null +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Server } from "../../src/server/server" +import { SessionID } from "../../src/session/schema" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { + FilePaths, + FileQuery, + FindFileQuery, + FindTextQuery, +} from "../../src/server/routes/instance/httpapi/groups/file" +import { + ExperimentalPaths, + SessionListQuery as ExperimentalSessionListQuery, + ToolListQuery, +} from "../../src/server/routes/instance/httpapi/groups/experimental" +import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { + ListQuery as SessionListQuery, + MessagesQuery, + SessionPaths, +} from "../../src/server/routes/instance/httpapi/groups/session" +import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" +import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" +import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES + +type Method = "get" | "post" | "put" | "delete" | "patch" +type QuerySchema = { readonly fields: Record } +type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string } +type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } +type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } + +const openApiDriftRoutes = [ + { method: "get", path: SessionPaths.list, query: SessionListQuery }, + { method: "get", path: SessionPaths.messages, query: MessagesQuery }, + { method: "get", path: FilePaths.findFile, query: FindFileQuery }, + { method: "get", path: FilePaths.findText, query: FindTextQuery }, + { method: "get", path: FilePaths.list, query: FileQuery }, + { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery }, + { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery }, + { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery }, + { method: "get", path: "/api/session", query: V2SessionsQuery }, + { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, +] satisfies Array<{ method: Method; path: string; query: QuerySchema }> + +const numericSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } }, + { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } }, + { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } }, + { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } }, + { + method: "get", + path: SessionPaths.messages, + name: "limit", + schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + }, + { method: "get", path: "/api/session", name: "limit", schema: { type: "number" } }, + { method: "get", path: "/api/session", name: "start", schema: { type: "number" } }, + { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, +] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> + +function app() { + return Server.Default().app +} + +function request(url: string, init?: RequestInit) { + return Effect.promise(async () => app().request(url, init)) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) +} + +function openApiPath(path: string) { + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") +} + +function queryParameters(operation: OpenApiOperation | undefined) { + return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) +} + +function queryParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) +} + +function assertAdvertisedQueryParamsAreRuntimeFields(input: { + readonly method: Method + readonly operation: OpenApiOperation | undefined + readonly path: string + readonly query: QuerySchema +}) { + const runtimeFields = new Set(Object.keys(input.query.fields)) + const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name)) + + expect( + advertisedOnly, + `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`, + ).toEqual([]) +} + +afterEach(async () => { + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +// Regression for the "OpenAPI advertises ?directory&workspace, runtime +// rejects them" drift class. Each affected route must accept both params +// without 400. +describe("httpapi query schema drift", () => { + const routingParams = (dir: string) => + `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}` + + const expectNotSchemaRejection = (status: number, url: string) => { + expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) + } + + it.effect( + "boolean query schema accepts only true and false strings", + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(QueryBoolean) + const encode = Schema.encodeUnknownSync(QueryBoolean) + + expect(decode("true")).toBe(true) + expect(decode("false")).toBe(false) + expect(encode(true)).toBe("true") + expect(encode(false)).toBe("false") + + for (const input of ["1", "yes", "True", "", true, false]) { + expect(() => decode(input)).toThrow() + } + }), + ) + + it.effect( + "OpenAPI workspace query params are declared by runtime query schemas", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const route of openApiDriftRoutes) { + assertAdvertisedQueryParamsAreRuntimeFields({ + ...route, + operation: spec.paths[openApiPath(route.path)]?.[route.method], + }) + } + }), + ) + + it.effect( + "OpenAPI numeric query params preserve generated SDK call shapes", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const expected of numericSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(expected.schema) + } + }), + ) + + it.effect( + "drift assertion catches spec-only workspace query params", + Effect.sync(() => { + expect(() => + assertAdvertisedQueryParamsAreRuntimeFields({ + method: "get", + operation: { + parameters: [ + { name: "directory", in: "query" }, + { name: "workspace", in: "query" }, + ], + }, + path: "/fixture", + query: { fields: {} }, + }), + ).toThrow("advertises query params not accepted by runtime schema") + }), + ) + + it.live( + "session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "session messages accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/file accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find/file?query=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/text accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find?pattern=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file read accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/file?path=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental tool list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "vcs diff accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 3037c6f662..7c4057add6 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -394,8 +394,16 @@ describe("HttpApi SDK", () => { const missing = yield* capture(() => sdk.session.get({ sessionID })) const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true })) + // Result-tuple path: error body is preserved as-is so existing + // consumers reading `result.error.name` / `JSON.stringify(error)` + // keep working byte-for-byte. expect(missing.error).toEqual(expected) - expect(thrown).toEqual(expected) + // throwOnError path: SDK wraps the body in a real Error with the + // server's message, with the original parsed body preserved under + // `.cause.body`. + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toBe(expected.data.message) + expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected) return { status: missing.status, error: missing.error, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 0ccb63a9ca..385f74c871 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -297,6 +297,36 @@ describe("session HttpApi", () => { ), ) + it.live( + "serves sessions with migrated summary diffs missing file details", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const session = yield* createSession(tmp.path, { title: "legacy diff" }) + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ + summary_additions: 1, + summary_deletions: 0, + summary_files: 1, + summary_diffs: [{ additions: 1, deletions: 0 }], + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ), + ) + + const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { + headers: { "x-kilo-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) + }), + ), + ) + it.live( "serves lifecycle mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => @@ -364,8 +394,15 @@ describe("session HttpApi", () => { headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" }, body: JSON.stringify({ title: "workspace session" }), }) + const messages = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, + { + headers: { "x-kilo-directory": tmp.path }, + }, + ) expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect(messages.status).toBe(200) expect( yield* Effect.sync(() => Database.use((db) => diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index b2af253d5b..ac62dc18ed 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -122,7 +122,7 @@ describe("sync HttpApi", () => { } }) - test("returns structured validation errors", async () => { + test.todo("returns structured validation errors", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await ExperimentalHttpApiServer.webHandler().handler( new Request(`http://localhost${SyncPaths.history}`, { diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts new file mode 100644 index 0000000000..77ad1bc279 --- /dev/null +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -0,0 +1,97 @@ +// Regression: a stored step-finish part with a negative token count made the +// messages endpoint 400. Some providers reported `outputTokens` excluding +// reasoning while also reporting `reasoningTokens` separately, so the +// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to +// negative. The pre-fix `safe()` clamp only guarded against non-finite. The +// strict `NonNegativeInt` schema then made every load of the message list +// fail to encode, killing Desktop boot for every user with such a row. +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { MessageID, PartID } from "../../src/session/schema" +import * as Database from "@/storage/db" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function seedNegativeTokenSession(directory: string) { + return Effect.promise(async () => + WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + // Bypass the schema with a direct SQL update to install the + // negative `output` value we want to test loading. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }), + ) +} + +describe("messages endpoint tolerates legacy negative token counts", () => { + it.live( + "returns 200 even when a step-finish part has tokens.output < 0", + Effect.acquireRelease( + Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const sessionID = yield* seedNegativeTokenSession(tmp.path) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) + }), + ), + ), + ) +}) diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts new file mode 100644 index 0000000000..31195dd021 --- /dev/null +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -0,0 +1,74 @@ +/** + * Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true` + * path used to throw raw values (empty strings or POJOs from JSON-decoded + * error bodies). The TUI catches those and `e.message`/`e.stack` are + * undefined, so users see `[object Object]` or a blank crash. + * + * Both cases must throw a real `Error` instance with a non-empty `.message` + * extracted from the response body, plus `.status` and `.body` attached. + */ +import { afterEach, describe, expect, test } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2" +import { Server } from "../../src/server/server" +import * as Log from "@opencode-ai/core/util/log" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createKiloClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v2 SDK error shape", () => { + test("404 with NamedError body throws a real Error carrying the server message", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { body?: any; status?: number } + expect(err.message).toContain("Session not found") + expect(cause.status).toBe(404) + expect(cause.body).toMatchObject({ + name: "NotFoundError", + data: { message: expect.stringContaining("Session not found") }, + }) + }) + + test("400 with empty body throws a real Error naming the status", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + // POST /sync/history with `aggregate: -1` triggers schema validation + // that returns an empty 400 body (verified via plan-mode probe). + await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { status?: number } + expect(err.message.length).toBeGreaterThan(0) + expect(cause.status).toBe(400) + }) +}) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts new file mode 100644 index 0000000000..8079fc5ce4 --- /dev/null +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -0,0 +1,81 @@ +/** + * Regression test for the same bug class as #26574 (sibling of #26566 and + * #26553). The Desktop app calls GET /session//diff; before #26574 + * the response was Schema-encoded against `Snapshot.FileDiff` with + * `patch: Schema.String` (required), so any session whose stored + * `summary_diffs` had a row without `patch` returned HTTP 400 and the + * session never loaded. + * + * This test inserts a session row with a missing-patch diff entry and + * asserts that GET /session//diff returns 200 with the row intact. + */ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Server } from "@/server/server" +import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { Storage } from "@/storage/storage" +import { WithInstance } from "@/project/with-instance" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function pathFor(template: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template) +} + +describe("session diff with missing patch (#26574)", () => { + it.live("GET /session//diff returns 200 when summary_diffs row has no patch", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })), + (t) => Effect.promise(() => t[Symbol.asyncDispose]()), + ) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Effect.runPromise( + Effect.provide( + Session.Service.use((s) => s.create({ title: "missing-patch" })), + Session.defaultLayer, + ), + ) + + // Mimic legacy/imported on-disk shape: a diff entry with no + // `patch` text. Pre-fix the typed response encoder rejects + // this and returns 400. + await Effect.runPromise( + Effect.provide( + Storage.Service.use((s) => + s.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), + ), + Storage.defaultLayer, + ), + ) + + const headers = { "x-kilo-directory": tmp.path } + const response = await Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), { + headers, + }) + expect(response.status).toBe(200) + const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }> + expect(body).toHaveLength(1) + expect(body[0]?.file).toBe("legacy.txt") + expect(body[0]?.additions).toBe(1) + expect(body[0]?.patch).toBeUndefined() + }, + }), + ) + }), + ) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index a1feb1725f..1ef8b490a2 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" @@ -165,4 +164,28 @@ describe("session messages endpoint", () => { }), ) }) + + test("accepts directory query used by workspace routing", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await fill(session.id, 1) + const app = Server.Default().app + + const res = await app.request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + + await svc.remove(session.id) + }, + }), + ) + }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index bee2184e5b..e9628ce49f 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -83,6 +83,26 @@ describe("Session.Info", () => { expect(Session.Info.zod.parse(input)).toEqual(input) }) + test("accepts migrated summary diffs without file details", () => { + const input = { + id: sessionID, + slug: "legacy-diff", + projectID, + directory: "/tmp/proj", + title: "Legacy diff", + version: "0.1.0", + summary: { + additions: 1, + deletions: 0, + files: 1, + diffs: [{ additions: 1, deletions: 0 }], + }, + time: { created: 1, updated: 2 }, + } + expect(decode(input)).toEqual(input) + expect(Session.Info.zod.parse(input)).toEqual(input) + }) + test("rejects unbranded session id", () => { const bad = { id: "not-a-session-id" } as unknown expect(() => decode(bad)).toThrow() diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 82b88a72fd..671f62145c 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -238,7 +238,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget - let diff: Array<{ file: string }> = [] + let diff: Array<{ file?: string }> = [] for (let i = 0; i < 50; i++) { diff = yield* summary.diff({ sessionID: session.id }) if (diff.length > 0) break diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts new file mode 100644 index 0000000000..2d30646976 --- /dev/null +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "bun:sqlite" +import { drizzle } from "drizzle-orm/bun-sqlite" +import { migrate } from "drizzle-orm/bun-sqlite/migrator" +import { readFileSync, readdirSync } from "fs" +import path from "path" + +const target = "20260507164347_add_workspace_time" + +function migrations() { + return readdirSync(path.join(import.meta.dirname, "../../migration"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + timestamp: Number(entry.name.split("_")[0]), + sql: readFileSync(path.join(import.meta.dirname, "../../migration", entry.name, "migration.sql"), "utf-8"), + })) + .sort((a, b) => a.timestamp - b.timestamp) +} + +describe("workspace time migration", () => { + test("migrates existing workspace rows", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + const entries = migrations() + const index = entries.findIndex((entry) => entry.name === target) + + expect(index).toBeGreaterThan(0) + + migrate(db, entries.slice(0, index)) + sqlite.run( + "INSERT INTO project (id, worktree, vcs, name, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["project_1", "/tmp/project", "git", "project", 1, 1, "[]"], + ) + sqlite.run( + "INSERT INTO workspace (id, type, name, branch, directory, extra, project_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["workspace_1", "local", "main", "main", "/tmp/project", null, "project_1"], + ) + + expect(() => migrate(db, entries.slice(index))).not.toThrow() + expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 }) + }) +}) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 9f6a0617ed..17af7b983e 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" -import { toJsonSchema } from "../../src/util/effect-zod" +import { toJsonSchema } from "@opencode-ai/core/effect-zod" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 695d96ec2f..969364bad9 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -155,11 +155,24 @@ describe("tool.read external_directory permission", () => { yield* exec(dir, { filePath: alt }, next) const read = items.find((item) => item.permission === "read") expect(read).toBeDefined() - expect(read!.patterns).toEqual([full(target)]) + expect(read!.patterns).toEqual([path.relative(dir, full(target))]) }), ) } + it.live("uses worktree-relative path for read permission so user rules match like edit/write", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* put(path.join(dir, "src", "secret.ts"), "shh") + + const { items, next } = asks() + yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next) + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + expect(read!.patterns).toEqual([path.join("src", "secret.ts")]) + }), + ) + it.live("asks for directory-scoped external_directory permission when reading external directory", () => Effect.gen(function* () { const outer = yield* tmpdirScoped() diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 70cd8f0e64..ab3923d8e0 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 } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ed59bd9005..04d27ab212 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.3.18", + "version": "7.3.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 4b37e54651..e557a9763a 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -77,6 +77,42 @@ export type TuiKeys = { export type TuiKeymap = Keymap +/** + * Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. + */ +export type TuiCommand = { + title: string + value: string + description?: string + category?: string + keybind?: string + suggested?: boolean + hidden?: boolean + enabled?: boolean + slash?: { + name: string + aliases?: string[] + } + onSelect?: (dialog?: TuiDialogStack) => void | Promise +} + +/** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ +export type TuiCommandApi = { + /** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */ + register: (cb: () => TuiCommand[]) => () => void + /** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */ + trigger: (value: string) => void + /** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */ + show: () => void +} + export type TuiDialogProps = { size?: "medium" | "large" | "xlarge" onClose: () => void @@ -461,6 +497,13 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp + /** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ + command?: TuiCommandApi keys: TuiKeys keymap: TuiKeymap route: { diff --git a/packages/script/package.json b/packages/script/package.json index 74d5106dcd..30bf4362ef 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.3.18", + "version": "7.3.22", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index e0190ed33e..bf0381d5ce 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.3.18", + "version": "7.3.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index f6b696368b..d30033953b 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { KiloClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "./error-interceptor.js" export { type Config as KiloClientConfig, KiloClient } function pick(value: string | null, fallback?: string) { @@ -51,5 +52,6 @@ export function createKiloClient(config?: Config & { directory?: string }) { const client = createClient(config) client.interceptors.request.use((request) => rewrite(request, config?.directory)) + client.interceptors.error.use(wrapClientError) return new KiloClient({ client }) } diff --git a/packages/sdk/js/src/error-interceptor.ts b/packages/sdk/js/src/error-interceptor.ts new file mode 100644 index 0000000000..26407ecfc9 --- /dev/null +++ b/packages/sdk/js/src/error-interceptor.ts @@ -0,0 +1,51 @@ +/** + * Wrap whatever the generated client decoded from a non-2xx error body + * into a real `Error` so downstream formatters (TUI, plugins) get a + * useful `.message` instead of `[object Object]` or blank. The original + * parsed body and status live under `.cause` for callers that need + * structured fields. + * + * Only fires when the caller used `{ throwOnError: true }`. Callers that + * read `result.error` directly (the result-tuple path) get the parsed + * body unchanged so existing field-level reads (`.error.name`, + * `JSON.stringify(error)`, etc.) are byte-for-byte identical to before. + */ +export function wrapClientError( + error: unknown, + response: Response | undefined, + request: Request | undefined, + opts: { throwOnError?: boolean } | undefined, +): unknown { + if (!opts?.throwOnError) return error + if (error instanceof Error) return error + + // NamedError-shaped responses (the common case for opencode 4xx) come + // through as POJOs — extract a useful message first, then wrap. + if (typeof error === "object" && error !== null && Object.keys(error).length > 0) { + const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown } + const message = + (typeof obj.data?.message === "string" && obj.data.message) || + (typeof obj.message === "string" && obj.message) || + (typeof obj.name === "string" && obj.name) || + describe(request, response) + return new Error(message, { cause: { body: error, status: response?.status } }) + } + + if (typeof error === "string" && error.length > 0) { + return new Error(error, { cause: { body: error, status: response?.status } }) + } + + // Empty body / network failure / undefined / null / empty object. + const reason = response ? "(empty response body)" : "network error (no response)" + return new Error(`opencode server ${describe(request, response)}: ${reason}`, { + cause: { body: error, status: response?.status }, + }) +} + +function describe(request: Request | undefined, response: Response | undefined) { + const method = request?.method ?? "?" + const url = request?.url ?? "?" + const status = response?.status + const statusText = response?.statusText + return `${method} ${url}${status ? " → " + status : ""}${statusText ? " " + statusText : ""}` +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe9..62e1b8fe8d 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1065,7 +1065,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options?: { [key: string]: unknown } @@ -3012,7 +3012,7 @@ export type ProviderListResponses = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options: { [key: string]: unknown } diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index de272ba2ae..55ba10edb5 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { KiloClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "../error-interceptor.js" export { type Config as KiloClientConfig, KiloClient } function pick(value: string | null, fallback?: string, encode?: (value: string) => string) { @@ -84,24 +85,6 @@ export function createKiloClient(config?: Config & { directory?: string; experim return response }) - // The generated client falls back to throwing a literal `{}` when the server - // responds with an empty / unparseable error body, which surfaces as a bare - // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so - // downstream formatters get a useful message — but pass through any parsed - // JSON error body unchanged so existing consumers can still inspect fields. - client.interceptors.error.use((error, response, request) => { - const isEmpty = - error === undefined || - error === null || - error === "" || - (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) - if (!isEmpty) return error - const method = request?.method ?? "?" - const url = request?.url ?? "?" - if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) - const status = response.status - const statusText = response.statusText ? " " + response.statusText : "" - return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) - }) + client.interceptors.error.use(wrapClientError) return new KiloClient({ client }) } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 51cd7607d6..c0c57ea157 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4163,6 +4163,13 @@ export class Session3 extends HeyApiClient { parameters?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + cursor?: string }, options?: Options, ) { @@ -4173,6 +4180,13 @@ export class Session3 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "path" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "search" }, + { in: "query", key: "cursor" }, ], }, ], @@ -4331,6 +4345,9 @@ export class Session3 extends HeyApiClient { sessionID: string directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + cursor?: string }, options?: Options, ) { @@ -4342,6 +4359,9 @@ export class Session3 extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 220278b8c2..4779f7cebd 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -119,8 +119,8 @@ export type PermissionRequest = { } export type SnapshotFileDiff = { - file: string - patch: string + file?: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -1060,7 +1060,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" provider?: { npm?: string api?: string @@ -1520,7 +1520,7 @@ export type VcsFileStatus = { export type VcsFileDiff = { file: string - patch: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -6235,6 +6235,16 @@ export type V2SessionListData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters. + */ + cursor?: string } url: "/api/session" } @@ -6352,6 +6362,12 @@ export type V2SessionMessagesData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. + */ + cursor?: string } url: "/api/session/{sessionID}/message" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e19ead9b32..8e7d56a987 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -115,18 +115,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -446,18 +446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -490,18 +490,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -532,18 +532,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -595,18 +595,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -656,18 +656,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -710,18 +710,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -797,18 +797,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -861,18 +861,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "provider", @@ -931,18 +931,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -985,18 +985,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1031,18 +1031,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1092,18 +1092,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1156,18 +1156,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1220,18 +1220,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "roots", @@ -1332,18 +1332,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1380,18 +1380,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "pattern", @@ -1496,18 +1496,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1580,18 +1580,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1636,18 +1636,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1692,18 +1692,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1744,18 +1744,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1792,18 +1792,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1837,18 +1837,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1881,18 +1881,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1925,18 +1925,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1973,18 +1973,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "mode", @@ -2030,18 +2030,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2074,18 +2074,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2152,18 +2152,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2200,18 +2200,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2248,18 +2248,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2312,18 +2312,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2360,18 +2360,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2408,18 +2408,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2454,18 +2454,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2535,22 +2535,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.start", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2558,6 +2542,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2616,22 +2616,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2639,6 +2623,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2687,22 +2687,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2710,6 +2694,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2775,22 +2775,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.authenticate", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2798,6 +2782,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2847,22 +2847,6 @@ "tags": ["mcp"], "operationId": "mcp.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2870,6 +2854,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2899,22 +2899,6 @@ "tags": ["mcp"], "operationId": "mcp.disconnect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2922,6 +2906,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2954,18 +2954,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3002,18 +3002,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3046,18 +3046,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3087,22 +3087,6 @@ "tags": ["project"], "operationId": "project.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "projectID", "in": "path", @@ -3110,6 +3094,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3202,18 +3202,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3263,18 +3263,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3309,18 +3309,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3393,22 +3393,6 @@ "tags": ["pty"], "operationId": "pty.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3417,6 +3401,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3454,22 +3454,6 @@ "tags": ["pty"], "operationId": "pty.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3478,6 +3462,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3545,22 +3545,6 @@ "tags": ["pty"], "operationId": "pty.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3569,6 +3553,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3609,22 +3609,6 @@ "tags": ["pty"], "operationId": "pty.connectToken", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3633,6 +3617,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3697,18 +3697,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3742,22 +3742,6 @@ "tags": ["question"], "operationId": "question.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3766,6 +3750,22 @@ "pattern": "^que.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3836,22 +3836,6 @@ "tags": ["question"], "operationId": "question.reject", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3860,6 +3844,22 @@ "pattern": "^que.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3913,18 +3913,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3958,22 +3958,6 @@ "tags": ["permission"], "operationId": "permission.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3982,6 +3966,22 @@ "pattern": "^per.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4055,18 +4055,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4122,18 +4122,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4170,22 +4170,6 @@ "tags": ["provider"], "operationId": "provider.oauth.authorize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4193,6 +4177,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4255,22 +4255,6 @@ "tags": ["provider"], "operationId": "provider.oauth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4278,6 +4262,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4341,18 +4341,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "scope", @@ -4444,18 +4444,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4541,18 +4541,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4596,22 +4596,6 @@ "tags": ["session"], "operationId": "session.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4620,6 +4604,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4667,22 +4667,6 @@ "tags": ["session"], "operationId": "session.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4691,6 +4675,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4739,22 +4739,6 @@ "tags": ["session"], "operationId": "session.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4763,6 +4747,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4839,22 +4839,6 @@ "tags": ["session"], "operationId": "session.children", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4863,6 +4847,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4916,22 +4916,6 @@ "tags": ["session"], "operationId": "session.todo", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4940,6 +4924,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4993,22 +4993,6 @@ "tags": ["session"], "operationId": "session.diff", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5018,6 +5002,22 @@ }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "messageID", "in": "query", @@ -5059,22 +5059,6 @@ "tags": ["session"], "operationId": "session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5084,6 +5068,22 @@ }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "limit", "in": "query", @@ -5165,22 +5165,6 @@ "tags": ["session"], "operationId": "session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5189,6 +5173,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5319,22 +5319,6 @@ "tags": ["session"], "operationId": "session.message", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5352,6 +5336,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5413,22 +5413,6 @@ "tags": ["session"], "operationId": "session.deleteMessage", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5446,6 +5430,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5496,22 +5496,6 @@ "tags": ["session"], "operationId": "session.fork", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5520,6 +5504,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5574,22 +5574,6 @@ "tags": ["session"], "operationId": "session.abort", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5598,6 +5582,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5648,22 +5648,6 @@ "tags": ["session"], "operationId": "session.init", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5672,6 +5656,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5744,22 +5744,6 @@ "tags": ["session"], "operationId": "session.share", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5768,6 +5752,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5815,22 +5815,6 @@ "tags": ["session"], "operationId": "session.unshare", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5839,6 +5823,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5888,22 +5888,6 @@ "tags": ["session"], "operationId": "session.summarize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5912,6 +5896,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5984,22 +5984,6 @@ "tags": ["session"], "operationId": "session.prompt_async", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6008,6 +5992,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6119,22 +6119,6 @@ "tags": ["session"], "operationId": "session.command", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6143,6 +6127,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6264,22 +6264,6 @@ "tags": ["session"], "operationId": "session.shell", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6288,6 +6272,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6386,22 +6386,6 @@ "tags": ["session"], "operationId": "session.revert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6410,6 +6394,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6478,22 +6478,6 @@ "tags": ["session"], "operationId": "session.unrevert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6502,6 +6486,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6551,22 +6551,6 @@ "tags": ["session"], "operationId": "permission.respond", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6584,6 +6568,22 @@ "pattern": "^per.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6652,22 +6652,6 @@ "tags": ["session"], "operationId": "part.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6694,6 +6678,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6741,22 +6741,6 @@ "tags": ["session"], "operationId": "part.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6783,6 +6767,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6843,18 +6843,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6888,18 +6888,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6993,18 +6993,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7071,18 +7071,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7162,18 +7162,84 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "roots", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] + }, + "required": false + }, + { + "name": "start", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters." + }, + "required": false } ], "responses": { @@ -7213,22 +7279,6 @@ "tags": ["v2"], "operationId": "v2.session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7237,6 +7287,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7285,22 +7351,6 @@ "tags": ["v2"], "operationId": "v2.session.compact", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7309,6 +7359,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7331,22 +7397,6 @@ "tags": ["v2"], "operationId": "v2.session.wait", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7355,6 +7405,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7377,22 +7443,6 @@ "tags": ["v2"], "operationId": "v2.session.context", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7401,6 +7451,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7433,22 +7499,6 @@ "tags": ["v2 messages"], "operationId": "v2.session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7457,6 +7507,48 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order." + }, + "required": false } ], "responses": { @@ -7499,18 +7591,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7570,18 +7662,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7615,18 +7707,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7660,18 +7752,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7705,18 +7797,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7750,18 +7842,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7795,18 +7887,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7840,18 +7932,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7911,18 +8003,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7983,18 +8075,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8060,18 +8152,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8142,18 +8234,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8195,18 +8287,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8247,18 +8339,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8308,18 +8400,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8354,18 +8446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8445,18 +8537,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8482,18 +8574,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8538,22 +8630,6 @@ "tags": ["workspace"], "operationId": "experimental.workspace.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "id", "in": "path", @@ -8562,6 +8638,22 @@ "pattern": "^wrk.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -8604,18 +8696,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8684,22 +8776,6 @@ "tags": ["pty"], "operationId": "pty.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -8708,6 +8784,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -9108,19 +9200,17 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["additions", "deletions"], "additionalProperties": false }, "ProviderAuthError": { @@ -9955,31 +10045,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -10163,12 +10247,10 @@ "type": "string" }, "start": { - "type": "integer", - "minimum": 0 + "type": "number" }, "end": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["value", "start", "end"], @@ -10551,31 +10633,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -10843,16 +10919,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -11725,7 +11798,7 @@ }, "status": { "type": "string", - "enum": ["alpha", "beta", "deprecated"] + "enum": ["alpha", "beta", "deprecated", "active"] }, "provider": { "type": "object", @@ -12675,16 +12748,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -13002,12 +13072,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -13027,19 +13095,17 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["file", "additions", "deletions"], "additionalProperties": false }, "VcsApplyError": { @@ -14155,16 +14221,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -14749,27 +14812,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -15487,8 +15545,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" @@ -16991,27 +17048,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -17599,8 +17651,7 @@ "type": "string" }, "statusCode": { - "type": "integer", - "minimum": 0 + "type": "number" }, "isRetryable": { "type": "boolean" @@ -17644,8 +17695,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 6999aa7ba6..0a874b2191 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.3.18", + "version": "7.3.22", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 7f265caddc..43362e1a02 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.3.18", + "version": "7.3.22", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 6a9267d2c7..0492314704 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -12,7 +12,8 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff +type SnapshotDiff = SnapshotFileDiff & { file: string } +type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff export type ViewDiff = { file: string diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 35d823facf..debfbcb435 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -62,7 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } +type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } function diff(value: unknown): value is ReviewDiff { @@ -108,7 +113,7 @@ export interface SessionReviewProps { classList?: Record classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element - diffs: ReviewDiff[] + diffs: RawReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise lineCommentMention?: LineCommentEditorProps["mention"] diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 944962579c..8ff280eee8 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -90,6 +90,12 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } +type SummaryDiff = SnapshotFileDiff & { file: string } + +function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + return typeof value.file === "string" +} + const hidden = new Set(["todowrite"]) function partState(part: PartType, showReasoningSummaries: boolean) { @@ -169,7 +175,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: SnapshotFileDiff[] = [] + const emptyDiffs: SummaryDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -238,7 +244,8 @@ export function SessionTurn( const seen = new Set() return files - .reduceRight((result, diff) => { + .reduceRight((result, diff) => { + if (!summaryDiff(diff)) return result if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff)