diff --git a/.gitattributes b/.gitattributes index 18177b31a5..27a99603b2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ packages/core/migration/**/snapshot.json linguist-generated packages/core/src/database/migration.gen.ts linguist-generated +packages/core/src/**/*.txt text eol=lf diff --git a/AGENTS.md b/AGENTS.md index f6f3c970e8..14f9f27f14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,6 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`. -Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or circumvent commit or push hooks. If a hook fails, fix the failure or stop and report it to the user. - ## Style Guide ### General Principles diff --git a/bun.lock b/bun.lock index 04fa74a1c2..f5fe5b2523 100644 --- a/bun.lock +++ b/bun.lock @@ -724,6 +724,7 @@ "@opencode-ai/client": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@standard-schema/spec": "^1.1.0", "effect": "catalog:", "zod": "catalog:", }, @@ -3008,7 +3009,7 @@ "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="], - "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@stoplight/better-ajv-errors": ["@stoplight/better-ajv-errors@1.0.3", "", { "dependencies": { "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA=="], @@ -6392,8 +6393,6 @@ "@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -6610,6 +6609,8 @@ "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@hono/standard-validator/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -7332,8 +7333,6 @@ "editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -7394,8 +7393,6 @@ "fumadocs-core/shiki": ["shiki@4.3.1", "", { "dependencies": { "@shikijs/core": "4.3.1", "@shikijs/engine-javascript": "4.3.1", "@shikijs/engine-oniguruma": "4.3.1", "@shikijs/langs": "4.3.1", "@shikijs/themes": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw=="], - "fumadocs-mdx/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "fumadocs-mdx/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "fumadocs-mdx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], @@ -7534,6 +7531,8 @@ "opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], + "opencode/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], "opencode/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -7740,46 +7739,6 @@ "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/cohere/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/deepgram/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/elevenlabs/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/fireworks/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/perplexity/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/togetherai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -8382,10 +8341,6 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], - "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@stoplight/spectral-core/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], @@ -8850,10 +8805,6 @@ "venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], - "venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], @@ -9118,8 +9069,6 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], - "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], @@ -9302,14 +9251,6 @@ "@vitejs/plugin-react/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], diff --git a/packages/ai/README.md b/packages/ai/README.md index e88213e342..b9139bc3af 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -127,23 +127,37 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints: - `@opencode-ai/ai/providers/openai/responses` - `@opencode-ai/ai/providers/openai-compatible/responses` - `@opencode-ai/ai/providers/anthropic-compatible` -- `@opencode-ai/ai/providers/google-vertex` -- `@opencode-ai/ai/providers/google-vertex/anthropic` +- `@opencode-ai/ai/providers/google-vertex/gemini` +- `@opencode-ai/ai/providers/google-vertex/chat` +- `@opencode-ai/ai/providers/google-vertex/responses` +- `@opencode-ai/ai/providers/google-vertex/messages` Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths. -Vertex Gemini and Vertex Anthropic are separate products with separate entrypoints. Both accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. +Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`. Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890` and require OAuth or ADC; Vertex express-mode API keys support publisher models only. ```ts -import { model } from "@opencode-ai/ai/providers/google-vertex" +import { model } from "@opencode-ai/ai/providers/google-vertex/gemini" model("gemini-3.5-flash", { project: "my-project", location: "global" }) ``` ```ts -import { model } from "@opencode-ai/ai/providers/google-vertex/anthropic" +import { model } from "@opencode-ai/ai/providers/google-vertex/chat" + +model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" }) +``` + +```ts +import { model } from "@opencode-ai/ai/providers/google-vertex/responses" + +model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" }) +``` + +```ts +import { model } from "@opencode-ai/ai/providers/google-vertex/messages" model("claude-sonnet-4-6", { project: "my-project", location: "global" }) ``` diff --git a/packages/ai/STATUS.md b/packages/ai/STATUS.md index f3a95c1a7b..85980d58cf 100644 --- a/packages/ai/STATUS.md +++ b/packages/ai/STATUS.md @@ -1,15 +1,15 @@ # LLM Provider Parity Status -Last reviewed: 2026-07-15 +Last reviewed: 2026-07-16 This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. ## Existing Status Sources -| File | What it tracks | Limitation | -| ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `packages/ai/DESIGN.md` | Future clean-break API proposal for `@opencode-ai/ai`. | Not a provider parity tracker. | -| `packages/ai/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. | +| File | What it tracks | Limitation | +| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------- | +| `packages/ai/DESIGN.md` | Future clean-break API proposal for `@opencode-ai/ai`. | Not a provider parity tracker. | +| `packages/ai/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. | ## Current Implementation Snapshot @@ -23,8 +23,10 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI | Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base. | No named compatible family profiles or recorded deployment coverage yet. | | Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. | | Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. | -| Vertex Gemini | `src/protocols/google-vertex-gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. | -| Vertex Anthropic Messages | `src/protocols/google-vertex-anthropic.ts`, `src/providers/google-vertex-anthropic.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. | +| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. | +| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. | +| Vertex Responses | `src/protocols/openai-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through OpenAI-compatible Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and storage disabled by default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. | +| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. | | Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | | Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | | Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | @@ -54,8 +56,8 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently | `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. | | `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. | | `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. | -| `@ai-sdk/google-vertex/maas` | Vertex MaaS OpenAI-compatible namespace/facade | Missing | Decide native Chat/Responses selection, endpoint derivation, auth, and catalog mapping. | -| `@ai-sdk/google-vertex/xai` | Vertex xAI OpenAI-compatible namespace/facade | Missing | Decide whether this composes the generic compatible bases or the xAI facade, then add endpoint/auth mapping and tests. | +| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. | +| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. | | `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. | | `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. | | `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. | @@ -65,19 +67,19 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently 1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata. 2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it. 3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade. -4. Vertex Gemini and Vertex Anthropic now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing. +4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing. 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. -8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native boundaries remain for Vertex MaaS, Vertex xAI, and Bedrock Mantle. +8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection; the missing native boundary is Bedrock Mantle. 9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. ## Native Namespace Shape These are implementation/API slices, not separate npm packages. -| API slice | Package-like entrypoint | Purpose | -| ----------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | +| API slice | Package-like entrypoint | Purpose | +| ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- | | OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | | OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | | OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | @@ -85,12 +87,12 @@ These are implementation/API slices, not separate npm packages. | Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. | | Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. | | Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. | -| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex` | Vertex Gemini API. | -| Vertex Anthropic Messages | `@opencode-ai/ai/providers/google-vertex/anthropic` | Vertex-hosted Anthropic Messages API. | -| Vertex MaaS | Missing | Vertex OpenAI-compatible MaaS APIs. | -| Vertex xAI | Missing | Vertex-hosted xAI APIs. | +| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. | +| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. | +| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex OpenAI-compatible Responses for Grok models. | +| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. | | Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. | -| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | +| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | | Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. | | Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. | @@ -99,8 +101,8 @@ These are implementation/API slices, not separate npm packages. 1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close. 2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses. 3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling. -4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini and Vertex Anthropic entrypoints. -5. Add native Vertex MaaS and Vertex xAI entrypoints by composing the compatible bases and shared Vertex auth/endpoint setup. +4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints. +5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models. 6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model. 7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples. -8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Anthropic, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages. +8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages. diff --git a/packages/ai/example/call-sites.md b/packages/ai/example/call-sites.md index 84a9a9e4be..0b33d28c48 100644 --- a/packages/ai/example/call-sites.md +++ b/packages/ai/example/call-sites.md @@ -360,17 +360,29 @@ import { model } from "@opencode-ai/ai/providers/openai/responses" model("gpt-4o", { apiKey, transport: "websocket" }) ``` -Vertex keeps Gemini and Anthropic Messages as separate package-like entrypoints, +Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints, while sharing project/location resolution and ADC authentication internally: ```ts -import { model } from "@opencode-ai/ai/providers/google-vertex" +import { model } from "@opencode-ai/ai/providers/google-vertex/gemini" model("gemini-3.5-flash", { project, location: "global" }) ``` ```ts -import { model } from "@opencode-ai/ai/providers/google-vertex/anthropic" +import { model } from "@opencode-ai/ai/providers/google-vertex/chat" + +model("deepseek-ai/deepseek-v3.2-maas", { project, location: "global" }) +``` + +```ts +import { model } from "@opencode-ai/ai/providers/google-vertex/responses" + +model("xai/grok-4.20-reasoning", { project, location: "global" }) +``` + +```ts +import { model } from "@opencode-ai/ai/providers/google-vertex/messages" model("claude-sonnet-4-6", { project, location: "global" }) ``` diff --git a/packages/ai/src/protocols/google-vertex-anthropic.ts b/packages/ai/src/protocols/google-vertex-anthropic.ts deleted file mode 100644 index e387ccd665..0000000000 --- a/packages/ai/src/protocols/google-vertex-anthropic.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Effect, Schema, Struct } from "effect" -import { AnthropicMessages } from "./anthropic-messages" -import { Auth } from "../route/auth" -import { Route } from "../route/client" -import { Endpoint } from "../route/endpoint" -import { Framing } from "../route/framing" -import { Protocol } from "../route/protocol" - -const VERSION = "vertex-2023-10-16" as const - -export const GoogleVertexAnthropicBody = Schema.Struct({ - ...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model"]), - anthropic_version: Schema.Literal(VERSION), -}) -export type GoogleVertexAnthropicBody = Schema.Schema.Type - -export const protocol = Protocol.make({ - id: "google-vertex-anthropic", - body: { - schema: GoogleVertexAnthropicBody, - from: (request) => - AnthropicMessages.protocol.body.from(request).pipe( - Effect.map((body) => ({ - ...Struct.omit(body, ["model"]), - anthropic_version: VERSION, - })), - ), - }, - stream: AnthropicMessages.protocol.stream, -}) - -export const route = Route.make({ - id: "google-vertex-anthropic", - provider: "google-vertex-anthropic", - providerMetadataKey: "anthropic", - protocol, - endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`), - auth: Auth.none, - framing: Framing.sse, -}) - -export * as GoogleVertexAnthropic from "./google-vertex-anthropic" diff --git a/packages/ai/src/protocols/google-vertex-gemini.ts b/packages/ai/src/protocols/google-vertex-gemini.ts deleted file mode 100644 index 3cf00f50c8..0000000000 --- a/packages/ai/src/protocols/google-vertex-gemini.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Gemini } from "./gemini" -import { Auth } from "../route/auth" -import { Route } from "../route/client" -import { Endpoint } from "../route/endpoint" -import { Framing } from "../route/framing" - -export const route = Route.make({ - id: "google-vertex-gemini", - provider: "google-vertex", - providerMetadataKey: "google", - protocol: Gemini.protocol, - endpoint: Endpoint.path(({ request }) => { - const model = String(request.model.id) - return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse` - }), - auth: Auth.none, - framing: Framing.sse, -}) - -export * as GoogleVertexGemini from "./google-vertex-gemini" diff --git a/packages/ai/src/protocols/index.ts b/packages/ai/src/protocols/index.ts index 8c1f332946..d00d517a09 100644 --- a/packages/ai/src/protocols/index.ts +++ b/packages/ai/src/protocols/index.ts @@ -1,6 +1,4 @@ export * as AnthropicMessages from "./anthropic-messages" -export * as GoogleVertexAnthropic from "./google-vertex-anthropic" -export * as GoogleVertexGemini from "./google-vertex-gemini" export * as BedrockConverse from "./bedrock-converse" export * as Gemini from "./gemini" export * as OpenAIChat from "./openai-chat" diff --git a/packages/ai/src/providers/google-vertex-chat.ts b/packages/ai/src/providers/google-vertex-chat.ts new file mode 100644 index 0000000000..e2f9c1a415 --- /dev/null +++ b/packages/ai/src/providers/google-vertex-chat.ts @@ -0,0 +1,81 @@ +import type { ProviderPackage } from "../provider-package" +import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat" +import type { RouteDefaultsInput } from "../route/client" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { GoogleVertexShared } from "./google-vertex-shared" + +export const id = ProviderID.make("google-vertex") + +export type Config = RouteDefaultsInput & + GoogleVertexShared.OAuthOptions & { + readonly baseURL?: string + readonly location?: string + readonly project?: string + } + +export interface Settings extends ProviderPackage.Settings { + readonly accessToken?: string + readonly apiKey?: never + readonly baseURL?: string + readonly location?: string + readonly project?: string + readonly providerOptions?: ProviderOptions +} + +const route = OpenAICompatibleChat.route.with({ + id: "google-vertex-chat", + provider: id, +}) + +export const routes = [route] + +const configuredRoute = (input: Config) => { + if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys") + const { + accessToken: _accessToken, + auth: _auth, + baseURL, + location: inputLocation, + project: inputProject, + ...rest + } = input + const location = GoogleVertexShared.location(inputLocation, "global") + const project = GoogleVertexShared.project(inputProject) + return route.with({ + ...rest, + endpoint: { + baseURL: + baseURL ?? + `https://aiplatform.googleapis.com/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/endpoints/openapi`, + }, + auth: GoogleVertexShared.oauth(input, project), + }) +} + +export const configure = (input: Config = {}) => { + const route = configuredRoute(input) + return { + id, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = { + id, + configure, +} + +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { + if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys") + return configure({ + accessToken: settings.accessToken, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + location: settings.location, + project: settings.project, + providerOptions: settings.providerOptions, + }).model(modelID) +} diff --git a/packages/ai/src/providers/google-vertex-messages.ts b/packages/ai/src/providers/google-vertex-messages.ts new file mode 100644 index 0000000000..7cb6f9cfb2 --- /dev/null +++ b/packages/ai/src/providers/google-vertex-messages.ts @@ -0,0 +1,111 @@ +import { Effect, Schema, Struct } from "effect" +import type { ProviderPackage } from "../provider-package" +import { AnthropicMessages } from "../protocols/anthropic-messages" +import { Auth } from "../route/auth" +import { Route, type RouteDefaultsInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { GoogleVertexShared } from "./google-vertex-shared" + +const VERSION = "vertex-2023-10-16" as const + +// models.dev uses this provider id even though the API contract is Anthropic Messages. +export const id = ProviderID.make("google-vertex-anthropic") + +export type Config = RouteDefaultsInput & + GoogleVertexShared.OAuthOptions & { + readonly baseURL?: string + readonly location?: string + readonly project?: string + } + +export interface Settings extends ProviderPackage.Settings { + readonly accessToken?: string + readonly apiKey?: never + readonly baseURL?: string + readonly location?: string + readonly project?: string + readonly providerOptions?: ProviderOptions +} + +const route = Route.make({ + id: "google-vertex-messages", + provider: id, + providerMetadataKey: "anthropic", + protocol: Protocol.make({ + id: AnthropicMessages.protocol.id, + body: { + schema: Schema.Struct({ + ...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model"]), + anthropic_version: Schema.Literal(VERSION), + }), + from: (request) => + AnthropicMessages.protocol.body.from(request).pipe( + Effect.map((body) => ({ + ...Struct.omit(body, ["model"]), + anthropic_version: VERSION, + })), + ), + }, + stream: AnthropicMessages.protocol.stream, + }), + endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`), + auth: Auth.none, + framing: Framing.sse, +}) + +export const routes = [route] + +const configuredRoute = (input: Config) => { + if ("apiKey" in input && input.apiKey !== undefined) + throw new Error("Google Vertex Messages does not support API keys") + const { + accessToken: _accessToken, + auth: _auth, + baseURL, + location: inputLocation, + project: inputProject, + ...rest + } = input + const location = GoogleVertexShared.location(inputLocation, "global") + const project = GoogleVertexShared.project(inputProject) + return route.with({ + ...rest, + endpoint: { + baseURL: + baseURL ?? + `https://${GoogleVertexShared.host(location)}/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/publishers/anthropic/models`, + }, + auth: GoogleVertexShared.oauth(input, project), + }) +} + +export const configure = (input: Config = {}) => { + const route = configuredRoute(input) + return { + id, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = { + id, + configure, +} + +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { + if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys") + return configure({ + accessToken: settings.accessToken, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + location: settings.location, + project: settings.project, + providerOptions: settings.providerOptions, + }).model(modelID) +} diff --git a/packages/ai/src/providers/google-vertex-anthropic.ts b/packages/ai/src/providers/google-vertex-responses.ts similarity index 78% rename from packages/ai/src/providers/google-vertex-anthropic.ts rename to packages/ai/src/providers/google-vertex-responses.ts index 73502c4aa7..47ede23996 100644 --- a/packages/ai/src/providers/google-vertex-anthropic.ts +++ b/packages/ai/src/providers/google-vertex-responses.ts @@ -1,10 +1,10 @@ import type { ProviderPackage } from "../provider-package" -import { GoogleVertexAnthropic } from "../protocols/google-vertex-anthropic" +import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses" import type { RouteDefaultsInput } from "../route/client" import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import { GoogleVertexShared } from "./google-vertex-shared" -export const id = ProviderID.make("google-vertex-anthropic") +export const id = ProviderID.make("google-vertex") export type Config = RouteDefaultsInput & GoogleVertexShared.OAuthOptions & { @@ -22,11 +22,16 @@ export interface Settings extends ProviderPackage.Settings { readonly providerOptions?: ProviderOptions } -export const routes = [GoogleVertexAnthropic.route] +const route = OpenAICompatibleResponses.route.with({ + id: "google-vertex-responses", + provider: id, +}) + +export const routes = [route] const configuredRoute = (input: Config) => { if ("apiKey" in input && input.apiKey !== undefined) - throw new Error("Google Vertex Anthropic does not support API keys") + throw new Error("Google Vertex Responses does not support API keys") const { accessToken: _accessToken, auth: _auth, @@ -37,12 +42,12 @@ const configuredRoute = (input: Config) => { } = input const location = GoogleVertexShared.location(inputLocation, "global") const project = GoogleVertexShared.project(inputProject) - return GoogleVertexAnthropic.route.with({ + return route.with({ ...rest, endpoint: { baseURL: baseURL ?? - `https://${GoogleVertexShared.host(location)}/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/publishers/anthropic/models`, + `https://aiplatform.googleapis.com/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/endpoints/openapi`, }, auth: GoogleVertexShared.oauth(input, project), }) @@ -63,7 +68,7 @@ export const provider = { } export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { - if (settings.apiKey !== undefined) throw new Error("Google Vertex Anthropic does not support API keys") + if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys") return configure({ accessToken: settings.accessToken, baseURL: settings.baseURL, diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index cd019d6e05..a0f44711d4 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -1,7 +1,9 @@ import type { ProviderPackage } from "../provider-package" -import { GoogleVertexGemini } from "../protocols/google-vertex-gemini" +import { Gemini } from "../protocols/gemini" import { Auth } from "../route/auth" -import type { RouteDefaultsInput } from "../route/client" +import { Route, type RouteDefaultsInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import { GoogleVertexShared } from "./google-vertex-shared" @@ -25,7 +27,20 @@ export type Settings = ProviderPackage.Settings & readonly providerOptions?: ProviderOptions } -export const routes = [GoogleVertexGemini.route] +const route = Route.make({ + id: "google-vertex-gemini", + provider: id, + providerMetadataKey: "google", + protocol: Gemini.protocol, + endpoint: Endpoint.path(({ request }) => { + const model = String(request.model.id) + return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse` + }), + auth: Auth.none, + framing: Framing.sse, +}) + +export const routes = [route] const configuredRoute = (input: Config, modelID: string | ModelID) => { const { @@ -48,7 +63,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => { (apiKey ? "https://aiplatform.googleapis.com/v1/publishers/google" : `https://${GoogleVertexShared.host(location)}/v1beta1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}${endpointModel ? "" : "/publishers/google"}`) - return GoogleVertexGemini.route.with({ + return route.with({ ...rest, endpoint: { baseURL: endpoint }, auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey), diff --git a/packages/ai/src/providers/google-vertex/anthropic.ts b/packages/ai/src/providers/google-vertex/anthropic.ts deleted file mode 100644 index 3bdbf6ff44..0000000000 --- a/packages/ai/src/providers/google-vertex/anthropic.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { model } from "../google-vertex-anthropic" -export type { Settings } from "../google-vertex-anthropic" diff --git a/packages/ai/src/providers/google-vertex/chat.ts b/packages/ai/src/providers/google-vertex/chat.ts new file mode 100644 index 0000000000..085e5e1a28 --- /dev/null +++ b/packages/ai/src/providers/google-vertex/chat.ts @@ -0,0 +1,2 @@ +export { model } from "../google-vertex-chat" +export type { Settings } from "../google-vertex-chat" diff --git a/packages/ai/src/providers/google-vertex/gemini.ts b/packages/ai/src/providers/google-vertex/gemini.ts new file mode 100644 index 0000000000..d48c493513 --- /dev/null +++ b/packages/ai/src/providers/google-vertex/gemini.ts @@ -0,0 +1,2 @@ +export { model } from "../google-vertex" +export type { Settings } from "../google-vertex" diff --git a/packages/ai/src/providers/google-vertex/messages.ts b/packages/ai/src/providers/google-vertex/messages.ts new file mode 100644 index 0000000000..0ef1b3a427 --- /dev/null +++ b/packages/ai/src/providers/google-vertex/messages.ts @@ -0,0 +1,2 @@ +export { model } from "../google-vertex-messages" +export type { Settings } from "../google-vertex-messages" diff --git a/packages/ai/src/providers/google-vertex/responses.ts b/packages/ai/src/providers/google-vertex/responses.ts new file mode 100644 index 0000000000..9ed9fd99db --- /dev/null +++ b/packages/ai/src/providers/google-vertex/responses.ts @@ -0,0 +1,2 @@ +export { model } from "../google-vertex-responses" +export type { Settings } from "../google-vertex-responses" diff --git a/packages/ai/src/providers/index.ts b/packages/ai/src/providers/index.ts index 023fb03669..9838e916be 100644 --- a/packages/ai/src/providers/index.ts +++ b/packages/ai/src/providers/index.ts @@ -7,7 +7,9 @@ export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" export * as GitHubCopilot from "./github-copilot" export * as Google from "./google" export * as GoogleVertex from "./google-vertex" -export * as GoogleVertexAnthropic from "./google-vertex-anthropic" +export * as GoogleVertexChat from "./google-vertex-chat" +export * as GoogleVertexMessages from "./google-vertex-messages" +export * as GoogleVertexResponses from "./google-vertex-responses" export * as OpenAI from "./openai" export * as OpenAICompatible from "./openai-compatible" export * as OpenAICompatibleResponses from "./openai-compatible-responses" diff --git a/packages/ai/test/auth-options.types.ts b/packages/ai/test/auth-options.types.ts index ff376cd5a2..4f3d1da039 100644 --- a/packages/ai/test/auth-options.types.ts +++ b/packages/ai/test/auth-options.types.ts @@ -11,7 +11,9 @@ import * as Cloudflare from "../src/providers/cloudflare" import * as GitHubCopilot from "../src/providers/github-copilot" import * as Google from "../src/providers/google" import * as GoogleVertex from "../src/providers/google-vertex" -import * as GoogleVertexAnthropic from "../src/providers/google-vertex-anthropic" +import * as GoogleVertexChat from "../src/providers/google-vertex-chat" +import * as GoogleVertexMessages from "../src/providers/google-vertex-messages" +import * as GoogleVertexResponses from "../src/providers/google-vertex-responses" import * as OpenAI from "../src/providers/openai" import * as OpenAICompatible from "../src/providers/openai-compatible" import * as OpenRouter from "../src/providers/openrouter" @@ -171,20 +173,56 @@ GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", proj // @ts-expect-error Vertex Gemini package settings accept only one auth source. GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" }) -GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6") -// @ts-expect-error Vertex Anthropic package settings do not accept API keys. -GoogleVertexAnthropic.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" }) -GoogleVertexAnthropic.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model( - "claude-sonnet-4-6", +GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas") +GoogleVertexChat.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model( + "deepseek-ai/deepseek-v3.2-maas", ) -GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model( - "claude-sonnet-4-6", - // @ts-expect-error Vertex Anthropic model selectors only accept model ids. +// @ts-expect-error Vertex Chat package settings do not accept API keys. +GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { apiKey: "vertex-key", project: "project" }) +GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model( + "deepseek-ai/deepseek-v3.2-maas", + // @ts-expect-error Vertex Chat model selectors only accept model ids. {}, ) -GoogleVertexAnthropic.configure({ +GoogleVertexChat.configure({ accessToken: "vertex-token", - // @ts-expect-error Vertex Anthropic config accepts only one auth source. + // @ts-expect-error Vertex Chat config accepts only one auth source. + auth: RuntimeAuth.bearer("vertex-token"), + project: "project", +}) + +GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning") +GoogleVertexResponses.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model( + "xai/grok-4.20-reasoning", +) +// @ts-expect-error Vertex Responses package settings do not accept API keys. +GoogleVertexResponses.model("xai/grok-4.20-reasoning", { apiKey: "vertex-key", project: "project" }) +GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model( + "xai/grok-4.20-reasoning", + // @ts-expect-error Vertex Responses model selectors only accept model ids. + {}, +) +GoogleVertexResponses.configure({ + accessToken: "vertex-token", + // @ts-expect-error Vertex Responses config accepts only one auth source. + auth: RuntimeAuth.bearer("vertex-token"), + project: "project", +}) + +GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6") +// @ts-expect-error Vertex Messages package settings do not accept API keys. +GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" }) +GoogleVertexMessages.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model( + "claude-sonnet-4-6", +) +GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model( + "claude-sonnet-4-6", + // @ts-expect-error Vertex Messages model selectors only accept model ids. + {}, +) +GoogleVertexMessages.configure({ + accessToken: "vertex-token", + // @ts-expect-error Vertex Messages config accepts only one auth source. auth: RuntimeAuth.bearer("vertex-token"), project: "project", }) diff --git a/packages/ai/test/provider-package.test.ts b/packages/ai/test/provider-package.test.ts index 2ddcb5e0d1..46d4dbb503 100644 --- a/packages/ai/test/provider-package.test.ts +++ b/packages/ai/test/provider-package.test.ts @@ -17,12 +17,16 @@ describe("provider package entrypoints", () => { import("@opencode-ai/ai/providers/azure/chat"), import("@opencode-ai/ai/providers/google"), import("@opencode-ai/ai/providers/google-vertex"), - import("@opencode-ai/ai/providers/google-vertex/anthropic"), + import("@opencode-ai/ai/providers/google-vertex/gemini"), + import("@opencode-ai/ai/providers/google-vertex/chat"), + import("@opencode-ai/ai/providers/google-vertex/responses"), + import("@opencode-ai/ai/providers/google-vertex/messages"), ]) for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) expect(modules[8].model).toBe(modules[9].model) + expect(modules[12].model).toBe(modules[13].model) }) test("maps package settings onto the executable model", () => { @@ -179,20 +183,35 @@ describe("provider package entrypoints", () => { test("selects Vertex entrypoints with the same model contract", async () => { const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex") - const GoogleVertexAnthropic = await import("@opencode-ai/ai/providers/google-vertex/anthropic") + const GoogleVertexGemini = await import("@opencode-ai/ai/providers/google-vertex/gemini") + const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat") + const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses") + const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages") const gemini = GoogleVertex.model("gemini-3.5-flash", { apiKey: "fixture", headers: { "x-application": "opencode" }, body: { safetySettings: [] }, limits: { context: 1_000_000, output: 65_536 }, }) - const anthropic = GoogleVertexAnthropic.model("claude-sonnet-4-6", { + const messages = GoogleVertexMessages.model("claude-sonnet-4-6", { + accessToken: "fixture", + location: "global", + project: "vertex-project", + }) + const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { + accessToken: "fixture", + location: "global", + project: "vertex-project", + }) + const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", { accessToken: "fixture", location: "global", project: "vertex-project", }) + expect(GoogleVertexGemini.model).toBe(GoogleVertex.model) expect(gemini.route.id).toBe("google-vertex-gemini") + expect(gemini.route.protocol).toBe("gemini") expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google") expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" }) expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] }) @@ -204,15 +223,31 @@ describe("provider package entrypoints", () => { project: "vertex-project", }).route.endpoint.baseURL, ).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google") - expect(anthropic.route.id).toBe("google-vertex-anthropic") - expect(anthropic.route.endpoint.baseURL).toBe( + expect(messages.route.id).toBe("google-vertex-messages") + expect(messages.route.protocol).toBe("anthropic-messages") + expect(messages.route.endpoint.baseURL).toBe( "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models", ) + expect(chat.route.id).toBe("google-vertex-chat") + expect(chat.route.protocol).toBe("openai-chat") + expect(chat.route.endpoint).toMatchObject({ + baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi", + path: "/chat/completions", + }) + expect(responses.route.id).toBe("google-vertex-responses") + expect(responses.route.protocol).toBe("openai-responses") + expect(responses.route.endpoint).toMatchObject({ + baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi", + path: "/responses", + }) + expect(responses.route.defaults.providerOptions).toEqual({ openai: { store: false } }) }) test("rejects conflicting Vertex auth settings at runtime", async () => { const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex") - const GoogleVertexAnthropic = await import("@opencode-ai/ai/providers/google-vertex/anthropic") + const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat") + const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages") + const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses") const Providers = await import("@opencode-ai/ai/providers") expect(() => Reflect.apply(GoogleVertex.model, undefined, [ @@ -225,15 +260,37 @@ describe("provider package entrypoints", () => { ]) expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth") expect(() => - Reflect.apply(GoogleVertexAnthropic.model, undefined, [ + Reflect.apply(GoogleVertexMessages.model, undefined, [ "claude-sonnet-4-6", { apiKey: "fixture", project: "vertex-project" }, ]), - ).toThrow("Google Vertex Anthropic does not support API keys") + ).toThrow("Google Vertex Messages does not support API keys") expect(() => - Reflect.apply(Providers.GoogleVertexAnthropic.configure, undefined, [ + Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [ { apiKey: "fixture", project: "vertex-project" }, ]), - ).toThrow("Google Vertex Anthropic does not support API keys") + ).toThrow("Google Vertex Messages does not support API keys") + expect(() => + Reflect.apply(GoogleVertexChat.model, undefined, [ + "deepseek-ai/deepseek-v3.2-maas", + { apiKey: "fixture", project: "vertex-project" }, + ]), + ).toThrow("Google Vertex Chat does not support API keys") + expect(() => + Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [ + { apiKey: "fixture", project: "vertex-project" }, + ]), + ).toThrow("Google Vertex Chat does not support API keys") + expect(() => + Reflect.apply(GoogleVertexResponses.model, undefined, [ + "xai/grok-4.20-reasoning", + { apiKey: "fixture", project: "vertex-project" }, + ]), + ).toThrow("Google Vertex Responses does not support API keys") + expect(() => + Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [ + { apiKey: "fixture", project: "vertex-project" }, + ]), + ).toThrow("Google Vertex Responses does not support API keys") }) }) diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts index aa90d67aee..395d983ffe 100644 --- a/packages/ai/test/provider/google-vertex.test.ts +++ b/packages/ai/test/provider/google-vertex.test.ts @@ -2,10 +2,11 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { HttpClientRequest } from "effect/unstable/http" import { LLM } from "../../src" -import { GoogleVertex, GoogleVertexAnthropic } from "../../src/providers" +import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers" import { LLMClient } from "../../src/route" import { it } from "../lib/effect" import { dynamicResponse } from "../lib/http" +import { deltaChunk, finishChunk } from "../lib/openai-chunks" import { sseEvents } from "../lib/sse" describe("Google Vertex providers", () => { @@ -56,7 +57,7 @@ describe("Google Vertex providers", () => { Effect.gen(function* () { const response = yield* LLMClient.generate( LLM.request({ - model: GoogleVertexAnthropic.configure({ + model: GoogleVertexMessages.configure({ accessToken: "vertex-token", location: "eu", project: "vertex-project", @@ -99,11 +100,91 @@ describe("Google Vertex providers", () => { }), ) - it.effect("protects the Vertex Anthropic API version from body overlays", () => + it.effect("sends MaaS requests through Vertex Chat Completions", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: GoogleVertexChat.configure({ + accessToken: "vertex-token", + location: "global", + project: "vertex-project", + }).model("deepseek-ai/deepseek-v3.2-maas"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.url).toBe( + "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/chat/completions", + ) + expect(request.headers.get("authorization")).toBe("Bearer vertex-token") + expect(yield* Effect.promise(() => request.json())).toMatchObject({ + model: "deepseek-ai/deepseek-v3.2-maas", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + stream_options: { include_usage: true }, + }) + return input.respond(sseEvents(deltaChunk({ content: "Hello." }), finishChunk("stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + + expect(response.text).toBe("Hello.") + }), + ) + + it.effect("sends Grok requests through Vertex Responses", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: GoogleVertexResponses.configure({ + accessToken: "vertex-token", + location: "global", + project: "vertex-project", + }).model("xai/grok-4.20-reasoning"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.url).toBe( + "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/responses", + ) + expect(request.headers.get("authorization")).toBe("Bearer vertex-token") + expect(yield* Effect.promise(() => request.json())).toMatchObject({ + model: "xai/grok-4.20-reasoning", + input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }], + store: false, + stream: true, + }) + return input.respond( + sseEvents( + { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello." }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello.") + }), + ) + + it.effect("protects the Vertex Messages API version from body overlays", () => Effect.gen(function* () { const error = yield* LLMClient.prepare( LLM.request({ - model: GoogleVertexAnthropic.configure({ + model: GoogleVertexMessages.configure({ accessToken: "vertex-token", http: { body: { anthropic_version: "wrong" } }, project: "vertex-project", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a80c5ba108..89b109c961 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -66,6 +66,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO }), ], }), + Spec.make("auth", { + description: "Manage authentication", + commands: [ + Spec.make("connect", { + description: "Connect to a wellknown authentication provider", + params: { + url: Argument.string("url").pipe(Argument.withDescription("Wellknown provider URL")), + }, + }), + ], + }), Spec.make("mcp", { description: "Manage MCP (Model Context Protocol) servers", commands: [ @@ -178,10 +189,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.atMost(100), ), title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional), - thinking: Flag.boolean("thinking").pipe( - Flag.withDescription("Show thinking blocks"), - Flag.withDefault(false), - ), + thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)), auto: Flag.boolean("auto").pipe( Flag.withDescription("Auto-approve permissions that are not explicitly denied"), Flag.withDefault(false), diff --git a/packages/cli/src/commands/handlers/auth/connect.ts b/packages/cli/src/commands/handlers/auth/connect.ts new file mode 100644 index 0000000000..f135145568 --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/connect.ts @@ -0,0 +1,52 @@ +import { EOL } from "node:os" +import { Effect } from "effect" +import { Service } from "@opencode-ai/client/effect/service" +import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { ServiceConfig } from "../../../services/service-config" + +const location = { directory: process.cwd() } + +export default Runtime.handler( + Commands.commands.auth.commands.connect, + Effect.fn("cli.auth.connect")(function* (input) { + process.stdout.write("Connecting..." + EOL + EOL) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + yield* request(() => client.integration.wellknown.add({ url: input.url, location })) + const integrationID = input.url.replace(/\/+$/, "") + const started = yield* request(() => + client.integration.command.connect({ integrationID, methodID: "login", location }), + ) + yield* Effect.addFinalizer(() => + request(() => + client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }), + ).pipe(Effect.ignore), + ) + + const status = yield* wait(client, integrationID, started.data.attemptID) + if (status.status === "failed") return yield* Effect.fail(new Error(status.message)) + if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired")) + process.stdout.write("Connected" + EOL) + }), +) + +const wait = ( + client: OpenCodeClient, + integrationID: string, + attemptID: string, + shown = false, +): Effect.Effect, unknown> => + Effect.gen(function* () { + const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location })) + if (response.data.status !== "pending") return response.data + const output = response.data.message?.trim() + if (!shown && output) process.stdout.write(output + EOL + EOL) + yield* Effect.sleep(500) + return yield* wait(client, integrationID, attemptID, shown || !!output) + }) + +function request(task: () => Promise) { + return Effect.tryPromise({ try: task, catch: (cause) => cause }) +} diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts index 99af278a35..cb2eee5193 100644 --- a/packages/cli/src/commands/handlers/console/login.ts +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -46,7 +46,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo yield* request(() => timeline.pending("Starting authorization...")) const started = yield* request((signal) => - client.integration.connect.oauth( + client.integration.oauth.connect( { integrationID, methodID: method.id, @@ -59,8 +59,8 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo const attempt = started.data yield* Effect.addFinalizer(() => request(() => - client.integration.attempt.cancel( - { attemptID: attempt.attemptID, location }, + client.integration.oauth.cancel( + { integrationID, attemptID: attempt.attemptID, location }, { signal: AbortSignal.timeout(5_000) }, ), ).pipe(Effect.ignore), @@ -75,7 +75,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo }).pipe(Effect.ignore) yield* request(() => timeline.pending("Waiting for authorization...")) - const status = yield* waitForConsoleLogin(client, attempt.attemptID) + const status = yield* waitForConsoleLogin(client, integrationID, attempt.attemptID) if (status.status === "failed") yield* Effect.fail(new Error(status.message)) if (status.status === "expired") yield* Effect.fail(new Error("Device code expired")) @@ -85,11 +85,12 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* ( client: OpenCodeClient, + integrationID: string, attemptID: string, ) { while (true) { const response = yield* request((signal) => - client.integration.attempt.status({ attemptID, location }, { signal }), + client.integration.oauth.status({ integrationID, attemptID, location }, { signal }), ) if (response.data.status !== "pending") return response.data yield* Effect.sleep(500) diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 32e728b435..2ca5259953 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -32,7 +32,7 @@ export default Runtime.handler( return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) const started = yield* Effect.promise(() => - client.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }), + client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, inputs: {}, location }), ) const attempt = started.data if (attempt.mode === "code") @@ -40,7 +40,7 @@ export default Runtime.handler( process.stdout.write(attempt.instructions + EOL + attempt.url + EOL) - const result = yield* poll(client, attempt.attemptID) + const result = yield* poll(client, integration.id, attempt.attemptID) if (result.status === "complete") { process.stdout.write(`Authenticated with ${input.name}` + EOL) return @@ -52,15 +52,16 @@ export default Runtime.handler( const poll = ( client: OpenCodeClient, + integrationID: string, attemptID: string, ): Effect.Effect> => Effect.gen(function* () { - const status = yield* Effect.promise(() => client.integration.attempt.status({ attemptID, location })).pipe( - Effect.map((result) => result.data), - ) + const status = yield* Effect.promise(() => + client.integration.oauth.status({ integrationID, attemptID, location }), + ).pipe(Effect.map((result) => result.data)) if (status.status === "pending") { yield* Effect.sleep("1 second") - return yield* poll(client, attemptID) + return yield* poll(client, integrationID, attemptID) } return status }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fd59472a86..9346dbe35f 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,6 +17,9 @@ import { Npm } from "@opencode-ai/core/npm" const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), api: () => import("./commands/handlers/api"), + auth: { + connect: () => import("./commands/handlers/auth/connect"), + }, debug: { agents: () => import("./commands/handlers/debug/agents"), }, diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 1da8209607..27da772894 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -407,75 +407,124 @@ export type Endpoint10_1Input = { export type Endpoint10_1Output = EffectValue> export type IntegrationGetOperation = (input: Endpoint10_1Input) => Effect.Effect -type Endpoint10_2Request = Parameters[0] +type Endpoint10_2Request = Parameters[0] export type Endpoint10_2Input = { - readonly integrationID: Endpoint10_2Request["params"]["integrationID"] readonly location?: Endpoint10_2Request["query"]["location"] - readonly key: Endpoint10_2Request["payload"]["key"] - readonly label?: Endpoint10_2Request["payload"]["label"] + readonly url: Endpoint10_2Request["payload"]["url"] } -export type Endpoint10_2Output = EffectValue> -export type IntegrationConnectKeyOperation = ( +export type Endpoint10_2Output = EffectValue> +export type IntegrationWellknownAddOperation = ( input: Endpoint10_2Input, ) => Effect.Effect -type Endpoint10_3Request = Parameters[0] +type Endpoint10_3Request = Parameters[0] export type Endpoint10_3Input = { readonly integrationID: Endpoint10_3Request["params"]["integrationID"] readonly location?: Endpoint10_3Request["query"]["location"] - readonly methodID: Endpoint10_3Request["payload"]["methodID"] - readonly inputs: Endpoint10_3Request["payload"]["inputs"] + readonly key: Endpoint10_3Request["payload"]["key"] readonly label?: Endpoint10_3Request["payload"]["label"] } -export type Endpoint10_3Output = EffectValue> -export type IntegrationConnectOauthOperation = ( +export type Endpoint10_3Output = EffectValue> +export type IntegrationConnectKeyOperation = ( input: Endpoint10_3Input, ) => Effect.Effect -type Endpoint10_4Request = Parameters[0] +type Endpoint10_4Request = Parameters[0] export type Endpoint10_4Input = { - readonly attemptID: Endpoint10_4Request["params"]["attemptID"] + readonly integrationID: Endpoint10_4Request["params"]["integrationID"] readonly location?: Endpoint10_4Request["query"]["location"] + readonly methodID: Endpoint10_4Request["payload"]["methodID"] + readonly inputs: Endpoint10_4Request["payload"]["inputs"] + readonly label?: Endpoint10_4Request["payload"]["label"] } -export type Endpoint10_4Output = EffectValue> -export type IntegrationAttemptStatusOperation = ( +export type Endpoint10_4Output = EffectValue> +export type IntegrationOauthConnectOperation = ( input: Endpoint10_4Input, ) => Effect.Effect -type Endpoint10_5Request = Parameters[0] +type Endpoint10_5Request = Parameters[0] export type Endpoint10_5Input = { + readonly integrationID: Endpoint10_5Request["params"]["integrationID"] readonly attemptID: Endpoint10_5Request["params"]["attemptID"] readonly location?: Endpoint10_5Request["query"]["location"] - readonly code?: Endpoint10_5Request["payload"]["code"] } -export type Endpoint10_5Output = EffectValue< - ReturnType -> -export type IntegrationAttemptCompleteOperation = ( +export type Endpoint10_5Output = EffectValue> +export type IntegrationOauthStatusOperation = ( input: Endpoint10_5Input, ) => Effect.Effect -type Endpoint10_6Request = Parameters[0] +type Endpoint10_6Request = Parameters[0] export type Endpoint10_6Input = { + readonly integrationID: Endpoint10_6Request["params"]["integrationID"] readonly attemptID: Endpoint10_6Request["params"]["attemptID"] readonly location?: Endpoint10_6Request["query"]["location"] + readonly code?: Endpoint10_6Request["payload"]["code"] } -export type Endpoint10_6Output = EffectValue> -export type IntegrationAttemptCancelOperation = ( +export type Endpoint10_6Output = EffectValue> +export type IntegrationOauthCompleteOperation = ( input: Endpoint10_6Input, ) => Effect.Effect +type Endpoint10_7Request = Parameters[0] +export type Endpoint10_7Input = { + readonly integrationID: Endpoint10_7Request["params"]["integrationID"] + readonly attemptID: Endpoint10_7Request["params"]["attemptID"] + readonly location?: Endpoint10_7Request["query"]["location"] +} +export type Endpoint10_7Output = EffectValue> +export type IntegrationOauthCancelOperation = ( + input: Endpoint10_7Input, +) => Effect.Effect + +type Endpoint10_8Request = Parameters[0] +export type Endpoint10_8Input = { + readonly integrationID: Endpoint10_8Request["params"]["integrationID"] + readonly location?: Endpoint10_8Request["query"]["location"] + readonly methodID: Endpoint10_8Request["payload"]["methodID"] + readonly label?: Endpoint10_8Request["payload"]["label"] +} +export type Endpoint10_8Output = EffectValue> +export type IntegrationCommandConnectOperation = ( + input: Endpoint10_8Input, +) => Effect.Effect + +type Endpoint10_9Request = Parameters[0] +export type Endpoint10_9Input = { + readonly integrationID: Endpoint10_9Request["params"]["integrationID"] + readonly attemptID: Endpoint10_9Request["params"]["attemptID"] + readonly location?: Endpoint10_9Request["query"]["location"] +} +export type Endpoint10_9Output = EffectValue> +export type IntegrationCommandStatusOperation = ( + input: Endpoint10_9Input, +) => Effect.Effect + +type Endpoint10_10Request = Parameters[0] +export type Endpoint10_10Input = { + readonly integrationID: Endpoint10_10Request["params"]["integrationID"] + readonly attemptID: Endpoint10_10Request["params"]["attemptID"] + readonly location?: Endpoint10_10Request["query"]["location"] +} +export type Endpoint10_10Output = EffectValue> +export type IntegrationCommandCancelOperation = ( + input: Endpoint10_10Input, +) => Effect.Effect + export interface IntegrationApi { readonly list: IntegrationListOperation readonly get: IntegrationGetOperation - readonly connect: { - readonly key: IntegrationConnectKeyOperation - readonly oauth: IntegrationConnectOauthOperation + readonly wellknown: { readonly add: IntegrationWellknownAddOperation } + readonly connect: { readonly key: IntegrationConnectKeyOperation } + readonly oauth: { + readonly connect: IntegrationOauthConnectOperation + readonly status: IntegrationOauthStatusOperation + readonly complete: IntegrationOauthCompleteOperation + readonly cancel: IntegrationOauthCancelOperation } - readonly attempt: { - readonly status: IntegrationAttemptStatusOperation - readonly complete: IntegrationAttemptCompleteOperation - readonly cancel: IntegrationAttemptCancelOperation + readonly command: { + readonly connect: IntegrationCommandConnectOperation + readonly status: IntegrationCommandStatusOperation + readonly cancel: IntegrationCommandCancelOperation } } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index ed2918eb9d..c7a2f86035 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -504,75 +504,133 @@ const Endpoint10_1 = (raw: RawClient["server.integration"]) => (input: Endpoint1 query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_2Request = Parameters[0] +type Endpoint10_2Request = Parameters[0] type Endpoint10_2Input = { - readonly integrationID: Endpoint10_2Request["params"]["integrationID"] readonly location?: Endpoint10_2Request["query"]["location"] - readonly key: Endpoint10_2Request["payload"]["key"] - readonly label?: Endpoint10_2Request["payload"]["label"] + readonly url: Endpoint10_2Request["payload"]["url"] } const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) => + raw["integration.wellknown.add"]({ query: { location: input["location"] }, payload: { url: input["url"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint10_3Request = Parameters[0] +type Endpoint10_3Input = { + readonly integrationID: Endpoint10_3Request["params"]["integrationID"] + readonly location?: Endpoint10_3Request["query"]["location"] + readonly key: Endpoint10_3Request["payload"]["key"] + readonly label?: Endpoint10_3Request["payload"]["label"] +} +const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint10_3Input) => raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_3Request = Parameters[0] -type Endpoint10_3Input = { - readonly integrationID: Endpoint10_3Request["params"]["integrationID"] - readonly location?: Endpoint10_3Request["query"]["location"] - readonly methodID: Endpoint10_3Request["payload"]["methodID"] - readonly inputs: Endpoint10_3Request["payload"]["inputs"] - readonly label?: Endpoint10_3Request["payload"]["label"] +type Endpoint10_4Request = Parameters[0] +type Endpoint10_4Input = { + readonly integrationID: Endpoint10_4Request["params"]["integrationID"] + readonly location?: Endpoint10_4Request["query"]["location"] + readonly methodID: Endpoint10_4Request["payload"]["methodID"] + readonly inputs: Endpoint10_4Request["payload"]["inputs"] + readonly label?: Endpoint10_4Request["payload"]["label"] } -const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint10_3Input) => - raw["integration.connect.oauth"]({ +const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint10_4Input) => + raw["integration.oauth.connect"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_4Request = Parameters[0] -type Endpoint10_4Input = { - readonly attemptID: Endpoint10_4Request["params"]["attemptID"] - readonly location?: Endpoint10_4Request["query"]["location"] +type Endpoint10_5Request = Parameters[0] +type Endpoint10_5Input = { + readonly integrationID: Endpoint10_5Request["params"]["integrationID"] + readonly attemptID: Endpoint10_5Request["params"]["attemptID"] + readonly location?: Endpoint10_5Request["query"]["location"] } -const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint10_4Input) => - raw["integration.attempt.status"]({ - params: { attemptID: input["attemptID"] }, +const Endpoint10_5 = (raw: RawClient["server.integration"]) => (input: Endpoint10_5Input) => + raw["integration.oauth.status"]({ + params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_5Request = Parameters[0] -type Endpoint10_5Input = { - readonly attemptID: Endpoint10_5Request["params"]["attemptID"] - readonly location?: Endpoint10_5Request["query"]["location"] - readonly code?: Endpoint10_5Request["payload"]["code"] +type Endpoint10_6Request = Parameters[0] +type Endpoint10_6Input = { + readonly integrationID: Endpoint10_6Request["params"]["integrationID"] + readonly attemptID: Endpoint10_6Request["params"]["attemptID"] + readonly location?: Endpoint10_6Request["query"]["location"] + readonly code?: Endpoint10_6Request["payload"]["code"] } -const Endpoint10_5 = (raw: RawClient["server.integration"]) => (input: Endpoint10_5Input) => - raw["integration.attempt.complete"]({ - params: { attemptID: input["attemptID"] }, +const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint10_6Input) => + raw["integration.oauth.complete"]({ + params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_6Request = Parameters[0] -type Endpoint10_6Input = { - readonly attemptID: Endpoint10_6Request["params"]["attemptID"] - readonly location?: Endpoint10_6Request["query"]["location"] +type Endpoint10_7Request = Parameters[0] +type Endpoint10_7Input = { + readonly integrationID: Endpoint10_7Request["params"]["integrationID"] + readonly attemptID: Endpoint10_7Request["params"]["attemptID"] + readonly location?: Endpoint10_7Request["query"]["location"] } -const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint10_6Input) => - raw["integration.attempt.cancel"]({ - params: { attemptID: input["attemptID"] }, +const Endpoint10_7 = (raw: RawClient["server.integration"]) => (input: Endpoint10_7Input) => + raw["integration.oauth.cancel"]({ + params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint10_8Request = Parameters[0] +type Endpoint10_8Input = { + readonly integrationID: Endpoint10_8Request["params"]["integrationID"] + readonly location?: Endpoint10_8Request["query"]["location"] + readonly methodID: Endpoint10_8Request["payload"]["methodID"] + readonly label?: Endpoint10_8Request["payload"]["label"] +} +const Endpoint10_8 = (raw: RawClient["server.integration"]) => (input: Endpoint10_8Input) => + raw["integration.command.connect"]({ + params: { integrationID: input["integrationID"] }, + query: { location: input["location"] }, + payload: { methodID: input["methodID"], label: input["label"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint10_9Request = Parameters[0] +type Endpoint10_9Input = { + readonly integrationID: Endpoint10_9Request["params"]["integrationID"] + readonly attemptID: Endpoint10_9Request["params"]["attemptID"] + readonly location?: Endpoint10_9Request["query"]["location"] +} +const Endpoint10_9 = (raw: RawClient["server.integration"]) => (input: Endpoint10_9Input) => + raw["integration.command.status"]({ + params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint10_10Request = Parameters[0] +type Endpoint10_10Input = { + readonly integrationID: Endpoint10_10Request["params"]["integrationID"] + readonly attemptID: Endpoint10_10Request["params"]["attemptID"] + readonly location?: Endpoint10_10Request["query"]["location"] +} +const Endpoint10_10 = (raw: RawClient["server.integration"]) => (input: Endpoint10_10Input) => + raw["integration.command.cancel"]({ + params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) const adaptGroup10 = (raw: RawClient["server.integration"]) => ({ list: Endpoint10_0(raw), get: Endpoint10_1(raw), - connect: { key: Endpoint10_2(raw), oauth: Endpoint10_3(raw) }, - attempt: { status: Endpoint10_4(raw), complete: Endpoint10_5(raw), cancel: Endpoint10_6(raw) }, + wellknown: { add: Endpoint10_2(raw) }, + connect: { key: Endpoint10_3(raw) }, + oauth: { + connect: Endpoint10_4(raw), + status: Endpoint10_5(raw), + complete: Endpoint10_6(raw), + cancel: Endpoint10_7(raw), + }, + command: { connect: Endpoint10_8(raw), status: Endpoint10_9(raw), cancel: Endpoint10_10(raw) }, }) type Endpoint11_0Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index b6f4d44b67..3783a5e5c0 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -82,16 +82,24 @@ import type { IntegrationListOutput, IntegrationGetInput, IntegrationGetOutput, + IntegrationWellknownAddInput, + IntegrationWellknownAddOutput, IntegrationConnectKeyInput, IntegrationConnectKeyOutput, - IntegrationConnectOauthInput, - IntegrationConnectOauthOutput, - IntegrationAttemptStatusInput, - IntegrationAttemptStatusOutput, - IntegrationAttemptCompleteInput, - IntegrationAttemptCompleteOutput, - IntegrationAttemptCancelInput, - IntegrationAttemptCancelOutput, + IntegrationOauthConnectInput, + IntegrationOauthConnectOutput, + IntegrationOauthStatusInput, + IntegrationOauthStatusOutput, + IntegrationOauthCompleteInput, + IntegrationOauthCompleteOutput, + IntegrationOauthCancelInput, + IntegrationOauthCancelOutput, + IntegrationCommandConnectInput, + IntegrationCommandConnectOutput, + IntegrationCommandStatusInput, + IntegrationCommandStatusOutput, + IntegrationCommandCancelInput, + IntegrationCommandCancelOutput, McpListInput, McpListOutput, McpResourceCatalogInput, @@ -887,6 +895,21 @@ export function make(options: ClientOptions) { }, requestOptions, ), + wellknown: { + add: (input: IntegrationWellknownAddInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/experimental/integration/wellknown`, + query: { location: input["location"] }, + body: { url: input["url"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + }, connect: { key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => request( @@ -901,8 +924,10 @@ export function make(options: ClientOptions) { }, requestOptions, ), - oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => - request( + }, + oauth: { + connect: (input: IntegrationOauthConnectInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, @@ -914,13 +939,11 @@ export function make(options: ClientOptions) { }, requestOptions, ), - }, - attempt: { - status: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => - request( + status: (input: IntegrationOauthStatusInput, requestOptions?: RequestOptions) => + request( { method: "GET", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`, query: { location: input["location"] }, successStatus: 200, declaredStatuses: [401, 400], @@ -928,11 +951,11 @@ export function make(options: ClientOptions) { }, requestOptions, ), - complete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => - request( + complete: (input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions) => + request( { method: "POST", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}/complete`, query: { location: input["location"] }, body: { code: input["code"] }, successStatus: 204, @@ -941,11 +964,50 @@ export function make(options: ClientOptions) { }, requestOptions, ), - cancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => - request( + cancel: (input: IntegrationOauthCancelInput, requestOptions?: RequestOptions) => + request( { method: "DELETE", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, + command: { + connect: (input: IntegrationCommandConnectInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command`, + query: { location: input["location"] }, + body: { methodID: input["methodID"], label: input["label"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + status: (input: IntegrationCommandStatusInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + cancel: (input: IntegrationCommandCancelInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`, query: { location: input["location"] }, successStatus: 204, declaredStatuses: [401, 400], diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e2ed9a9f9c..25241fb908 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -187,6 +187,8 @@ export type ProviderV2Info = { export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string } +export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array } + export type IntegrationKeyMethod = { type: "key"; label?: string } export type IntegrationEnvMethod = { type: "env"; names: Array } @@ -214,6 +216,31 @@ export type IntegrationAttemptStatus = time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } } +export type IntegrationCommandAttempt = { + attemptID: string + time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } +} + +export type IntegrationCommandAttemptStatus = + | { + status: "pending" + message?: string + time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } + } + | { + status: "complete" + time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } + } + | { + status: "failed" + message: string + time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } + } + | { + status: "expired" + time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" } + } + export type McpStatusConnected = { status: "connected" } export type McpStatusPending = { status: "pending" } @@ -1992,7 +2019,11 @@ export type SessionMessageAssistantTool = { time: { created: number; ran?: number; completed?: number } } -export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod +export type IntegrationMethod = + | IntegrationOAuthMethod + | IntegrationCommandMethod + | IntegrationKeyMethod + | IntegrationEnvMethod export type FormFields = [FormField, ...Array] @@ -3273,6 +3304,15 @@ export type IntegrationGetOutput = { data: IntegrationInfo | null } +export type IntegrationWellknownAddInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly url: { readonly url: string }["url"] +} + +export type IntegrationWellknownAddOutput = void + export type IntegrationConnectKeyInput = { readonly integrationID: { readonly integrationID: string }["integrationID"] readonly location?: { @@ -3284,7 +3324,7 @@ export type IntegrationConnectKeyInput = { export type IntegrationConnectKeyOutput = void -export type IntegrationConnectOauthInput = { +export type IntegrationOauthConnectInput = { readonly integrationID: { readonly integrationID: string }["integrationID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -3306,7 +3346,7 @@ export type IntegrationConnectOauthInput = { }["label"] } -export type IntegrationConnectOauthOutput = { +export type IntegrationOauthConnectOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } data: { attemptID: string @@ -3317,36 +3357,76 @@ export type IntegrationConnectOauthOutput = { } } -export type IntegrationAttemptStatusInput = { - readonly attemptID: { readonly attemptID: string }["attemptID"] +export type IntegrationOauthStatusInput = { + readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] + readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type IntegrationAttemptStatusOutput = { +export type IntegrationOauthStatusOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } data: IntegrationAttemptStatus } -export type IntegrationAttemptCompleteInput = { - readonly attemptID: { readonly attemptID: string }["attemptID"] +export type IntegrationOauthCompleteInput = { + readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] + readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] readonly code?: { readonly code?: string | undefined }["code"] } -export type IntegrationAttemptCompleteOutput = void +export type IntegrationOauthCompleteOutput = void -export type IntegrationAttemptCancelInput = { - readonly attemptID: { readonly attemptID: string }["attemptID"] +export type IntegrationOauthCancelInput = { + readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] + readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type IntegrationAttemptCancelOutput = void +export type IntegrationOauthCancelOutput = void + +export type IntegrationCommandConnectInput = { + readonly integrationID: { readonly integrationID: string }["integrationID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"] + readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"] +} + +export type IntegrationCommandConnectOutput = { + location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + data: IntegrationCommandAttempt +} + +export type IntegrationCommandStatusInput = { + readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] + readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationCommandStatusOutput = { + location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } + data: IntegrationCommandAttemptStatus +} + +export type IntegrationCommandCancelInput = { + readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] + readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type IntegrationCommandCancelOutput = void export type McpListInput = { readonly location?: { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 502bcae678..fd836272b0 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -36,9 +36,11 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.debug.location)).toEqual(["list", "evict"]) expect(Object.keys(client.message)).toEqual(["list"]) - expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"]) - expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"]) - expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"]) + expect(Object.keys(client.integration)).toEqual(["list", "get", "wellknown", "connect", "oauth", "command"]) + expect(Object.keys(client.integration.wellknown)).toEqual(["add"]) + expect(Object.keys(client.integration.connect)).toEqual(["key"]) + expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"]) + expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) @@ -61,6 +63,28 @@ test("server.get uses the public HTTP contract", async () => { expect(request?.url).toBe("http://localhost:3000/api/server") }) +test("experimental wellknown integration add uses the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + request = input instanceof Request ? input : new Request(input, init) + return new Response(null, { status: 204 }) + }, + }) + + await client.integration.wellknown.add({ + url: "https://example.com", + location: { directory: "/tmp/project" }, + }) + + expect(request?.method).toBe("POST") + expect(request?.url).toBe( + "http://localhost:3000/api/experimental/integration/wellknown?location%5Bdirectory%5D=%2Ftmp%2Fproject", + ) + expect(await request?.json()).toEqual({ url: "https://example.com" }) +}) + test("health.stop sends exact replacement identity", async () => { let request: Request | undefined const client = OpenCode.make({ diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index a950eaa72f..70d34f2408 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -4,10 +4,9 @@ This is the checkable support matrix for CodeMode's confined JavaScript interpre standard-library surface that programs can use today, plus concrete gaps that may be implemented later. - `[x]` means the feature is implemented at the scope described here. -- `[ ]` means the feature is unavailable, incomplete, or intentionally divergent as described. -- A checked item does not promise complete ECMAScript edge-case parity. Known differences are listed next to the - supported surface or under [Known semantic gaps](#known-semantic-gaps). -- [Intentional exclusions](#intentional-exclusions) are boundaries, not backlog. +- `[ ]` means a concrete compatibility gap remains. +- Checked items do not promise complete ECMAScript edge-case parity; known differences are stated explicitly. +- Intentional boundaries are not listed as compatibility work. When behavior changes, update this file and the tests in the same change. The implementation and tests remain the ultimate source of truth. @@ -19,42 +18,45 @@ ultimate source of truth. TypeScript is transpiled first; the emitted JavaScript must still use the supported subset. - [x] Top-level `await` and `return` through the program's implicit async-function scope. - [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced. -- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`. +- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool + arguments remain subject to their schema and the outbound-handling gap listed below. - [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode. - [x] Tool calls through the host-provided `tools` tree only. - [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is shadowable by program declarations like other globals. - [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency. -- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language. ## Values and literals - [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings. - [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, and URLSearchParams. -- [x] Object literals with shorthand, computed string/number keys, and object spread. +- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and + `undefined` are no-ops, while arrays are rejected. - [x] Template literals with interpolation. - [x] Regular-expression literals. - [x] `NaN` and `Infinity` globals. -- [ ] BigInt literals and values. -- [ ] Symbols. -- [ ] Tagged template literals. -- [ ] Getters and setters in object literals. +- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries. +- [ ] Symbol primitive values and symbol-keyed properties. +- [ ] Tagged-template calls. +- [ ] Getter and setter definitions in object literals. ## Bindings and destructuring - [x] `const`, `let`, and accepted `var` declarations. - [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings. - [x] Nested patterns, defaults, elisions, and rest elements. -- [x] Assignment to identifiers, object fields, array indexes, and writable URL fields. -- [x] Function declarations are hoisted within their interpreted scope. +- [x] Assignment to identifiers, unblocked plain-object fields, non-negative integer array indexes, and writable URL + fields. +- [x] Direct function declarations are hoisted in program and block statement lists. - [x] Parameter defaults observe a temporal dead zone for later parameters. -- [ ] JavaScript-correct `var` function scope, hoisting, and redeclaration. Accepted `var` currently behaves like a - lexical declaration; prefer `let` or `const`. -- [ ] Complete `let`/`const` temporal-dead-zone and declaration-hoisting semantics. -- [ ] Computed object destructuring keys such as `const { [field]: value } = record`. -- [ ] Object destructuring from arrays, such as `const { length } = values`. -- [ ] Iterable array destructuring from Map, Set, string, or URLSearchParams values. -- [ ] Dynamic property deletion with `delete object[key]`. +- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations. +- [ ] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and + loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript + temporal dead zone. +- [ ] Hoist function declarations accepted directly in switch cases. +- [x] Computed object destructuring keys such as `const { [field]: value } = record`. +- [x] Object destructuring from arrays, such as `const { length } = values`. +- [x] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams. ## Statements and control flow @@ -69,7 +71,6 @@ ultimate source of truth. - [x] `throw` with arbitrary values. - [ ] Labeled statements, labeled `break`, and labeled `continue`. - [ ] `for await...of` and async iteration. -- [ ] `with` and `debugger` statements. ## Functions and callbacks @@ -90,15 +91,15 @@ ultimate source of truth. like JS. - [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an arrow function. -- [x] Async string replacement callbacks; replacements are evaluated sequentially. +- [ ] Stop automatically awaiting promise-returning string replacers; match JavaScript's synchronous callback-result + coercion. - [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so ignoring it matches JS arrow-function semantics exactly. -- [ ] `this`, `super`, user-defined constructor functions, or function prototype methods such as `call`, `apply`, - and `bind`. +- [ ] `this` in non-arrow CodeMode functions and callbacks. +- [ ] User-defined constructor calls. +- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions. - [ ] Classes and private fields. - [ ] Generator functions and `yield`. -- [ ] Async predicates, reducers, and comparators with automatic awaiting. Async mapping can be joined explicitly with - `Promise.all`, but a promise is not a meaningful predicate or sort result. ## Expressions and operators @@ -108,16 +109,16 @@ ultimate source of truth. - [x] Sequence expressions (the comma operator). - [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its continuation one reaction turn. -- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. +- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. - [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. - [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`. - [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`. - [x] Logical operators: `&&`, `||`, `??`, and `!`, with short-circuiting. -- [x] Unary `+`, unary `-`, `typeof`, `instanceof`, and own-property-only `in`. +- [x] Unary `+`, unary `-`, `void`, `typeof`, `instanceof`, and own-property-only `in`. - [x] Prefix and postfix `++` and `--`. - [x] Plain, arithmetic, bitwise, and logical assignment operators. -- [ ] Unary `void` and `delete`. -- [ ] Arbitrary constructors. +- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index + creates a hole without changing its length. ## Promises and tools @@ -152,8 +153,13 @@ ultimate source of truth. promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are accepted, including `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot cross the data boundary. -- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises). -- [ ] Async iterables, host streams, and stream consumption. +- [ ] Thenable assimilation; objects with a callable `then` field remain plain data. +- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the + last definition supplied for a canonical path wins. +- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys. +- [ ] Reject `undefined` and non-finite numbers in outbound tool arguments before render-only and OpenAPI tools run; + retain null normalization for program results and JSON serialization. +- [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search. ## Objects and properties @@ -164,19 +170,17 @@ ultimate source of truth. - [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`. - [x] `Object.keys` over arrays and tool references. - [x] Object identity is preserved by in-CodeMode Object helpers. -- [x] Blocked access to `__proto__`, `constructor`, and `prototype`. -- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first. +- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked. +- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and + cannot be created, read, or written in CodeMode; tool path segments with those names remain supported. +- [x] `Object.is` for supported data values. - [ ] `Object.groupBy`. -- [ ] Object creation, descriptors, freezing/sealing, prototype APIs, and reflection APIs. -- [ ] A final policy for legal data keys named `__proto__`, `constructor`, or `prototype` (tool path segments - already allow them; see known semantic gaps). ## Arrays -- [x] The `Array` constructor with or without `new`: `Array(a, b)` collects arguments, `Array(n)` creates a - sparse array of that length (invalid lengths throw a `RangeError`). Holes behave like JS in iteration, - spread, join, and JSON; `sort` densifies holes into trailing `undefined`, and results returned to the - host normalize holes to `null`. +- [x] The `Array` constructor with or without `new`: `Array(a, b)` collects arguments and `Array(n)` creates a sparse + array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, and JSON handle holes like + JavaScript, and host results normalize holes to `null`. - [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with `(value, index)` arguments. - [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`. @@ -190,8 +194,10 @@ ultimate source of truth. - [x] `length`, numeric indexing, index assignment, spread, and `for...of`. - [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows. - [ ] `Array.prototype.toSpliced`. -- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`. -- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS. +- [ ] Canonical array/string index parsing: a key such as `"01"` must remain an ordinary property key rather than + aliasing index `1`. +- [ ] `Array.prototype.sort` and `toSorted` must preserve trailing holes; they currently turn holes into own + `undefined` elements. ## Strings @@ -204,8 +210,8 @@ ultimate source of truth. - [x] `localeCompare`; locale and options arguments are currently ignored. - [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point. - [x] Static `String.fromCharCode` and `String.fromCodePoint`. -- [ ] Locale/options-aware `localeCompare` and locale formatting APIs. -- [ ] Exact native coercion across every string method; CodeMode often requires explicit strings/numbers. +- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently + reject instead of coercing. - [ ] Native no-argument parity for `match()` and `search()`. ## Numbers and Math @@ -221,20 +227,21 @@ ultimate source of truth. `floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`, `tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`. - [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`. -- [ ] Safe interpreter coercion for `++` and `--` rather than host `Number(...)` coercion. -- [ ] Reliable feature detection for unknown static members. +- [ ] `++` and `--` must use CodeMode numeric coercion and reject opaque runtime references; they currently call host + `Number(...)` directly. +- [ ] Unknown static members must read as `undefined` for feature detection; some currently appear callable or throw + during property access. - [ ] `Math.sumPrecise`. - [ ] Global coercing `isFinite` and `isNaN`. ## JSON and console -- [x] `JSON.parse` and `JSON.stringify`. +- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies. - [x] Numeric/string indentation for `JSON.stringify`. - [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`. - [x] Captured `console.dir` and `console.table`. - [ ] `JSON.parse` reviver callbacks. - [ ] `JSON.stringify` function/array replacers. -- [ ] Other console methods, timers, counters, groups, and host console access. ## Date @@ -250,22 +257,22 @@ ultimate source of truth. - [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`. - [x] Date values serialize to ISO strings; invalid dates serialize to `null`. - [ ] Date setters. -- [ ] `toUTCString`, locale methods, and other Date formatting methods. -- [ ] Exact native constructor coercion, local-time, and loose-equality semantics. +- [ ] `Date.prototype.toUTCString` and its `toGMTString` alias. +- [ ] Native one-argument Date coercion; unsupported boolean/object inputs currently become invalid dates instead of + being coerced. +- [ ] Native Date loose-equality and default primitive-coercion semantics. - [ ] Native `RangeError` branding for invalid `toISOString()` calls. -- [ ] Temporal and Intl date/time APIs. ## Regular expressions - [x] Literal and `RegExp(pattern, flags)` construction, with or without `new`. - [x] `test`, `exec`, and `toString`. - [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`. -- [x] Captures, named groups, match indexes, and stateful global matching. -- [x] Integration with supported String methods, including async function replacers. +- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching. +- [x] Integration with supported String methods, including function replacers. - [ ] Writable `lastIndex`. -- [ ] Exposed metadata for the `d` and `v` flags. +- [ ] `hasIndices`, match `indices`, and `unicodeSets` metadata for the `d` and `v` flags. - [ ] `RegExp.escape`. -- [ ] Protection from pathological host-regex backtracking beyond the cooperative execution timeout. ## Map and Set @@ -276,9 +283,8 @@ ultimate source of truth. - [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set. - [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration. - [x] Map and Set values serialize to `{}` at host/JSON boundaries. -- [ ] Set composition methods such as `union`, `intersection`, `difference`, and relation predicates. -- [ ] WeakMap and WeakSet. -- [ ] Native iterator objects and custom iterators. +- [ ] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`, + `isSupersetOf`, and `isDisjointFrom`. ## URL and URI helpers @@ -301,48 +307,12 @@ ultimate source of truth. an all-rejected `Promise.any`. - [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. - [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types. -- [x] Catchable interpreter failures and awaited tool failures. -- [x] Source locations on unsupported-syntax diagnostics when available. +- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited + tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program + `catch`. +- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may + shift them. - [x] Sanitized model-visible diagnostics and explicit safe `ToolError` messages. -- [ ] Distinct public categories for user throws, tool refusal, tool internal failure, invalid returned data, compile - failures, and genuine interpreter defects. -- [ ] Preservation of detailed recoverable failure categories inside `catch` and `Promise.allSettled`. - -## Known semantic gaps - -These are actionable implementation items. Check them off only when behavior and direct tests land. - -- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`. -- [x] Canonicalize dotted tool names into namespace paths so every advertised dotted path is executable, one - canonical path can be both a callable tool and a namespace, and the last definition supplied for a canonical - path wins. -- [x] Allow blocked member names (`constructor`, `prototype`, `__proto__`) as tool path segments: segments are Map - keys and inert strings, never plain-object property accesses, so every advertised path is executable. Blocked - member access on data values stays rejected. Tool names with empty segments are rejected at construction. -- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become - `null` in render-only or OpenAPI tool calls. -- [ ] Make regular-expression execution genuinely timeout-safe, or narrow the timeout guarantee explicitly. -- [ ] Complete lexical declaration and destructuring semantics listed above. -- [x] Make callback acceptance consistent across built-ins: collections, sort, string replacers, `Array.from` - mappers, and promise reactions share one acceptance rule. -- [x] Reject every unsupported callable callback argument explicitly rather than silently ignoring it - (`JSON.stringify` replacers and `JSON.parse` revivers fail loudly). The iteration-method `thisArg` is - accepted and ignored: CodeMode functions have no `this`, so ignoring it matches JS arrow semantics. -- [ ] Make async callback behavior consistent across built-ins; only string replacers settle async callback results - today. -- [ ] Resolve the built-in correctness gaps listed in the Array, String, Number, Date, and RegExp sections. -- [ ] Make tool search tokenization Unicode-aware. -- [ ] Design explicit tagged representations and size limits before adding binary values or streams. - -## Intentional exclusions - -These constraints preserve CodeMode's confinement and host-neutral scope. They are not TODO items. - -- Ambient filesystem, process, environment, credential, network, or application access. -- `fetch`, timers, crypto, or other host globals unless a future host explicitly supplies a bounded capability. -- Static imports, dynamic imports, modules, npm packages, and module loading. -- `eval`, `Function(...)`, arbitrary host execution, and prototype mutation. -- Generic permission prompts, authorization policy, persistence, replay, or exactly-once side effects. -- Arbitrary method dispatch outside the documented allowlists. -- Automatic parsing of text tool results as JSON. -- Full browser, Node.js, Bun, or ECMAScript runtime compatibility. +- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from sanitized internal tool + failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled` + reasons. diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts index f32b726149..b2ac2d22a2 100644 --- a/packages/codemode/src/interpreter/references.ts +++ b/packages/codemode/src/interpreter/references.ts @@ -34,30 +34,51 @@ export const isRuntimeReference = (value: unknown): boolean => value instanceof ErrorConstructorReference || isCodeModeValue(value) -export const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { - if (isRuntimeReference(value)) return true - if (value === null || typeof value !== "object") return false - if (seen.has(value)) return false - seen.add(value) - const contains = Array.isArray(value) - ? value.some((item) => containsRuntimeReference(item, seen)) - : Object.values(value).some((item) => containsRuntimeReference(item, seen)) - seen.delete(value) - return contains +function* childValues(value: object): Generator { + if (Array.isArray(value)) { + const length = value.length + for (let index = 0; index < length; index++) yield value[index] + return + } + yield* Object.values(value) +} + +export const containsRuntimeReference = (value: unknown): boolean => { + const pending: Array> = [[value].values()] + const seen = new Set() + while (pending.length > 0) { + const next = pending.at(-1)!.next() + if (next.done) { + pending.pop() + continue + } + const current = next.value + if (isRuntimeReference(current)) return true + if (current === null || typeof current !== "object" || seen.has(current)) continue + seen.add(current) + pending.push(childValues(current)) + } + return false } // CodeMode values are data here, not opaque interpreter references. -export const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { - if (isCodeModeValue(value)) return false - if (isRuntimeReference(value)) return true - if (value === null || typeof value !== "object") return false - if (seen.has(value)) return false - seen.add(value) - const contains = Array.isArray(value) - ? value.some((item) => containsOpaqueReference(item, seen)) - : Object.values(value).some((item) => containsOpaqueReference(item, seen)) - seen.delete(value) - return contains +export const containsOpaqueReference = (value: unknown): boolean => { + const pending: Array> = [[value].values()] + const seen = new Set() + while (pending.length > 0) { + const next = pending.at(-1)!.next() + if (next.done) { + pending.pop() + continue + } + const current = next.value + if (isCodeModeValue(current)) continue + if (isRuntimeReference(current)) return true + if (current === null || typeof current !== "object" || seen.has(current)) continue + seen.add(current) + pending.push(childValues(current)) + } + return false } // Reject cycles before mutation so later boundary walks remain safe. @@ -66,15 +87,22 @@ export const rejectCircularInsertion = ( value: unknown, label: string, node: AstNode, - seen = new Set(), ): void => { - if (value === container) - throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") - if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return - seen.add(value) - const items = Array.isArray(value) ? value : Object.values(value) - for (const item of items) rejectCircularInsertion(container, item, label, node, seen) - seen.delete(value) + const pending: Array> = [[value].values()] + const seen = new Set() + while (pending.length > 0) { + const next = pending.at(-1)!.next() + if (next.done) { + pending.pop() + continue + } + const current = next.value + if (current === container) + throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue + seen.add(current) + pending.push(Array.isArray(current) ? current[Symbol.iterator]() : childValues(current)) + } } export const typeofValue = (value: unknown): string => { diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index b9ed33fd61..e4529028fb 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -777,9 +777,9 @@ export class Interpreter { } if (pattern.type === "ObjectPattern") { - if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { + if (value === null || typeof value !== "object" || isRuntimeReference(value)) { throw new InterpreterRuntimeError( - "Object destructuring requires a data object value.", + "Object destructuring requires a data object or array value.", pattern, "InvalidDataValue", ) @@ -798,38 +798,35 @@ export class Interpreter { continue } - if ( - property.type !== "Property" || - getBoolean(property, "computed") || - getString(property, "kind") !== "init" - ) { - throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) + const key = yield* self.destructuringPropertyKey(property) + if (isBlockedMember(String(key))) { + throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property) } - - const keyNode = getNode(property, "key") - const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) - if (isBlockedMember(key)) { - throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode) - } - consumed.add(key) - yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property) + consumed.add(String(key)) + yield* self.declarePattern( + getNode(property, "value"), + self.destructuringPropertyValue(value as SafeObject | Array, key), + mutable, + property, + ) } return } if (pattern.type === "ArrayPattern") { - if (!Array.isArray(value)) { - throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) + const items = spreadItems(value) + if (items === undefined) { + throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern) } for (const [index, item] of getArray(pattern, "elements").entries()) { if (item === null) continue const element = asNode(item, `elements[${index}]`) if (element.type === "RestElement") { - yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) + yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element) break } - yield* self.declarePattern(element, value[index], mutable, pattern) + yield* self.declarePattern(element, items[index], mutable, pattern) } return } @@ -858,15 +855,15 @@ export class Interpreter { } if (pattern.type === "ObjectPattern") { - if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { + if (value === null || typeof value !== "object" || isRuntimeReference(value)) { throw new InterpreterRuntimeError( - "Object destructuring requires a data object value.", + "Object destructuring requires a data object or array value.", pattern, "InvalidDataValue", ) } - const source = value as SafeObject + const source = value as SafeObject | Array const consumed = new Set() for (const propertyValue of getArray(pattern, "properties")) { const property = asNode(propertyValue, "properties") @@ -878,36 +875,29 @@ export class Interpreter { yield* self.assignPattern(getNode(property, "argument"), rest, property) continue } - if ( - property.type !== "Property" || - getBoolean(property, "computed") || - getString(property, "kind") !== "init" - ) { - throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) + const key = yield* self.destructuringPropertyKey(property) + if (isBlockedMember(String(key))) { + throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property) } - const keyNode = getNode(property, "key") - const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) - if (isBlockedMember(key)) { - throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode) - } - consumed.add(key) - yield* self.assignPattern(getNode(property, "value"), source[key], property) + consumed.add(String(key)) + yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property) } return } if (pattern.type === "ArrayPattern") { - if (!Array.isArray(value)) { - throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) + const items = spreadItems(value) + if (items === undefined) { + throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern) } for (const [index, item] of getArray(pattern, "elements").entries()) { if (item === null) continue const element = asNode(item, `elements[${index}]`) if (element.type === "RestElement") { - yield* self.assignPattern(getNode(element, "argument"), value.slice(index), element) + yield* self.assignPattern(getNode(element, "argument"), items.slice(index), element) break } - yield* self.assignPattern(element, value[index], pattern) + yield* self.assignPattern(element, items[index], pattern) } return } @@ -916,6 +906,26 @@ export class Interpreter { }) } + private destructuringPropertyKey(property: AstNode): Effect.Effect { + if (property.type !== "Property" || getString(property, "kind") !== "init") { + throw new InterpreterRuntimeError("Unsupported object destructuring property.", property) + } + const keyNode = getNode(property, "key") + if (getBoolean(property, "computed")) { + return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode)) + } + return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)) + } + + private destructuringPropertyValue(source: SafeObject | Array, key: string | number): unknown { + if (!Array.isArray(source)) return source[String(key)] + if (key === "length") return source.length + if (typeof key === "number") return source[key] + if (Object.hasOwn(source, key)) return (source as Record & Array)[key] + if (arrayMethods.has(key)) return new IntrinsicReference(source, key) + return undefined + } + private evaluateExpression(node: AstNode): Effect.Effect { switch (node.type) { case "Literal": { @@ -1279,6 +1289,7 @@ export class Interpreter { private evaluateUnaryExpression(node: AstNode): Effect.Effect { const operator = getString(node, "operator") const argument = getNode(node, "argument") + if (operator === "delete") return this.evaluateDeleteExpression(argument) // Undeclared names short-circuit, but declared TDZ bindings must still throw. if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(getString(argument, "name"))) { return Effect.succeed("undefined") @@ -1286,6 +1297,7 @@ export class Interpreter { return Effect.map(this.evaluateExpression(argument), (value) => { if (operator === "typeof") return typeofValue(value) if (operator === "!") return !value + if (operator === "void") return undefined if (containsOpaqueReference(value)) { throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue") } @@ -1729,6 +1741,7 @@ export class Interpreter { private getMemberReference( node: AstNode, + operation: "read" | "delete" = "read", ): Effect.Effect< | MemberReference | ToolReference @@ -1875,6 +1888,7 @@ export class Interpreter { } if (Array.isArray(objectValue)) { + if (operation === "delete") return { target: objectValue, key } if ( key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && @@ -1923,6 +1937,29 @@ export class Interpreter { return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) } + private evaluateDeleteExpression(argument: AstNode): Effect.Effect { + const target = argument.type === "ChainExpression" ? getNode(argument, "expression") : argument + if (target.type !== "MemberExpression") { + throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", argument) + } + return Effect.map(this.getMemberReference(target, "delete"), (reference) => { + if (reference === OptionalShortCircuit) return true + if ( + reference instanceof ComputedValue || + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof PromiseInstanceMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference || + reference.target instanceof CodeModeURL + ) { + throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue") + } + return Reflect.deleteProperty(reference.target, reference.key) + }) + } + // Resolve side-effecting object and key expressions exactly once. private modifyMember( node: AstNode, diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index def400344b..a47370e4c8 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -1,4 +1,5 @@ import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { containsOpaqueReference } from "../interpreter/references.js" import { isBlockedMember } from "../tool-runtime.js" import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js" import { boundedData, coerceToString } from "./value.js" @@ -44,6 +45,11 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast return Object.entries(requireObject()).map(([key, item]) => [key, item]) case "hasOwn": return Object.hasOwn(requireObject(), String(args[1])) + case "is": + if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) { + throw new InterpreterRuntimeError("Object.is requires data values in CodeMode.", node, "InvalidDataValue") + } + return Object.is(args[0], args[1]) case "assign": { const target = args[0] if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) { diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 132b40b12c..0326c13c54 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -99,6 +99,84 @@ describe("H4: typeof on an undeclared identifier is 'undefined'", () => { }) }) +describe("unary void", () => { + test("evaluates its operand and returns undefined", async () => { + expect(await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`)).toEqual([ + 1, + true, + ]) + }) + + test("discards opaque values", async () => { + expect(await value(`return void tools === undefined`)).toBe(true) + }) +}) + +describe("property deletion", () => { + test("deletes plain object fields and reports missing fields as successful", async () => { + expect( + await value(` + const object = { keep: 1, remove: 2 } + return [delete object.remove, delete object.missing, object] + `), + ).toEqual([true, true, { keep: 1 }]) + }) + + test("evaluates computed object and key expressions once", async () => { + expect( + await value(` + const object = { remove: true } + let objectReads = 0 + let keyReads = 0 + function getObject() { objectReads++; return object } + function getKey() { keyReads++; return "remove" } + const removed = delete getObject()[getKey()] + return [removed, objectReads, keyReads, Object.hasOwn(object, "remove")] + `), + ).toEqual([true, 1, 1, false]) + }) + + test("deleting an array index creates a hole without changing its length", async () => { + expect(await value(`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`)).toEqual([ + true, + 3, + false, + [1, null, 3], + ]) + }) + + test("array length is not configurable", async () => { + expect(await value(`const values = [1, 2]; return [delete values.length, values.length]`)).toEqual([false, 2]) + }) + + test("does not broaden unsupported array property assignment", async () => { + expect( + await value(` + const values = [] + let rightHandSideRuns = 0 + function next() { rightHandSideRuns++; return 1 } + try { values.field = next() } catch {} + return rightHandSideRuns + `), + ).toBe(0) + }) + + test("optional deletion short-circuits without evaluating the key", async () => { + expect( + await value(`let keyReads = 0; const object = null; return [delete object?.[keyReads++], keyReads]`), + ).toEqual([true, 0]) + }) + + test("rejects deletion from opaque runtime references", async () => { + expect((await error(`return delete tools.example`)).kind).toBe("InvalidDataValue") + }) + + test("keeps blocked property names unavailable", async () => { + expect((await error(`const object = {}; return delete object.__proto__`)).kind).toBe("ExecutionFailure") + expect((await error(`const values = []; return delete values["constructor"]`)).kind).toBe("ExecutionFailure") + }) +}) + describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => { test("guards run instead of the program crashing on a transient NaN", async () => { expect(await value(`return parseInt("abc") || 0`)).toBe(0) @@ -470,4 +548,71 @@ describe("destructuring assignment", () => { test("returns the assigned value", async () => { expect(await value(`let a = 0; const result = ([a] = [7]); return [a, result]`)).toEqual([7, [7]]) }) + + test("supports computed object keys and evaluates them once", async () => { + expect( + await value(` + let calls = 0 + const field = () => { calls++; return "name" } + const { [field()]: name, ...rest } = { name: "Ada", role: "engineer" } + return { calls, name, rest } + `), + ).toEqual({ calls: 1, name: "Ada", rest: { role: "engineer" } }) + }) + + test("supports object patterns over arrays", async () => { + expect( + await value(` + const { 0: first, length, slice, ...rest } = ["a", "b", "c"] + return { first, length, sliced: slice(1), rest } + `), + ).toEqual({ first: "a", length: 3, sliced: ["b", "c"], rest: { 1: "b", 2: "c" } }) + }) + + test("preserves exact computed property names on arrays", async () => { + expect( + await value(` + const { ["01"]: item, ...rest } = [10, 20] + return { missing: item === undefined, rest } + `), + ).toEqual({ missing: true, rest: { 0: 10, 1: 20 } }) + }) + + test("supports array patterns over strings, Maps, Sets, and URLSearchParams", async () => { + expect( + await value(` + const [letter, ...letters] = "A๐Ÿ˜€B" + const [[mapKey, mapValue]] = new Map([["key", 1]]) + const [setFirst, setSecond] = new Set([2, 3]) + const [[queryKey, queryValue]] = new URLSearchParams("q=test&page=2") + return { letter, letters, mapKey, mapValue, setFirst, setSecond, queryKey, queryValue } + `), + ).toEqual({ + letter: "A", + letters: ["๐Ÿ˜€", "B"], + mapKey: "key", + mapValue: 1, + setFirst: 2, + setSecond: 3, + queryKey: "q", + queryValue: "test", + }) + }) + + test("supports iterable patterns in assignment and parameters", async () => { + expect( + await value(` + let first + let rest + ;[first, ...rest] = new Set([1, 2, 3]) + const read = ([[key, value]]) => key + value + return { first, rest, entry: read(new Map([["a", 4]])) } + `), + ).toEqual({ first: 1, rest: [2, 3], entry: "a4" }) + }) + + test("rejects computed keys that are not confined property keys", async () => { + const err = await error(`const key = {}; const { [key]: value } = {}`) + expect(err.message).toContain("Property key must be a string or number") + }) }) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index ba779f33d7..94d8c57b07 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -593,6 +593,24 @@ describe("Set", () => { }) describe("stdlib integration", () => { + test("Object.is uses SameValue semantics", async () => { + expect( + await value(` + const object = {} + return [ + Object.is(NaN, NaN), + Object.is(0, -0), + Object.is(object, object), + Object.is({}, {}), + ] + `), + ).toEqual([true, false, true, false]) + }) + + test("Object.is rejects opaque runtime references", async () => { + expect((await error(`return Object.is(Math.max, Math.max)`)).kind).toBe("InvalidDataValue") + }) + test("Object values and entries accept arrays", async () => { expect(await value(`return [Object.values(["a", "b"]), Object.entries(["a", "b"])]`)).toEqual([ ["a", "b"], diff --git a/packages/core/schema.json b/packages/core/schema.json index 82a395b4e4..2ef980ade3 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "5f0a1db8-d4bf-42c3-becb-96b46fe66bed", + "id": "a4ba73b4-21bc-41ab-a415-94e2ca38d798", "prevIds": [ - "666138ef-82cb-4a9a-a765-e6669a436ff3" + "5f0a1db8-d4bf-42c3-becb-96b46fe66bed" ], "ddl": [ { @@ -38,6 +38,10 @@ "name": "event", "entityType": "tables" }, + { + "name": "kv", + "entityType": "tables" + }, { "name": "permission", "entityType": "tables" @@ -556,6 +560,46 @@ "entityType": "columns", "table": "event" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "kv" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "kv" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "kv" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "kv" + }, { "type": "text", "notNull": false, @@ -1844,6 +1888,15 @@ "table": "event", "entityType": "pks" }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "kv_pk", + "table": "kv", + "entityType": "pks" + }, { "columns": [ "id" diff --git a/packages/core/script/wellknown-server.ts b/packages/core/script/wellknown-server.ts new file mode 100644 index 0000000000..f8a513059f --- /dev/null +++ b/packages/core/script/wellknown-server.ts @@ -0,0 +1,112 @@ +const token = "dummy-wellknown-token" +const port = Number(process.env.PORT ?? 8787) +const config = { + $schema: "https://opencode.ai/config.json", + share: "manual", + model: "example-primary/example-chat", + enabled_providers: ["example-primary", "example-secondary"], + disabled_providers: ["opencode", "anthropic", "openai", "google", "xai", "amazon-bedrock", "azure"], + provider: { + "example-primary": { + name: "Example Primary", + npm: "@ai-sdk/openai-compatible", + whitelist: ["example-chat", "example-code"], + options: { + baseURL: "https://models.example.com/v1", + apiKey: "{env:TOKEN}", + }, + models: { + "example-chat": { + name: "Example Chat", + reasoning: true, + tool_call: true, + attachment: true, + modalities: { + input: ["text", "image"], + output: ["text"], + }, + limit: { + context: 128000, + output: 16000, + }, + }, + "example-code": { + name: "Example Code", + reasoning: true, + tool_call: true, + limit: { + context: 200000, + output: 32000, + }, + }, + }, + }, + "example-secondary": { + name: "Example Secondary", + npm: "@ai-sdk/openai-compatible", + whitelist: ["example-fast"], + options: { + baseURL: "https://inference.example.org/v1", + apiKey: "{env:TOKEN}", + }, + models: { + "example-fast": { + name: "Example Fast", + tool_call: true, + limit: { + context: 64000, + output: 8000, + }, + }, + }, + }, + }, + mcp: { + "example-tools": { + type: "remote", + url: "https://tools.example.net/mcp", + enabled: false, + }, + }, + permission: { + bash: "ask", + edit: "ask", + webfetch: "ask", + read: { + "*": "allow", + "*.env": "deny", + "*.env.*": "deny", + "*.env.example": "allow", + }, + }, +} + +const server = Bun.serve({ + hostname: "127.0.0.1", + port, + async fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/.well-known/opencode") { + return Response.json({ + auth: { + command: ["bun", "-e", `await Bun.sleep(5000); process.stdout.write(${JSON.stringify(token)})`], + env: "TOKEN", + }, + remote_config: { + url: `${url.origin}/config/opencode.json`, + headers: { authorization: "Bearer {env:TOKEN}" }, + }, + }) + } + if (url.pathname === "/config/opencode.json") { + if (request.headers.get("authorization") !== `Bearer ${token}`) { + return new Response("Unauthorized", { status: 401 }) + } + return Response.json(config) + } + return new Response("Not found", { status: 404 }) + }, +}) + +console.log(`Well-known fixture listening at ${server.url.origin}`) +console.log(`Test with: bun run dev auth connect ${server.url.origin}`) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 55a1641f1c..b3f762b228 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -2,10 +2,13 @@ export * as Config from "./config" import { makeLocationNode } from "./effect/app-node" import path from "path" +import { isDeepStrictEqual } from "node:util" import { type ParseError, parse } from "jsonc-parser" -import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect" import { Permission } from "@opencode-ai/schema/permission" import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { Integration } from "@opencode-ai/schema/integration" +import { Credential } from "./credential" import { EventV2 } from "./event" import { Watcher } from "./filesystem/watcher" import { FSUtil } from "./fs-util" @@ -16,6 +19,7 @@ import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" import { ConfigCommand } from "./config/command" +import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" import { ConfigMCP } from "./config/mcp" @@ -28,6 +32,7 @@ import { ConfigVariable } from "./config/variable" import { ConfigWatcher } from "./config/watcher" import { ConfigV1 } from "./v1/config/config" import { ConfigMigrateV1 } from "./v1/config/migrate" +import { WellKnown } from "./wellknown" export class Info extends Schema.Class("Config.Info")({ $schema: Schema.optional(Schema.String).annotate({ @@ -106,6 +111,7 @@ export class Info extends Schema.Class("Config.Info")({ description: "Ordered plugin enablement directives and external package declarations", }), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), + experimental: ConfigExperimental.Info.pipe(Schema.optional), }) {} export class Document extends Schema.Class("Config.Document")({ @@ -157,29 +163,68 @@ const layer = Layer.effect( const location = yield* Location.Service const watcher = yield* Watcher.Service const events = yield* EventV2.Service + const credentials = yield* Credential.Service + const wellknown = yield* WellKnown.Service const names = ["opencode.json", "opencode.jsonc"] + const reloadLock = Semaphore.makeUnsafe(1) const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions) + const parseInfo = (text: string) => { + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length) return + return Option.getOrUndefined( + ConfigMigrateV1.isV1(input) + ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo)) + : decodeInfo(input), + ) + } + const loadFile = Effect.fnUntraced(function* (filepath: string) { const text = yield* fs.readFileStringSafe(filepath) if (!text) return const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) - - const errors: ParseError[] = [] - const input: unknown = parse(substituted, errors, { allowTrailingComma: true }) - if (errors.length) return - - const info = Option.getOrUndefined( - ConfigMigrateV1.isV1(input) - ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo)) - : decodeInfo(input), - ) + const info = parseInfo(substituted) if (!info) return return new Document({ type: "document", path: filepath, info }) }) + const loadWellknown = Effect.fn("Config.loadWellknown")(function* () { + const entries = yield* wellknown + .entries() + .pipe( + Effect.catch((error) => + Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)), + ), + ) + return yield* Effect.forEach(entries, (entry) => + Effect.gen(function* () { + const auth = entry.manifest.auth + if (!auth) return [] + const credential = (yield* credentials.list(entry.integrationID)).findLast( + (credential) => credential.value.type === "key", + ) + if (!credential || credential.value.type !== "key") return [] + const variables = { [auth.env]: credential.value.key } + const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie) + return yield* Effect.forEach(configs, (config) => + ConfigVariable.substitute({ + type: "virtual", + source: entry.origin, + dir: entry.origin, + text: JSON.stringify(config), + env: variables, + }).pipe( + Effect.map(parseInfo), + Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)), + ), + ).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined))) + }), + ).pipe(Effect.map((documents) => documents.flat())) + }) + const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { return [ ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( @@ -201,7 +246,7 @@ const layer = Layer.effect( targets: [".opencode", ".claude", ".agents", ...names.toReversed()], start: location.directory, }) - .pipe(Effect.orDie) + .pipe(Effect.orDie) // We load certain files from a few other folders in the ecosystem const claude = [ @@ -244,7 +289,14 @@ const layer = Layer.effect( ) const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) - return [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] + return [ + ...claude, + ...agents, + ...(supplementary[0] ?? []), + ...direct, + ...supplementary.slice(1).flat(), + ...(yield* loadWellknown().pipe(Effect.orDie)), + ] }) const initial = yield* discover() @@ -276,16 +328,60 @@ const layer = Layer.effect( } }) - yield* Stream.fromPubSub(updates).pipe( - Stream.debounce("100 millis"), - Stream.runForEach((update) => + const reload = Effect.fn("Config.reload")(() => + reloadLock.withPermit( Effect.gen(function* () { const next = yield* discover() + if (isDeepStrictEqual(configs, next)) return configs = next yield* reconcile(next) yield* events.publish(ConfigSchema.Event.Updated, {}) - }).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))), + }), ), + ) + + yield* Stream.fromPubSub(updates).pipe( + Stream.debounce("100 millis"), + Stream.runForEach((update) => + reload().pipe( + Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filterEffect((event) => + wellknown.entries().pipe( + Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)), + Effect.catch(() => Effect.succeed(false)), + ), + ), + Stream.runForEach(() => + reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))), + ), + Effect.forkScoped({ startImmediately: true }), + ) + yield* events.subscribe(WellKnown.Event.Updated).pipe( + Stream.runForEach(() => + reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))), + ), + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.sleep("10 minutes").pipe( + Effect.andThen( + Effect.suspend(() => { + if (!wellknown.snapshot().length) return Effect.void + return Effect.gen(function* () { + const changed = yield* wellknown.refresh().pipe( + Effect.catch((error) => + Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)), + ), + ) + if (!changed) yield* reload() + }).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause }))) + }), + ), + Effect.forever, Effect.forkScoped({ startImmediately: true }), ) yield* reconcile(initial) @@ -301,5 +397,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node], + deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node], }) diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts new file mode 100644 index 0000000000..d8ecbbb762 --- /dev/null +++ b/packages/core/src/config/experimental.ts @@ -0,0 +1,14 @@ +export * as ConfigExperimental from "./experimental" + +import { Schema } from "effect" +import { NonNegativeInt } from "../schema" +import { ConfigPolicy } from "./policy" + +export class Info extends Schema.Class("ConfigExperimental.Info")({ + subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({ + description: "Maximum subagent nesting depth. Defaults to 1.", + }), + policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({ + description: "Ordered policies controlling access to configured resources", + }), +}) {} diff --git a/packages/core/src/config/plugin/policy.ts b/packages/core/src/config/plugin/policy.ts new file mode 100644 index 0000000000..fd05939d17 --- /dev/null +++ b/packages/core/src/config/plugin/policy.ts @@ -0,0 +1,35 @@ +export * as ConfigPolicyPlugin from "./policy" + +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Effect, Stream } from "effect" +import { Config } from "../../config" +import { Wildcard } from "../../util/wildcard" + +export const Plugin = define({ + id: "opencode.config.policy", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const loaded = { entries: yield* config.entries() } + yield* ctx.catalog.transform((catalog) => { + // User-global policy takes priority over policy authored by a repository. + const policies = loaded.entries + .filter((entry): entry is Config.Document => entry.type === "document") + .toReversed() + .flatMap((entry) => entry.info.experimental?.policies ?? []) + for (const record of catalog.provider.list()) { + const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource)) + if (policy?.effect === "deny") catalog.provider.remove(record.provider.id) + } + }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(ctx.catalog.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) diff --git a/packages/core/src/config/policy.ts b/packages/core/src/config/policy.ts new file mode 100644 index 0000000000..127bc822a3 --- /dev/null +++ b/packages/core/src/config/policy.ts @@ -0,0 +1,13 @@ +export * as ConfigPolicy from "./policy" + +import { Schema } from "effect" + +export const Effect = Schema.Literals(["allow", "deny"]) +export type Effect = typeof Effect.Type + +export const Info = Schema.Struct({ + action: Schema.Literal("provider.use"), + resource: Schema.String, + effect: Effect, +}) +export type Info = typeof Info.Type diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 2039393030..c4067c4765 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -54,5 +54,6 @@ export const migrations = ( import("./migration/20260709163752_time_suspended"), import("./migration/20260709190621_session_pending_table"), import("./migration/20260710025429_instruction_sync"), + import("./migration/20260716020354_kv"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260716020354_kv.ts b/packages/core/src/database/migration/20260716020354_kv.ts new file mode 100644 index 0000000000..5a0ac8bdd5 --- /dev/null +++ b/packages/core/src/database/migration/20260716020354_kv.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260716020354_kv", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`kv\` ( + \`key\` text PRIMARY KEY, + \`value\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 7d9fc7937b..b92b75e47b 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -87,6 +87,14 @@ export default { CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`kv\` ( + \`key\` text PRIMARY KEY, + \`value\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) yield* tx.run(` CREATE TABLE \`permission\` ( \`id\` text PRIMARY KEY, diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 236d027f3f..62d6376702 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -8,10 +8,12 @@ import { Duration, Effect, Exit, + Fiber, Layer, Schedule, Schema, Scope, + Stream, SynchronizedRef, Types, } from "effect" @@ -20,6 +22,8 @@ import { Credential } from "./credential" import { State } from "./state" import { EventV2 } from "./event" import { IntegrationConnection } from "./integration/connection" +import { AppProcess } from "./process" +import { ChildProcess } from "effect/unstable/process" export const ID = Integration.ID export type ID = Integration.ID @@ -45,6 +49,9 @@ export type Prompt = Integration.Prompt export const OAuthMethod = Integration.OAuthMethod export type OAuthMethod = Integration.OAuthMethod +export const CommandMethod = Integration.CommandMethod +export type CommandMethod = Integration.CommandMethod + export const KeyMethod = Integration.KeyMethod export type KeyMethod = Integration.KeyMethod @@ -87,12 +94,17 @@ export interface KeyImplementation { readonly method: KeyMethod } +export interface CommandImplementation { + readonly integrationID: ID + readonly method: CommandMethod +} + export interface EnvImplementation { readonly integrationID: ID readonly method: EnvMethod } -export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation +export type Implementation = OAuthImplementation | CommandImplementation | KeyImplementation | EnvImplementation export const Attempt = Integration.Attempt export type Attempt = Integration.Attempt @@ -100,6 +112,12 @@ export type Attempt = Integration.Attempt export const AttemptStatus = Integration.AttemptStatus export type AttemptStatus = typeof AttemptStatus.Type +export const CommandAttempt = Integration.CommandAttempt +export type CommandAttempt = Integration.CommandAttempt + +export const CommandAttemptStatus = Integration.CommandAttemptStatus +export type CommandAttemptStatus = Integration.CommandAttemptStatus + export class CodeRequiredError extends Schema.TaggedErrorClass()("Integration.CodeRequired", { attemptID: AttemptID, }) {} @@ -159,17 +177,6 @@ export interface Interface extends State.Transformable { /** User-facing label for the stored credential. */ readonly label?: string }) => Effect.Effect - /** Starts a stateful OAuth attempt. */ - readonly oauth: (input: { - /** Integration being authenticated. */ - readonly integrationID: ID - /** OAuth method selected by the caller. */ - readonly methodID: MethodID - /** Answers to the method's optional prompts. */ - readonly inputs: Inputs - /** User-facing label for the credential created on completion. */ - readonly label?: string - }) => Effect.Effect /** Updates a stored credential exposed as a connection. */ readonly update: ( credentialID: Credential.ID, @@ -178,18 +185,39 @@ export interface Interface extends State.Transformable { /** Removes a stored credential connection. */ readonly remove: (credentialID: Credential.ID) => Effect.Effect } - readonly attempt: { + readonly oauth: { + /** Starts a stateful OAuth attempt. */ + readonly connect: (input: { + readonly integrationID: ID + readonly methodID: MethodID + readonly inputs: Inputs + readonly label?: string + }) => Effect.Effect /** Returns the current state of an OAuth attempt. */ - readonly status: (attemptID: AttemptID) => Effect.Effect + readonly status: (input: { + readonly integrationID: ID + readonly attemptID: AttemptID + }) => Effect.Effect /** Completes the attempt and stores its credential. */ readonly complete: (input: { - /** Opaque handle returned by `oauth`. */ + readonly integrationID: ID readonly attemptID: AttemptID - /** Authorization code required by attempts in code mode. */ readonly code?: string }) => Effect.Effect /** Cancels an attempt and releases its resources. */ - readonly cancel: (attemptID: AttemptID) => Effect.Effect + readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect + } + readonly command: { + readonly connect: (input: { + readonly integrationID: ID + readonly methodID: MethodID + readonly label?: string + }) => Effect.Effect + readonly status: (input: { + readonly integrationID: ID + readonly attemptID: AttemptID + }) => Effect.Effect + readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect } } @@ -213,19 +241,40 @@ type PendingAttempt = { } type TerminalAttempt = { status: "complete" | "failed" | "expired" + integrationID: ID message?: string removeAt: number time: AttemptTime } type AttemptEntry = PendingAttempt | TerminalAttempt +type PendingCommandAttempt = { + status: "pending" + integrationID: ID + label?: string + message?: string + persisting: boolean + scope: Scope.Closeable + time: AttemptTime +} +type TerminalCommandAttempt = { + status: "complete" | "failed" | "expired" + integrationID: ID + message?: string + removeAt: number + time: AttemptTime +} +type CommandAttemptEntry = PendingCommandAttempt | TerminalCommandAttempt + const layer = Layer.effect( Service, Effect.gen(function* () { const credentials = yield* Credential.Service const events = yield* EventV2.Service + const processes = yield* AppProcess.Service const scope = yield* Scope.Scope const attempts = SynchronizedRef.makeUnsafe(new Map()) + const commandAttempts = SynchronizedRef.makeUnsafe(new Map()) const state = State.create({ name: "integration", initial: () => ({ integrations: new Map() }), @@ -259,8 +308,11 @@ const layer = Layer.effect( } const index = current.methods.findIndex((method) => { if (method.type !== implementation.method.type) return false - if (method.type !== "oauth" || implementation.method.type !== "oauth") return true - return method.id === implementation.method.id + if (method.type === "oauth" && implementation.method.type === "oauth") + return method.id === implementation.method.id + if (method.type === "command" && implementation.method.type === "command") + return method.id === implementation.method.id + return true }) if (index === -1) current.methods.push(implementation.method as Types.DeepMutable) else current.methods[index] = implementation.method as Types.DeepMutable @@ -276,8 +328,9 @@ const layer = Layer.effect( if (!current) return const index = current.methods.findIndex((candidate) => { if (candidate.type !== method.type) return false - if (candidate.type !== "oauth" || method.type !== "oauth") return true - return candidate.id === method.id + if (candidate.type === "oauth" && method.type === "oauth") return candidate.id === method.id + if (candidate.type === "command" && method.type === "command") return candidate.id === method.id + return true }) if (index !== -1) current.methods.splice(index, 1) if (method.type === "oauth") current.implementations.delete(method.id) @@ -332,6 +385,7 @@ const layer = Layer.effect( ? { ...match, persisting: true } : { status: "failed" as const, + integrationID: match.integrationID, message: message(exit.cause), time: match.time, removeAt: now + terminalRetention, @@ -362,13 +416,19 @@ const layer = Layer.effect( ) const settledAt = yield* Clock.currentTimeMillis const terminal: TerminalAttempt = Exit.isSuccess(persistence) - ? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention } + ? { + status: "complete", + integrationID: attempt.integrationID, + time: attempt.time, + removeAt: settledAt + terminalRetention, + } : { status: "failed", - message: message(persistence.cause), - time: attempt.time, - removeAt: settledAt + terminalRetention, - } + integrationID: attempt.integrationID, + message: message(persistence.cause), + time: attempt.time, + removeAt: settledAt + terminalRetention, + } // Persisting attempts cannot be cancelled, expired, or claimed again. yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal)) if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause) @@ -379,6 +439,61 @@ const layer = Layer.effect( ) }) + const settleCommand = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => { + const match = current.get(attemptID) + if (!match || match.status !== "pending" || match.persisting) return [undefined, current] + const next = Exit.isSuccess(exit) + ? { ...match, persisting: true } + : { + status: "failed" as const, + integrationID: match.integrationID, + message: message(exit.cause), + time: match.time, + removeAt: now + terminalRetention, + } + return [match, new Map(current).set(attemptID, next)] + }) + if (!attempt) return + if (Exit.isFailure(exit)) { + yield* close(attempt.scope) + return + } + + const persistence = yield* credentials + .create({ + integrationID: attempt.integrationID, + label: attempt.label, + value: Credential.Key.make({ type: "key", key: exit.value }), + }) + .pipe(Effect.asVoid, Effect.exit) + const settledAt = yield* Clock.currentTimeMillis + const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence) + ? { + status: "complete", + integrationID: attempt.integrationID, + time: attempt.time, + removeAt: settledAt + terminalRetention, + } + : { + status: "failed", + integrationID: attempt.integrationID, + message: message(persistence.cause), + time: attempt.time, + removeAt: settledAt + terminalRetention, + } + yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal)) + yield* close(attempt.scope) + if (Exit.isFailure(persistence)) return + yield* events.publish(Event.ConnectionUpdated, { integrationID: attempt.integrationID }) + yield* events.publish(Event.Updated, {}) + }), + ) + }) + const scrub = Effect.fnUntraced(function* () { const now = yield* Clock.currentTimeMillis const expired = yield* SynchronizedRef.modify(attempts, (current) => { @@ -387,7 +502,35 @@ const layer = Layer.effect( for (const [id, attempt] of current) { if (attempt.status === "pending" && !attempt.persisting && attempt.time.expires <= now) { scopes.push(attempt.scope) - next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention }) + next.set(id, { + status: "expired", + integrationID: attempt.integrationID, + time: attempt.time, + removeAt: now + terminalRetention, + }) + continue + } + if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id) + } + return [scopes, next] + }) + yield* Effect.forEach(expired, close, { discard: true }) + }) + + const scrubCommands = Effect.fnUntraced(function* () { + const now = yield* Clock.currentTimeMillis + const expired = yield* SynchronizedRef.modify(commandAttempts, (current) => { + const next = new Map(current) + const scopes: Scope.Closeable[] = [] + for (const [id, attempt] of current) { + if (attempt.status === "pending" && !attempt.persisting && attempt.time.expires <= now) { + scopes.push(attempt.scope) + next.set(id, { + status: "expired", + integrationID: attempt.integrationID, + time: attempt.time, + removeAt: now + terminalRetention, + }) continue } if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id) @@ -398,6 +541,120 @@ const layer = Layer.effect( }) yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope)) + yield* scrubCommands().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope)) + + const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: { + readonly integrationID: ID + readonly methodID: MethodID + readonly inputs: Inputs + readonly label?: string + }) { + const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) + if (!method) { + return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`)) + } + const attemptScope = yield* Scope.fork(scope) + const authorization = yield* authorize(method.authorize(input.inputs)).pipe( + Scope.provide(attemptScope), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)), + ) + const id = AttemptID.create() + const created = yield* Clock.currentTimeMillis + const time = { created, expires: created + attemptLifetime } + yield* SynchronizedRef.update(attempts, (current) => + new Map(current).set(id, { + status: "pending", + completing: authorization.mode === "auto", + persisting: false, + authorization, + integrationID: input.integrationID, + methodID: input.methodID, + label: input.label, + scope: attemptScope, + time, + }), + ) + if (authorization.mode === "auto") { + yield* authorization.callback.pipe( + Effect.exit, + Effect.flatMap((exit) => settle(id, exit)), + Effect.forkIn(attemptScope, { startImmediately: true }), + ) + } + return new Attempt({ + attemptID: id, + url: authorization.url, + instructions: authorization.instructions, + mode: authorization.mode, + time, + }) + }) + + const connectCommand = Effect.fn("Integration.command.connect")(function* (input: { + readonly integrationID: ID + readonly methodID: MethodID + readonly label?: string + }) { + const method = state + .get() + .integrations.get(input.integrationID) + ?.methods.find((method) => method.type === "command" && method.id === input.methodID) + if (!method || method.type !== "command" || !method.command[0]) { + return yield* Effect.die(new Error(`Command method not found: ${input.integrationID}/${input.methodID}`)) + } + + const attemptScope = yield* Scope.fork(scope) + const attemptID = AttemptID.create() + const created = yield* Clock.currentTimeMillis + const time = { created, expires: created + attemptLifetime } + yield* SynchronizedRef.update(commandAttempts, (current) => + new Map(current).set(attemptID, { + status: "pending", + integrationID: input.integrationID, + label: input.label, + persisting: false, + scope: attemptScope, + time, + }), + ) + + yield* Effect.gen(function* () { + const handle = yield* processes.spawn( + ChildProcess.make(method.command[0], method.command.slice(1), { + extendEnv: true, + stdin: "ignore", + }), + ) + const stdout = yield* AppProcess.collectStream(handle.stdout, undefined).pipe(Effect.forkScoped) + yield* handle.stderr.pipe( + Stream.decodeText, + Stream.tap((chunk) => + SynchronizedRef.update(commandAttempts, (current) => { + const attempt = current.get(attemptID) + if (!attempt || attempt.status !== "pending") return current + const message = (attempt.message ?? "") + chunk + return new Map(current).set(attemptID, { ...attempt, message }) + }), + ), + Stream.runDrain, + ) + const exitCode = yield* handle.exitCode + if (exitCode !== 0) { + const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(attemptID) + return yield* Effect.fail(new Error(attempt?.message?.trim() || `Authentication command exited ${exitCode}`)) + } + const credential = (yield* Fiber.join(stdout)).buffer.toString("utf8").trim() + if (!credential) return yield* Effect.fail(new Error("Authentication command returned no credential")) + return credential + }).pipe( + Scope.provide(attemptScope), + Effect.exit, + Effect.flatMap((exit) => settleCommand(attemptID, exit)), + Effect.forkIn(attemptScope, { startImmediately: true }), + ) + + return CommandAttempt.make({ attemptID, time }) + }) return Service.of({ transform: state.transform, @@ -451,47 +708,6 @@ const layer = Layer.effect( yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID }) yield* events.publish(Event.Updated, {}) }), - oauth: Effect.fn("Integration.connection.oauth")(function* (input) { - const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) - if (!method) { - return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`)) - } - const attemptScope = yield* Scope.fork(scope) - const authorization = yield* authorize(method.authorize(input.inputs)).pipe( - Scope.provide(attemptScope), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)), - ) - const id = AttemptID.create() - const created = yield* Clock.currentTimeMillis - const time = { created, expires: created + attemptLifetime } - yield* SynchronizedRef.update(attempts, (current) => - new Map(current).set(id, { - status: "pending", - completing: authorization.mode === "auto", - persisting: false, - authorization, - integrationID: input.integrationID, - methodID: input.methodID, - label: input.label, - scope: attemptScope, - time, - }), - ) - if (authorization.mode === "auto") { - yield* authorization.callback.pipe( - Effect.exit, - Effect.flatMap((exit) => settle(id, exit)), - Effect.forkIn(attemptScope, { startImmediately: true }), - ) - } - return new Attempt({ - attemptID: id, - url: authorization.url, - instructions: authorization.instructions, - mode: authorization.mode, - time, - }) - }), update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) { const credential = yield* credentials.get(credentialID) yield* credentials.update(credentialID, updates) @@ -509,19 +725,22 @@ const layer = Layer.effect( yield* events.publish(Event.Updated, {}) }), }, - attempt: { - status: Effect.fn("Integration.attempt.status")(function* (attemptID) { - const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID) - if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${attemptID}`)) + oauth: { + connect: connectOAuth, + status: Effect.fn("Integration.oauth.status")(function* (input) { + const attempt = (yield* SynchronizedRef.get(attempts)).get(input.attemptID) + if (!attempt || attempt.integrationID !== input.integrationID) + return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`)) if (attempt.status === "failed") { return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time } } return { status: attempt.status, time: attempt.time } }), - complete: Effect.fn("Integration.attempt.complete")(function* (input) { + complete: Effect.fn("Integration.oauth.complete")(function* (input) { const attempt = yield* SynchronizedRef.modify(attempts, (current) => { const match = current.get(input.attemptID) - if (!match || match.status !== "pending" || match.completing) return [match, current] + if (!match || match.integrationID !== input.integrationID) return [undefined, current] + if (match.status !== "pending" || match.completing) return [match, current] if (match.authorization.mode === "code" && input.code === undefined) return [match, current] return [match, new Map(current).set(input.attemptID, { ...match, completing: true })] }) @@ -540,12 +759,43 @@ const layer = Layer.effect( yield* settle(input.attemptID, exit) if (Exit.isFailure(exit)) return yield* exit }), - cancel: Effect.fn("Integration.attempt.cancel")(function* (attemptID) { + cancel: Effect.fn("Integration.oauth.cancel")(function* (input) { const attempt = yield* SynchronizedRef.modify(attempts, (current) => { - const match = current.get(attemptID) - if (!match || match.status !== "pending" || match.persisting) return [undefined, current] + const match = current.get(input.attemptID) + if (!match || match.integrationID !== input.integrationID || match.status !== "pending" || match.persisting) + return [undefined, current] const next = new Map(current) - next.delete(attemptID) + next.delete(input.attemptID) + return [match, next] + }) + if (attempt) yield* Scope.close(attempt.scope, Exit.void) + }), + }, + command: { + connect: connectCommand, + status: Effect.fn("Integration.command.status")(function* (input) { + const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(input.attemptID) + if (!attempt || attempt.integrationID !== input.integrationID) + return yield* Effect.die(new Error(`Command attempt not found: ${input.attemptID}`)) + if (attempt.status === "pending") { + return { + status: attempt.status, + ...(attempt.message ? { message: attempt.message } : {}), + time: attempt.time, + } + } + if (attempt.status === "failed") { + return { status: attempt.status, message: attempt.message ?? "Authentication failed", time: attempt.time } + } + return { status: attempt.status, time: attempt.time } + }), + cancel: Effect.fn("Integration.command.cancel")(function* (input) { + const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => { + const match = current.get(input.attemptID) + if (!match || match.integrationID !== input.integrationID || match.status !== "pending" || match.persisting) + return [undefined, current] + const next = new Map(current) + next.delete(input.attemptID) return [match, next] }) if (attempt) yield* Scope.close(attempt.scope, Exit.void) @@ -555,4 +805,8 @@ const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] }) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Credential.node, EventV2.node, AppProcess.node], +}) diff --git a/packages/core/src/kv.ts b/packages/core/src/kv.ts new file mode 100644 index 0000000000..9bc0d10094 --- /dev/null +++ b/packages/core/src/kv.ts @@ -0,0 +1,47 @@ +export * as KV from "./kv" + +import { eq } from "drizzle-orm" +import { Context, Effect, Layer, Schema } from "effect" +import { Database } from "./database/database" +import { makeGlobalNode } from "./effect/app-node" +import { KVTable } from "./kv/sql" + +export type Value = Schema.Json + +export interface Interface { + readonly get: (key: string) => Effect.Effect + readonly set: (key: string, value: Value) => Effect.Effect + readonly remove: (key: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/KV") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + return Service.of({ + get: Effect.fn("KV.get")(function* (key) { + return (yield* db + .select({ value: KVTable.value }) + .from(KVTable) + .where(eq(KVTable.key, key)) + .get() + .pipe(Effect.orDie))?.value + }), + set: Effect.fn("KV.set")(function* (key, value) { + yield* db + .insert(KVTable) + .values({ key, value }) + .onConflictDoUpdate({ target: KVTable.key, set: { value, time_updated: Date.now() } }) + .run() + .pipe(Effect.orDie) + }), + remove: Effect.fn("KV.remove")(function* (key) { + yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie) + }), + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/kv/sql.ts b/packages/core/src/kv/sql.ts new file mode 100644 index 0000000000..2344c30c28 --- /dev/null +++ b/packages/core/src/kv/sql.ts @@ -0,0 +1,9 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" +import type { KV } from "../kv" + +export const KVTable = sqliteTable("kv", { + key: text().primaryKey(), + value: text({ mode: "json" }).$type().notNull(), + ...Timestamps, +}) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 2da72df1dd..47e3f6dd2f 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -27,18 +27,19 @@ import { Pty } from "./pty" import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" -import { ReferenceGuidance } from "./reference/guidance" +import { ReferenceInstructions } from "./reference/instructions" import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionCompaction } from "./session/compaction" import { SessionTitle } from "./session/title" import { SkillV2 } from "./skill" -import { SkillGuidance } from "./skill/guidance" +import { SkillInstructions } from "./skill/instructions" import { Snapshot } from "./snapshot" import { InstructionDiscovery } from "./instruction-discovery" import { InstructionBuiltIns } from "./instructions/builtins" import { InstructionEntry } from "./session/instruction-entry" import { SessionInstructions } from "./session/instructions" +import { SessionGenerateNode } from "./session/generate-node" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" @@ -75,12 +76,13 @@ const locationServiceNodes = [ ToolRegistry.node, ToolRegistry.toolsNode, Image.node, - SkillGuidance.node, - ReferenceGuidance.node, + SkillInstructions.node, + ReferenceInstructions.node, InstructionEntry.node, Form.node, QuestionV2.node, Generate.node, + SessionGenerateNode.node, ReadToolFileSystem.node, McpTool.node, SessionInstructions.node, diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/instructions.ts similarity index 96% rename from packages/core/src/mcp/guidance.ts rename to packages/core/src/mcp/instructions.ts index 2e7f34b047..e5865de69f 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/instructions.ts @@ -1,4 +1,4 @@ -export * as McpGuidance from "./guidance" +export * as McpInstructions from "./instructions" import { makeLocationNode } from "../effect/app-node" import { Context, Effect, Layer, Schema } from "effect" @@ -54,7 +54,7 @@ export interface Interface { readonly load: (agent: AgentV2.Selection) => Effect.Effect } -export class Service extends Context.Service()("@opencode/v2/McpGuidance") {} +export class Service extends Context.Service()("@opencode/v2/McpInstructions") {} export const layer = Layer.effect( Service, @@ -62,7 +62,7 @@ export const layer = Layer.effect( const mcp = yield* MCP.Service return Service.of({ - load: Effect.fn("McpGuidance.load")(function* (selection) { + load: Effect.fn("McpInstructions.load")(function* (selection) { const agent = selection.info if (!agent) return Instructions.empty const source = (value: ReadonlyArray | Instructions.Removed) => diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index 5c36938612..4ac437d392 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -1,15 +1,15 @@ export * as PluginHooks from "./hooks" -import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai" import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" import { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "../effect/app-node" import { State } from "../state" export interface Domains { - readonly ai: AIHooks readonly aisdk: AISDKHooks + readonly session: SessionHooks readonly tool: ToolHooks } diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 207a0cb1e1..02927d583d 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -63,6 +63,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int return { options: {}, agent: { + get: (id) => agents.get(AgentV2.ID.make(id)), list: (input) => { const ref = locationRef(input) if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref) @@ -80,9 +81,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) }), }, - ai: { - hook: (name, callback) => hooks.register("ai", name, callback), - }, aisdk: { hook: (name, callback) => { if (name === "sdk") { @@ -126,6 +124,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), }, model: { + get: (providerID, modelID) => + catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), list: () => response(catalog.model.available()), default: () => response(catalog.model.default()), }, @@ -176,21 +176,57 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int key: input.key, label: input.label, }), - oauth: (input) => + }, + oauth: { + connect: (input) => response( - integration.connection.oauth({ + integration.oauth.connect({ integrationID: Integration.ID.make(input.integrationID), methodID: Integration.MethodID.make(input.methodID), inputs: input.inputs, label: input.label, }), ), - }, - attempt: { - status: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), + status: (input) => + response( + integration.oauth.status({ + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + }), + ), complete: (input) => - integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), - cancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), + integration.oauth.complete({ + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + code: input.code, + }), + cancel: (input) => + integration.oauth.cancel({ + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + }), + }, + command: { + connect: (input) => + response( + integration.command.connect({ + integrationID: Integration.ID.make(input.integrationID), + methodID: Integration.MethodID.make(input.methodID), + label: input.label, + }), + ), + status: (input) => + response( + integration.command.status({ + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + }), + ), + cancel: (input) => + integration.command.cancel({ + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + }), }, reload: integration.reload, connection: { @@ -270,6 +306,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) return } + if (input.method.type === "command") { + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method), + }) + return + } draft.method.update({ integrationID: Integration.ID.make(input.integrationID), method: { type: "key", label: input.method.label }, @@ -370,6 +413,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, }, session: { + hook: (name, callback) => hooks.register("session", name, callback), create: (input) => runtime.session.create({ id: input?.id, @@ -381,6 +425,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int get: (input) => runtime.session.get(input.sessionID), prompt: runtime.session.prompt, command: runtime.session.command, + synthetic: runtime.session.synthetic, interrupt: (input) => runtime.session.interrupt(input.sessionID), }, } satisfies Plugin.Context diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 9b75645db4..69725e2c24 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -10,6 +10,7 @@ import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" import { ConfigProviderPlugin } from "../config/plugin/provider" +import { ConfigPolicyPlugin } from "../config/plugin/policy" import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigSkillPlugin } from "../config/plugin/skill" import { EventV2 } from "../event" @@ -43,6 +44,7 @@ import { SubagentTool } from "../tool/subagent" import { Tools } from "../tool/tools" import { WebFetchTool } from "../tool/webfetch" import { WebSearchTool } from "../tool/websearch" +import { WellKnown } from "../wellknown" import { WriteTool } from "../tool/write" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" @@ -50,7 +52,9 @@ import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { PluginRuntime } from "./runtime" import { SkillPlugin } from "./skill" +import { SystemPromptPlugin } from "./system-prompt" import { VariantPlugin } from "./variant" +import { WellKnownPlugin } from "../wellknown/plugin" const services = Effect.fn("PluginInternal.services")(function* () { const agent = yield* AgentV2.Service @@ -80,6 +84,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const skill = yield* SkillV2.Service const tools = yield* Tools.Service const websearch = yield* WebSearchTool.ConfigService + const wellknown = yield* WellKnown.Service return Context.mergeAll( Context.make(AgentV2.Service, agent), Context.make(Catalog.Service, catalog), @@ -108,6 +113,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(SkillV2.Service, skill), Context.make(Tools.Service, tools), Context.make(WebSearchTool.ConfigService, websearch), + Context.make(WellKnown.Service, wellknown), ) }) @@ -118,9 +124,11 @@ export type Requirements = ContextServices const pre = [ + WellKnownPlugin.Plugin, AgentPlugin.Plugin, CommandPlugin.Plugin, SkillPlugin.Plugin, + ...SystemPromptPlugin.Plugins, ModelsDevPlugin, ...ProviderPlugins, PatchTool.Plugin, @@ -144,6 +152,7 @@ const post = [ ConfigSkillPlugin.Plugin, ConfigProviderPlugin.Plugin, VariantPlugin.Plugin, + ConfigPolicyPlugin.Plugin, ] as const satisfies readonly InternalPlugin[] export const list = Effect.fn("PluginInternal.list")(function* () { diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 670964b3ca..ad9cfc3bf1 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -59,14 +59,11 @@ export function fromPromise(plugin: Plugin) { const context2: Context = { options: host.options, agent: { + get: (id) => run(host.agent.get(id)), list: (input) => run(host.agent.list(input)), transform: transform(host.agent), reload: () => run(host.agent.reload()), }, - ai: { - hook: (name, callback) => - register(host.ai.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), - }, aisdk: { hook: (name, callback) => register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), @@ -74,9 +71,11 @@ export function fromPromise(plugin: Plugin) { catalog: { provider: { list: (input) => run(host.catalog.provider.list(input)), - get: (input) => run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })), + get: (input) => + run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })), }, model: { + get: (providerID, modelID) => run(host.catalog.model.get(providerID, modelID)), list: (input) => run(host.catalog.model.list(input)), default: (input) => run(host.catalog.model.default(input)).then((result) => ({ ...result, data: result.data ?? null })), @@ -100,35 +99,66 @@ export function fromPromise(plugin: Plugin) { ), connect: { key: (input) => - run(host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) })), - oauth: (input) => run( - host.integration.connect.oauth({ + host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) }), + ), + }, + oauth: { + connect: (input) => + run( + host.integration.oauth.connect({ ...input, integrationID: Integration.ID.make(input.integrationID), methodID: Integration.MethodID.make(input.methodID), }), ), - }, - attempt: { status: (input) => run( - host.integration.attempt.status({ + host.integration.oauth.status({ ...input, + integrationID: Integration.ID.make(input.integrationID), attemptID: Integration.AttemptID.make(input.attemptID), }), ), complete: (input) => run( - host.integration.attempt.complete({ + host.integration.oauth.complete({ ...input, + integrationID: Integration.ID.make(input.integrationID), attemptID: Integration.AttemptID.make(input.attemptID), }), ), cancel: (input) => run( - host.integration.attempt.cancel({ + host.integration.oauth.cancel({ ...input, + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + }), + ), + }, + command: { + connect: (input) => + run( + host.integration.command.connect({ + ...input, + integrationID: Integration.ID.make(input.integrationID), + methodID: Integration.MethodID.make(input.methodID), + }), + ), + status: (input) => + run( + host.integration.command.status({ + ...input, + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + }), + ), + cancel: (input) => + run( + host.integration.command.cancel({ + ...input, + integrationID: Integration.ID.make(input.integrationID), attemptID: Integration.AttemptID.make(input.attemptID), }), ), @@ -166,6 +196,8 @@ export function fromPromise(plugin: Plugin) { register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, session: { + hook: (name, callback) => + register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), create: (input) => run( host.session.create( @@ -212,6 +244,17 @@ export function fromPromise(plugin: Plugin) { resume: input.resume ?? undefined, }), ), + synthetic: (input) => + run( + host.session.synthetic({ + ...input, + sessionID: Session.ID.make(input.sessionID), + id: input.id == null ? undefined : SessionMessage.ID.make(input.id), + description: input.description ?? undefined, + delivery: input.delivery ?? undefined, + resume: input.resume ?? undefined, + }), + ), interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })), }, } diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index f41be2ad7c..fce294998e 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -35,6 +35,7 @@ import { SkillV2 } from "../skill" import { ReadToolFileSystem } from "../tool/read-filesystem" import { ToolRegistry } from "../tool/registry" import { WebSearchTool } from "../tool/websearch" +import { WellKnown } from "../wellknown" import { PluginInternal } from "./internal" import { PluginRuntime } from "./runtime" import { SdkPlugins } from "./sdk" @@ -163,9 +164,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract import(source)) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default @@ -296,6 +295,7 @@ export const node = makeLocationNode({ SkillV2.node, ToolRegistry.toolsNode, WebSearchTool.configNode, + WellKnown.node, ], }) diff --git a/packages/core/src/plugin/system-prompt.ts b/packages/core/src/plugin/system-prompt.ts new file mode 100644 index 0000000000..9d5d557871 --- /dev/null +++ b/packages/core/src/plugin/system-prompt.ts @@ -0,0 +1,47 @@ +export * as SystemPromptPlugin from "./system-prompt" + +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Effect } from "effect" + +import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt" +import PROMPT_CODEX from "./system-prompt/codex.txt" +import PROMPT_GEMINI from "./system-prompt/gemini.txt" +import PROMPT_GPT from "./system-prompt/gpt.txt" +import PROMPT_KIMI from "./system-prompt/kimi.txt" +import PROMPT_META from "./system-prompt/meta.txt" +import PROMPT_TRINITY from "./system-prompt/trinity.txt" + +export const OpenAIPlugin = make("openai", (id) => { + if (id.includes("gpt")) { + if (id.includes("codex")) return PROMPT_CODEX + return PROMPT_GPT + } + if (id.includes("o1") || id.includes("o3")) return PROMPT_GPT +}) + +export const GooglePlugin = make("google", (id) => (id.includes("gemini-") ? PROMPT_GEMINI : undefined)) +export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined)) +export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined)) +export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined)) +export const MetaPlugin = make("meta", (id) => (id.includes("muse-spark") ? PROMPT_META : undefined)) + +export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const + +function make(id: string, select: (modelID: string) => string | undefined) { + return define({ + id: `opencode.system-prompt.${id}`, + effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) { + yield* ctx.session.hook("context", (event) => + Effect.gen(function* () { + if ((yield* ctx.agent.get(event.agent))?.system) return + const system = event.system[0] + if (!system) return + const model = yield* ctx.catalog.model.get(event.model.providerID, event.model.id) + const prompt = select(`${model?.modelID ?? event.model.id} ${model?.family ?? ""}`.toLowerCase()) + if (!prompt) return + event.system[0] = { ...system, text: prompt } + }), + ) + }), + }) +} diff --git a/packages/core/src/session/runner/prompt/anthropic.txt b/packages/core/src/plugin/system-prompt/anthropic.txt similarity index 100% rename from packages/core/src/session/runner/prompt/anthropic.txt rename to packages/core/src/plugin/system-prompt/anthropic.txt diff --git a/packages/core/src/session/runner/prompt/codex.txt b/packages/core/src/plugin/system-prompt/codex.txt similarity index 100% rename from packages/core/src/session/runner/prompt/codex.txt rename to packages/core/src/plugin/system-prompt/codex.txt diff --git a/packages/core/src/session/runner/prompt/gemini.txt b/packages/core/src/plugin/system-prompt/gemini.txt similarity index 100% rename from packages/core/src/session/runner/prompt/gemini.txt rename to packages/core/src/plugin/system-prompt/gemini.txt diff --git a/packages/core/src/session/runner/prompt/gpt.txt b/packages/core/src/plugin/system-prompt/gpt.txt similarity index 100% rename from packages/core/src/session/runner/prompt/gpt.txt rename to packages/core/src/plugin/system-prompt/gpt.txt diff --git a/packages/core/src/session/runner/prompt/kimi.txt b/packages/core/src/plugin/system-prompt/kimi.txt similarity index 100% rename from packages/core/src/session/runner/prompt/kimi.txt rename to packages/core/src/plugin/system-prompt/kimi.txt diff --git a/packages/core/src/plugin/system-prompt/meta.txt b/packages/core/src/plugin/system-prompt/meta.txt new file mode 100644 index 0000000000..933b156b43 --- /dev/null +++ b/packages/core/src/plugin/system-prompt/meta.txt @@ -0,0 +1,76 @@ +You are OpenCode, the best coding agent on the planet. +You are based on a large language model trained by Meta MSL named Muse Spark. +When asked who you are, identify yourself as OpenCode powered by Meta Muse Spark by name. +You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. +IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. +If the user asks for help or wants to give feedback inform them of the following: +- ctrl+p to list available actions +- To give feedback, users should report the issue at + https://github.com/anomalyco/opencode +When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the webfetch tool to gather information to answer the question from V2 OpenCode docs. The list of available docs is available at https://v2.opencode.ai/llms.txt +# Tone and style +- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. +- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. +- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like shell or code comments as means to communicate with the user during the session. +- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files. +# Professional objectivity +Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. +# Task Management +Track tasks and plans explicitly when useful so that you manage your work and give the user visibility into your progress. +Task tracking is also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not track your plan, you may forget to do important tasks - and that is unacceptable. +It is critical that you mark tasks as completed as soon as you are done with them. Do not batch up multiple tasks before marking them as completed. +Examples: + +user: Run the build and fix any type errors +assistant: I'm going to work through the following items: +- Run the build +- Fix any type errors +I'm now going to run the build using shell. +Looks like I found 10 type errors. I'm going to track the 10 fixes and address them one at a time. +Starting the first fix... +Let me start working on the first item... +The first item has been fixed; I'll move on to the second item... +.. +.. + +In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. + +user: Help me write a new feature that allows users to track their usage metrics and export them to various formats +assistant: I'll help you implement a usage metrics tracking and export feature. Let me first plan this task. +The plan is: +1. Research existing metrics tracking in the codebase +2. Design the metrics collection system +3. Implement core metrics tracking functionality +4. Create export functionality for different formats +Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. +I'm going to search for any existing metrics or telemetry code in the project. +I've found some existing telemetry code. The first task is complete; I'll start designing our metrics tracking system based on what I've learned... +[Assistant continues implementing the feature step by step, reporting progress as tasks are completed] + +# Doing tasks +The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: +- Plan and track the task if required +- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. +# Tool usage policy +- When doing file search, prefer to use the subagent tool in order to reduce context usage. +- You should proactively use the subagent tool with specialized agents when the task at hand matches the agent's description. +- When webfetch returns a message about a redirect to a different host, you should immediately make a new webfetch request with the redirect URL provided in the response. +- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. +- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple subagent tool calls. +- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: read for reading files instead of cat/head/tail, edit for editing instead of sed/awk, and write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. +- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the subagent tool instead of running search commands directly. + +user: Where are errors from the client handled? +assistant: [Uses the subagent tool to find the files that handle client errors instead of using glob or grep directly] + + +user: What is the codebase structure? +assistant: [Uses the subagent tool] + +IMPORTANT: Plan and track multi-step tasks throughout the conversation. +# Code References +When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. + +user: Where are errors from the client handled? +assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. + diff --git a/packages/core/src/session/runner/prompt/trinity.txt b/packages/core/src/plugin/system-prompt/trinity.txt similarity index 100% rename from packages/core/src/session/runner/prompt/trinity.txt rename to packages/core/src/plugin/system-prompt/trinity.txt diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/instructions.ts similarity index 95% rename from packages/core/src/reference/guidance.ts rename to packages/core/src/reference/instructions.ts index a9fd9eb980..7e09946790 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/instructions.ts @@ -1,4 +1,4 @@ -export * as ReferenceGuidance from "./guidance" +export * as ReferenceInstructions from "./instructions" import { makeLocationNode } from "../effect/app-node" import { Context, Effect, Layer, Schema } from "effect" @@ -57,7 +57,7 @@ export interface Interface { readonly load: () => Effect.Effect } -export class Service extends Context.Service()("@opencode/v2/ReferenceGuidance") {} +export class Service extends Context.Service()("@opencode/v2/ReferenceInstructions") {} const layer = Layer.effect( Service, @@ -65,7 +65,7 @@ const layer = Layer.effect( const references = yield* Reference.Service return Service.of({ - load: Effect.fn("ReferenceGuidance.load")(function* () { + load: Effect.fn("ReferenceInstructions.load")(function* () { const available = (yield* references.list()) .filter((reference) => reference.description !== undefined) .map((reference) => ({ diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 34ff7ecfa9..e2c27f58b4 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -33,10 +33,12 @@ import { LocationServiceMap } from "./location-service-map" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionPending } from "./session/pending" +import { SessionGenerate } from "./session/generate" import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" import { Session } from "@opencode-ai/schema/session" import { FSUtil } from "./fs-util" +import { Image } from "./image" import { Mime } from "./mime" import type { EventLog } from "@opencode-ai/schema/event-log" import { SkillV2 } from "./skill" @@ -175,6 +177,7 @@ export type Error = | CommandV2.NotFoundError | CommandV2.EvaluationError | MessageNotFoundError + | SessionGenerate.Error export interface Interface { readonly list: (input?: ListInput) => Effect.Effect<{ @@ -243,6 +246,11 @@ export interface Interface { delivery?: SessionPending.Delivery resume?: boolean }) => Effect.Effect + /** Generates text from current Session context without admitting input or mutating history. */ + readonly generate: (input: { + sessionID: SessionSchema.ID + prompt: string + }) => Effect.Effect readonly command: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -531,9 +539,13 @@ const layer = Layer.effect( // continues from the reverted boundary rather than stale post-boundary history. if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) - const prompt = yield* resolvePrompt({ text: input.text, files: input.files, agents: input.agents }).pipe( - Effect.provideService(FSUtil.Service, fs), - ) + // Resolved lazily so prompt admission only boots location services when an + // image attachment actually needs the resizer. + const image = Image.Service.pipe(Effect.provide(locations.get(session.location))) + const prompt = yield* resolvePrompt( + { text: input.text, files: input.files, agents: input.agents }, + image, + ).pipe(Effect.provideService(FSUtil.Service, fs)) const messageID = input.id ?? SessionMessage.ID.create() const admittedInput = SessionPending.Message.make({ type: "user", @@ -564,6 +576,11 @@ const layer = Layer.effect( }), ), ), + generate: Effect.fn("V2Session.generate")(function* (input) { + const session = yield* result.get(input.sessionID) + const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location))) + return yield* generate.generate(input) + }), command: Effect.fn("V2Session.command")(function* (input) { const session = yield* result.get(input.sessionID) const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location))) @@ -859,10 +876,13 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf } } -const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) { +const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* ( + input: PromptInput.Prompt, + image: Effect.Effect, +) { const fs = yield* FSUtil.Service const files = input.files - ? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 }) + ? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file, image), { concurrency: 8 }) : undefined return Prompt.make({ text: input.text, agents: input.agents, files }) }) @@ -872,6 +892,7 @@ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(function* ( fs: FSUtil.Interface, input: PromptInput.FileAttachment, + image: Effect.Effect, ) { const resolved = input.uri.startsWith("data:") ? { @@ -900,9 +921,15 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct .join("\n"), ) : resolved.bytes - return FileAttachment.create({ - data: Base64.make(Buffer.from(content).toString("base64")), + const normalized = yield* normalizeImageAttachment( + input, + Base64.make(Buffer.from(content).toString("base64")), mime, + image, + ) + return FileAttachment.create({ + data: normalized.data, + mime: normalized.mime, source: resolved.source, name: input.name ?? resolved.name, description: input.description, @@ -910,6 +937,23 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct }) }) +const normalizeImageAttachment = Effect.fn("V2Session.normalizeImageAttachment")(function* ( + input: PromptInput.FileAttachment, + data: Base64, + mime: string, + image: Effect.Effect, +) { + if (!mime.startsWith("image/")) return { data, mime } + const service = yield* image + const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri) + const content = { uri: label, content: data, encoding: "base64" as const, mime } + const normalized = yield* service.normalize(label, content).pipe( + Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)), + Effect.mapError((error) => new AttachmentError({ uri: label, message: error.message })), + ) + return { data: Base64.make(normalized.content), mime: normalized.mime } +}) + const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) { const url = yield* Effect.try({ try: () => new URL(uri), diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index c15919f60b..547d5797bd 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -198,6 +198,7 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly ? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n\n${input.previousSummary}\n` : "Create a new anchored summary from the conversation history.", SUMMARY_TEMPLATE, + "The following is the conversation history:", ...input.context, ].join("\n\n") diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts new file mode 100644 index 0000000000..9bdf354731 --- /dev/null +++ b/packages/core/src/session/context.ts @@ -0,0 +1,124 @@ +export * as SessionContext from "./context" + +import { Context, Effect, Layer } from "effect" +import { AgentV2 } from "../agent" +import { Database } from "../database/database" +import { makeLocationNode } from "../effect/app-node" +import { InstructionDiscovery } from "../instruction-discovery" +import { Instructions } from "../instructions/index" +import { InstructionBuiltIns } from "../instructions/builtins" +import { Location } from "../location" +import { McpInstructions } from "../mcp/instructions" +import { PluginSupervisor } from "../plugin/supervisor" +import { ReferenceInstructions } from "../reference/instructions" +import { SkillInstructions } from "../skill/instructions" +import { AgentNotFoundError } from "./error" +import { SessionHistory } from "./history" +import { InstructionEntry } from "./instruction-entry" +import { SessionMessage } from "./message" +import { SessionRunnerModel } from "./runner/model" +import { SessionSchema } from "./schema" +import { SessionStore } from "./store" + +export interface Selection { + readonly session: SessionSchema.Info + readonly agent: AgentV2.Selection & { readonly info: AgentV2.Info } + readonly instructions: Instructions.Instructions +} + +export interface Loaded { + readonly session: SessionSchema.Info + readonly agent: AgentV2.Selection & { readonly info: AgentV2.Info } + readonly model: SessionRunnerModel.Resolved + readonly initial: string + readonly messages: ReadonlyArray +} + +/** + * Resolves model-request state in two phases: `select` fixes the Session, + * agent, and instruction sources; `load` adds the model and active history for + * that selection. This module does not build or execute the model request. + */ +export interface Interface { + /** Selects the Session, agent, and instruction sources used by subsequent work. */ + readonly select: (sessionID: SessionSchema.ID) => Effect.Effect + /** Resolves the model and active history for that selection. */ + readonly load: (selection: Selection) => Effect.Effect +} + +/** Location-scoped model-context loader for durable Session Steps. */ +export class Service extends Context.Service()("@opencode/v2/SessionContext") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const builtins = yield* InstructionBuiltIns.Service + const db = (yield* Database.Service).db + const discovery = yield* InstructionDiscovery.Service + const entries = yield* InstructionEntry.Service + const location = yield* Location.Service + const mcpInstructions = yield* McpInstructions.Service + const models = yield* SessionRunnerModel.Service + const plugins = yield* PluginSupervisor.Service + const referenceInstructions = yield* ReferenceInstructions.Service + const skillInstructions = yield* SkillInstructions.Service + const store = yield* SessionStore.Service + + const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) { + const session = yield* store.get(sessionID) + if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) + if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) + return yield* Effect.interrupt + + yield* plugins.flush + const agent = yield* agents.select(session.agent) + if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id }) + const instructions = yield* Effect.all( + [ + builtins.load(sessionID), + discovery.load(), + skillInstructions.load(agent), + referenceInstructions.load(), + mcpInstructions.load(agent), + entries.load(sessionID), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.map(Instructions.combine)) + return { session, agent: { ...agent, info: agent.info }, instructions } + }) + + const load = Effect.fn("SessionContext.load")(function* (selection: Selection) { + const model = yield* models.resolve(selection.session) + const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions) + return { + session: selection.session, + agent: selection.agent, + model, + initial: history.initial, + messages: history.entries.map((entry) => entry.message), + } + }) + + return Service.of({ select, load }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + AgentV2.node, + Database.node, + InstructionBuiltIns.node, + InstructionDiscovery.node, + InstructionEntry.node, + Location.node, + McpInstructions.node, + PluginSupervisor.node, + ReferenceInstructions.node, + SessionRunnerModel.node, + SessionStore.node, + SkillInstructions.node, + ], +}) diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts new file mode 100644 index 0000000000..7d7e88e90f --- /dev/null +++ b/packages/core/src/session/generate-node.ts @@ -0,0 +1,69 @@ +export * as SessionGenerateNode from "./generate-node" + +import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai" +import { Effect, Layer } from "effect" +import { Database } from "../database/database" +import { makeLocationNode } from "../effect/app-node" +import { llmClient } from "../effect/app-node-platform" +import { PluginHooks } from "../plugin/hooks" +import { SessionContext } from "./context" +import { SessionGenerate } from "./generate" +import { SessionHistory } from "./history" +import { SessionModelHeaders } from "./model-headers" +import { SessionRunnerModel } from "./runner/model" +import PROMPT_DEFAULT from "./runner/prompt/base.txt" +import { toLLMMessages } from "./runner/to-llm-message" + +const layer = Layer.effect( + SessionGenerate.Service, + Effect.gen(function* () { + const context = yield* SessionContext.Service + const database = yield* Database.Service + const hooks = yield* PluginHooks.Service + const llm = yield* LLMClient.Service + const models = yield* SessionRunnerModel.Service + + return SessionGenerate.Service.of({ + generate: Effect.fn("SessionGenerate.generate")(function* (input) { + const selection = yield* context.select(input.sessionID) + const model = yield* models.resolve(selection.session) + const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions) + const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider + const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id) + ? selection.session.id.slice(4) + : selection.session.id + const contextEvent = yield* hooks.trigger("session", "context", { + sessionID: selection.session.id, + agent: selection.agent.id, + model: model.ref, + system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial] + .filter((part) => part.length > 0) + .map(SystemPart.make), + messages: [ + ...toLLMMessages(history.messages, model.ref, providerMetadataKey), + ...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []), + Message.user(input.prompt), + ], + tools: {}, + }) + return (yield* llm.generate( + LLM.request({ + model: model.model, + http: { headers: SessionModelHeaders.make(selection.session) }, + providerOptions: { openai: { promptCacheKey } }, + system: contextEvent.system, + messages: contextEvent.messages, + tools: [], + toolChoice: "none", + }), + )).text + }), + }) + }), +) + +export const node = makeLocationNode({ + service: SessionGenerate.Service, + layer, + deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient], +}) diff --git a/packages/core/src/session/generate.ts b/packages/core/src/session/generate.ts new file mode 100644 index 0000000000..381c334518 --- /dev/null +++ b/packages/core/src/session/generate.ts @@ -0,0 +1,21 @@ +export * as SessionGenerate from "./generate" + +import type { LLMError } from "@opencode-ai/ai" +import { Context, type Effect } from "effect" +import type { Instructions } from "../instructions" +import type { AgentNotFoundError } from "./error" +import type { SessionRunnerModel } from "./runner/model" +import type { SessionSchema } from "./schema" + +export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | LLMError + +export interface Interface { + /** Generates text from current Session context without mutating the Session. */ + readonly generate: (input: { + readonly sessionID: SessionSchema.ID + readonly prompt: string + }) => Effect.Effect +} + +/** Location-scoped transient generation from Session context. */ +export class Service extends Context.Service()("@opencode/v2/SessionGenerate") {} diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index c41671271f..8cc4ceaed0 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -60,13 +60,17 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) => ), ) -export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { - return yield* Effect.forEach( - yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)), - decodeMessageRow, +const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)) + return yield* Effect.forEach(rows, (row) => + decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))), ) }) +export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return (yield* messageEntries(db, sessionID)).map((entry) => entry.message) +}) + export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, @@ -75,10 +79,7 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun return yield* db .transaction(() => Effect.gen(function* () { - const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)) - const messages = yield* Effect.forEach(rows, (row) => - decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))), - ) + const messages = yield* messageEntries(db, sessionID) const assembled = yield* InstructionState.assemble(db, sessionID, instructions) return { initial: assembled.initial, @@ -89,6 +90,33 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun .pipe(Effect.orDie) }) +export const preview = Effect.fn("SessionHistory.preview")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + instructions: Instructions.Instructions, +) { + const observed = yield* Instructions.read(instructions) + return yield* db + .transaction(() => + Effect.gen(function* () { + const messages = yield* messageEntries(db, sessionID) + // An active assistant may contain an unresolved tool call, so only preview the settled prefix. + const unsettled = messages.findIndex( + (entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined, + ) + const settled = unsettled === -1 ? messages : messages.slice(0, unsettled) + const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed) + const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq) + return { + initial: assembled.initial, + messages: entries.map((entry) => entry.message), + instructionUpdate: assembled.update, + } + }), + ) + .pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error)))) +}) + /** Returns the session's sole user message, or `undefined` once a second one exists. */ export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* ( db: DatabaseService, diff --git a/packages/core/src/session/instruction-state.ts b/packages/core/src/session/instruction-state.ts index 1915bb21fc..1d6127b6de 100644 --- a/packages/core/src/session/instruction-state.ts +++ b/packages/core/src/session/instruction-state.ts @@ -15,27 +15,52 @@ type DatabaseService = Database.Interface["db"] const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data) +export interface Observation extends Instructions.Admission { + readonly sessionID: SessionSchema.ID + readonly initial: boolean + readonly current: Instructions.Values +} + +export const observe = Effect.fn("InstructionState.observe")(function* ( + db: DatabaseService, + instructions: Instructions.Instructions, + sessionID: SessionSchema.ID, +): Effect.fn.Return { + const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], { + concurrency: "unbounded", + }) + const result = yield* observeAgainst(observed, stored?.current_values) + return { + sessionID, + initial: !stored, + ...result, + } +}) + +export const commit = Effect.fn("InstructionState.commit")(function* ( + db: DatabaseService, + events: EventV2.Interface, + observation: Observation, +) { + if (!observation.initial && Object.keys(observation.delta).length === 0) return + yield* events.publish( + SessionEvent.InstructionsUpdated, + { sessionID: observation.sessionID, delta: observation.delta }, + { + // Initial sync establishes the baseline; unlike later deltas it is not chronological history. + ...(observation.initial ? { metadata: { instructions: { initial: true } } } : {}), + commit: () => insertBlobs(db, observation.blobs), + }, + ) +}) + export const prepare = Effect.fn("InstructionState.prepare")(function* ( db: DatabaseService, events: EventV2.Interface, instructions: Instructions.Instructions, sessionID: SessionSchema.ID, ) { - const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], { - concurrency: "unbounded", - }) - const admission = yield* Instructions.diff(observed, stored?.current_values) - if (!stored || Object.keys(admission.delta).length > 0) { - yield* events.publish( - SessionEvent.InstructionsUpdated, - { sessionID, delta: admission.delta }, - { - // Initial sync establishes the baseline; unlike later deltas it is not chronological history. - ...(!stored ? { metadata: { instructions: { initial: true } } } : {}), - commit: () => insertBlobs(db, admission.blobs), - }, - ) - } + yield* commit(db, events, yield* observe(db, instructions, sessionID)) }) export const apply = Effect.fn("InstructionState.apply")(function* ( @@ -97,28 +122,21 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, ) { - const folded = fold(yield* instructionEvents(db, sessionID)) - if (!folded) { + const state = yield* stateFromEvents(db, sessionID) + if (!state) { yield* reset(db, sessionID) return undefined } - const state = { - session_id: sessionID, - epoch_start: folded.epochStart, - through_seq: folded.throughSeq, - initial_values: folded.initial, - current_values: folded.current, - } yield* db .insert(InstructionStateTable) .values(state) .onConflictDoUpdate({ target: InstructionStateTable.session_id, set: { - epoch_start: folded.epochStart, - through_seq: folded.throughSeq, - initial_values: folded.initial, - current_values: folded.current, + epoch_start: state.epoch_start, + through_seq: state.through_seq, + initial_values: state.initial_values, + current_values: state.current_values, }, }) .run() @@ -126,13 +144,12 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* ( return state }) -export const assemble = Effect.fn("InstructionState.assemble")(function* ( +const assembleState = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, instructions: Instructions.Instructions, + state: typeof InstructionStateTable.$inferSelect, ) { - const state = yield* find(db, sessionID) - if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`)) const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start) const updates = rows.map((row) => ({ row, @@ -162,7 +179,49 @@ export const assemble = Effect.fn("InstructionState.assemble")(function* ( }) values = Instructions.applyDelta(values, delta) } - return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result } + return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result, current: values } +}) + +export const assemble = Effect.fn("InstructionState.assemble")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + instructions: Instructions.Instructions, +) { + const state = yield* find(db, sessionID) + if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`)) + const assembled = yield* assembleState(db, sessionID, instructions, state) + return { initial: assembled.initial, updates: assembled.updates } +}) + +export const preview = Effect.fn("InstructionState.preview")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + instructions: Instructions.Instructions, + observed: Instructions.ReadResult, +) { + const state = yield* readState(db, sessionID) + const result = yield* observeAgainst(observed, state?.current_values) + const blobs = new Map( + Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]), + ) + if (!state) { + const values = dereference(result.current, blobs) + return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" } + } + const assembled = yield* assembleState(db, sessionID, instructions, state) + return { + initial: assembled.initial, + updates: assembled.updates, + update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)), + } +}) + +const observeAgainst = Effect.fnUntraced(function* (observed: Instructions.ReadResult, previous?: Instructions.Values) { + const admission = yield* Instructions.diff(observed, previous) + return { + current: Instructions.applyHashDelta(previous ?? {}, admission.delta), + ...admission, + } }) const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { @@ -177,7 +236,26 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { const stored = yield* find(db, sessionID) if (!stored) return yield* rebuild(db, sessionID) - const latest = yield* db + const latest = yield* latestRelevantSequence(db, sessionID) + if (!latest || latest.seq <= stored.through_seq) return stored + return yield* rebuild(db, sessionID) +}) + +const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const stored = yield* find(db, sessionID) + if (!stored) return yield* stateFromEvents(db, sessionID) + const latest = yield* latestRelevantSequence(db, sessionID) + if (!latest || latest.seq <= stored.through_seq) return stored + return yield* stateFromEvents(db, sessionID) +}) + +const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + const folded = fold(yield* instructionEvents(db, sessionID)) + return folded ? foldedState(sessionID, folded) : undefined +}) + +const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return yield* db .select({ seq: EventTable.seq }) .from(EventTable) .where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes))) @@ -185,8 +263,6 @@ const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sess .limit(1) .get() .pipe(Effect.orDie) - if (!latest || latest.seq <= stored.through_seq) return stored - return yield* rebuild(db, sessionID) }) const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly>) { @@ -338,3 +414,13 @@ function fold(rows: ReadonlyArray) { : { epochStart: row.seq, throughSeq: row.seq, initial: current, current } }, undefined) } + +function foldedState(sessionID: SessionSchema.ID, folded: NonNullable>) { + return { + session_id: sessionID, + epoch_start: folded.epochStart, + through_seq: folded.throughSeq, + initial_values: folded.initial, + current_values: folded.current, + } +} diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts new file mode 100644 index 0000000000..936c30591a --- /dev/null +++ b/packages/core/src/session/model-request.ts @@ -0,0 +1,119 @@ +export * as SessionModelRequest from "./model-request" + +import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" +import { SessionError } from "@opencode-ai/schema/session-error" +import { Context, Effect, Layer } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { PluginHooks } from "../plugin/hooks" +import { ToolRegistry } from "../tool/registry" +import { SessionContext } from "./context" +import { SessionModelHeaders } from "./model-headers" +import { MAX_STEPS_PROMPT } from "./runner/max-steps" +import PROMPT_DEFAULT from "./runner/prompt/base.txt" +import { toLLMMessages } from "./runner/to-llm-message" + +type ToolCallResolution = + | { readonly type: "reject"; readonly error: SessionError.Error } + | { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] } + +interface Prepared { + readonly request: LLMRequest + readonly resolveToolCall: (name: string) => ToolCallResolution +} + +interface PrepareInput { + readonly context: SessionContext.Loaded + readonly step: number +} + +/** + * Builds an outbound model request and captures the tool-call capability that + * must remain paired with it. It does not execute the request or mutate + * Session state. + */ +export interface Interface { + /** Builds one outbound model request and its matching tool-call capability. */ + readonly prepare: (input: PrepareInput) => Effect.Effect +} + +/** Location-scoped outbound model-request preparation. */ +export class Service extends Context.Service()("@opencode/v2/SessionModelRequest") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + const registry = yield* ToolRegistry.Service + + const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { + const session = input.context.session + const agent = input.context.agent + const resolved = input.context.model + const model = resolved.model + const providerMetadataKey = model.route.providerMetadataKey ?? model.provider + const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps + const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions) + const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id + const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial] + .filter((part) => part.length > 0) + .map(SystemPart.make) + const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) + const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history + const toolDefinitions = executableTools?.definitions ?? [] + const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool])) + // Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit. + const contextEvent = yield* hooks.trigger("session", "context", { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + system, + messages, + tools: Object.fromEntries( + toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), + ), + }) + const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => { + const registered = toolsByName.get(name) + return registered + ? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })] + : [] + }) + const request = LLM.request({ + model, + http: { + headers: SessionModelHeaders.make(session), + }, + providerOptions: { openai: { promptCacheKey } }, + system: contextEvent.system, + messages: contextEvent.messages, + tools: hookedTools, + toolChoice: stepLimitReached ? "none" : undefined, + }) + const resolveToolCall = (name: string): ToolCallResolution => { + if (!executableTools) + return { + type: "reject", + error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" }, + } + if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name)) + return { + type: "reject", + error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` }, + } + return { type: "settle", settle: executableTools.settle } + } + return { + request, + resolveToolCall, + } + }) + + return Service.of({ prepare }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [PluginHooks.node, ToolRegistry.node], +}) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3a7ab625bc..63dc59dbdd 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,59 +1,33 @@ export * as SessionRunnerLLM from "./llm" -import { - LLM, - LLMClient, - LLMError, - LLMEvent, - Message, - SystemPart, - isContextOverflowFailure, - type ProviderErrorEvent, -} from "@opencode-ai/ai" -import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai" +import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai" import { SessionError } from "@opencode-ai/schema/session-error" import { Money } from "@opencode-ai/schema/money" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" -import { AgentV2 } from "../../agent" import { Database } from "../../database/database" import { EventV2 } from "../../event" -import { Location } from "../../location" import { ModelV2 } from "../../model" import { PermissionV2 } from "../../permission" -import { Instructions } from "../../instructions/index" -import { InstructionBuiltIns } from "../../instructions/builtins" -import { InstructionDiscovery } from "../../instruction-discovery" -import { SkillGuidance } from "../../skill/guidance" -import { ReferenceGuidance } from "../../reference/guidance" -import { McpGuidance } from "../../mcp/guidance" -import { InstructionEntry } from "../instruction-entry" import { QuestionTool } from "../../tool/question" -import { ToolRegistry } from "../../tool/registry" import { ToolOutputStore } from "../../tool-output-store" import { InstructionState } from "../instruction-state" import { SessionCompaction } from "../compaction" +import { SessionContext } from "../context" import { SessionEvent } from "../event" -import { SessionHistory } from "../history" import { SessionPending } from "../pending" +import { SessionModelRequest } from "../model-request" import { SessionMessage } from "../message" import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { SessionTitle } from "../title" import { Service } from "./index" -import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" -import { toLLMMessages } from "./to-llm-message" -import { MAX_STEPS_PROMPT } from "./max-steps" -import { SessionRunnerSystemPrompt } from "./system-prompt" import { Snapshot } from "../../snapshot" import { makeLocationNode } from "../../effect/app-node" import { llmClient } from "../../effect/app-node-platform" -import { AgentNotFoundError, StepFailedError } from "../error" +import { StepFailedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" -import { PluginSupervisor } from "../../plugin/supervisor" -import { PluginHooks } from "../../plugin/hooks" -import { SessionModelHeaders } from "../model-headers" type StepTokens = { readonly input: number @@ -79,33 +53,18 @@ export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) { ) } -/** - * Runs one durable coding-agent Session until it settles. Each step reloads projected history, - * materializes tools, makes one model request, and settles local calls before continuation. - */ - const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service const llm = yield* LLMClient.Service - const agents = yield* AgentV2.Service - const tools = yield* ToolRegistry.Service - const hooks = yield* PluginHooks.Service - const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service - const location = yield* Location.Service - const builtins = yield* InstructionBuiltIns.Service - const discovery = yield* InstructionDiscovery.Service - const skillGuidance = yield* SkillGuidance.Service - const referenceGuidance = yield* ReferenceGuidance.Service - const mcpGuidance = yield* McpGuidance.Service - const entries = yield* InstructionEntry.Service + const context = yield* SessionContext.Service + const modelRequests = yield* SessionModelRequest.Service const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service - const plugins = yield* PluginSupervisor.Service // Title generation is a side effect of the first step; it must not delay step continuation. // Tracked per process so repeated wakes before the second user message arrives don't // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history. @@ -116,9 +75,6 @@ const layer = Layer.effect( if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) return session }) - const isCurrentLocation = (session: SessionSchema.Info) => - session.location.directory === location.directory && session.location.workspaceID === location.workspaceID - const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* ( sessionID: SessionSchema.ID, ) { @@ -145,19 +101,6 @@ const layer = Layer.effect( (reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError), ) - const loadInstructions = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) => - Effect.all( - [ - builtins.load(sessionID), - discovery.load(), - skillGuidance.load(agent), - referenceGuidance.load(), - mcpGuidance.load(agent), - entries.load(sessionID), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.map(Instructions.combine)) - const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* ( sessionID: SessionSchema.ID, promotion: SessionPending.Delivery | undefined, @@ -165,80 +108,35 @@ const layer = Layer.effect( recoverOverflow?: typeof compaction.compact, assistantMessageID?: SessionMessage.ID, ) { - const session = yield* getSession(sessionID) - if (!isCurrentLocation(session)) return yield* Effect.interrupt - yield* plugins.flush - const agent = yield* agents.select(session.agent) - const agentInfo = agent.info - if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id }) + const selected = yield* context.select(sessionID) // Establish what the model knows before admitting what the user said, so // a blocked first step leaves pending inputs untouched. - const instructions = yield* loadInstructions(agent, session.id) - yield* InstructionState.prepare(db, events, instructions, session.id) + yield* InstructionState.prepare(db, events, selected.instructions, selected.session.id) let currentStep = step if (promotion) { let promoted = 0 - if (promotion === "steer") promoted = yield* SessionPending.promoteSteers(db, events, session.id) + if (promotion === "steer") promoted = yield* SessionPending.promoteSteers(db, events, selected.session.id) if (promotion === "queue") { - promoted += Number(yield* SessionPending.promoteNextQueued(db, events, session.id)) - promoted += yield* SessionPending.promoteSteers(db, events, session.id) + promoted += Number(yield* SessionPending.promoteNextQueued(db, events, selected.session.id)) + promoted += yield* SessionPending.promoteSteers(db, events, selected.session.id) } if (promoted > 0) currentStep = 1 } - const resolved = yield* models.resolve(session) + const loaded = yield* context.load(selected) + const session = loaded.session + const agent = loaded.agent + const resolved = loaded.model const model = resolved.model - const providerMetadataKey = model.route.providerMetadataKey ?? model.provider - const history = yield* SessionHistory.entriesForRunner(db, session.id, instructions) - const context = history.entries.map((entry) => entry.message) - const compactionInput = { session, messages: context, model } + const compactionInput = { session, messages: loaded.messages, model } if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { const compacted = yield* compaction.compact(compactionInput) if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const return yield* new StepFailedError({ error: compacted.error }) } - const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps - const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agentInfo.permissions) - const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id - const request = LLM.request({ - model, - http: { - headers: SessionModelHeaders.make(session), - }, - providerOptions: { openai: { promptCacheKey } }, - system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), history.initial] - .filter((part): part is string => part !== undefined && part.length > 0) - .map(SystemPart.make), - messages: [ - ...toLLMMessages(context, resolved.ref, providerMetadataKey), - ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []), - ], - tools: toolMaterialization?.definitions ?? [], - toolChoice: isLastStep ? "none" : undefined, + const prepared = yield* modelRequests.prepare({ + context: loaded, + step: currentStep, }) - const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) - const requestEvent: AIHooks["request"] = { - sessionID: session.id, - agent: agent.id, - model: resolved.ref, - system: [...request.system], - messages: [...request.messages], - tools: Object.fromEntries( - request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), - ), - } - // Plugins may reshape the draft but cannot advertise tools excluded by - // permissions, registration state, or the selected agent's step limit. - yield* hooks.trigger("ai", "request", requestEvent) - const hookedRequest = LLM.updateRequest(request, { - system: requestEvent.system, - messages: requestEvent.messages, - tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => { - const registered = availableTools.get(name) - if (!registered) return [] - return [{ ...registered, description: tool.description, inputSchema: tool.input }] - }), - }) - const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name)) const toolFibers = yield* FiberSet.make() const ownedToolFibers: Array> = [] let needsContinuation = false @@ -249,7 +147,7 @@ const layer = Layer.effect( // The selected catalog identity, not model.id: route-level ids are provider API // model ids (for example gpt-5.5-fast resolves to api id gpt-5.5). model: resolved.ref, - providerMetadataKey, + providerMetadataKey: model.route.providerMetadataKey ?? model.provider, snapshot: startSnapshot, assistantMessageID, }) @@ -259,7 +157,7 @@ const layer = Layer.effect( const serialized = (effect: Effect.Effect) => publication.withPermit(effect) const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error)) let overflowFailure: ProviderErrorEvent | undefined - const providerStream = llm.stream(hookedRequest).pipe( + const providerStream = llm.stream(prepared.request).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return @@ -271,15 +169,9 @@ const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return - if (!toolMaterialization || (availableTools.has(event.name) && !advertisedTools.has(event.name))) { - yield* serialized( - publisher.failUnsettledTools({ - type: "tool.execution", - message: toolMaterialization - ? `Tool is not available for this request: ${event.name}` - : "Tools are disabled after the maximum agent steps", - }), - ) + const tool = prepared.resolveToolCall(event.name) + if (tool.type === "reject") { + yield* serialized(publisher.failUnsettledTools(tool.error)) return } needsContinuation = true @@ -287,7 +179,7 @@ const layer = Layer.effect( ownedToolFibers.push( yield* Effect.uninterruptibleMask((restore) => restore( - toolMaterialization.settle({ + tool.settle({ sessionID: session.id, agent: agent.id, messageID: assistantMessageID, @@ -366,8 +258,7 @@ const layer = Layer.effect( recoverOverflow && !publisher.hasRetryEvidence() && isContextOverflowFailure(overflowFailure ?? streamFailure) && - (yield* restore(recoverOverflow({ session, messages: context, model }))).status === - "completed" + (yield* restore(recoverOverflow({ session, messages: loaded.messages, model }))).status === "completed" ) return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const @@ -378,11 +269,7 @@ const layer = Layer.effect( const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined if (llmFailure && !publisher.hasProviderError()) { const error = toSessionError(llmFailure) - if ( - SessionRunnerRetry.isRetryable(llmFailure) && - !publisher.hasRetryEvidence() && - (agentInfo.steps === undefined || currentStep < agentInfo.steps) - ) { + if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) { return yield* new SessionRunnerRetry.RetryableFailure({ cause: llmFailure, assistantMessageID: yield* publisher.startAssistant(), @@ -498,7 +385,7 @@ const layer = Layer.effect( Effect.tapError((error) => error instanceof SessionRunnerRetry.RetryableFailure ? Effect.sync(() => { - currentStep = error.step + 1 + currentStep = error.step assistantMessageID = error.assistantMessageID currentPromotion = undefined }) @@ -612,22 +499,12 @@ export const node = makeLocationNode({ deps: [ EventV2.node, llmClient, - AgentV2.node, - ToolRegistry.node, - PluginHooks.node, - SessionRunnerModel.node, + SessionContext.node, + SessionModelRequest.node, SessionStore.node, - Location.node, - InstructionBuiltIns.node, - InstructionDiscovery.node, - SkillGuidance.node, - ReferenceGuidance.node, - McpGuidance.node, - InstructionEntry.node, SessionCompaction.node, SessionTitle.node, Snapshot.node, Database.node, - PluginSupervisor.node, ], }) diff --git a/packages/core/src/session/runner/prompt/default.txt b/packages/core/src/session/runner/prompt/base.txt similarity index 100% rename from packages/core/src/session/runner/prompt/default.txt rename to packages/core/src/session/runner/prompt/base.txt diff --git a/packages/core/src/session/runner/prompt/beast.txt b/packages/core/src/session/runner/prompt/beast.txt deleted file mode 100644 index a0a927a6d0..0000000000 --- a/packages/core/src/session/runner/prompt/beast.txt +++ /dev/null @@ -1,142 +0,0 @@ -You are opencode, an agent - please keep going until the userโ€™s query is completely resolved, before ending your turn and yielding back to the user. - -Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough. - -You MUST iterate and keep going until the problem is solved. - -You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me. - -Only terminate your turn when you are sure that the problem is solved. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn. - -THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH. - -You must use the webfetch tool to recursively gather all information from URL's provided to you by the user, as well as any links you find in the content of those pages. - -Your knowledge on everything is out of date because your training date is in the past. - -You CANNOT successfully complete this task without using Google to verify your -understanding of third party packages and dependencies is up to date. You must use the webfetch tool to search google for how to properly use libraries, packages, frameworks, dependencies, etc. every single time you install or implement one. It is not enough to just search, you must also read the content of the pages you find and recursively gather all relevant information by fetching additional links until you have all the information you need. - -Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why. - -If the user request is "resume" or "continue" or "try again", check the previous conversation history to identify the next incomplete step. Continue from that step, and do not hand back control to the user until the request is complete. Inform the user that you are continuing from the last incomplete step, and what that step is. - -Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking tool if available. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided. - -You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully. - -You MUST keep working until the problem is completely solved. Do not end your turn until you have completed the necessary work and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it. - -You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input. - -# Workflow -1. Fetch any URL's provided by the user using the `webfetch` tool. -2. Understand the problem deeply. Carefully read the issue and think critically about what is required. Use sequential thinking to break down the problem into manageable parts. Consider the following: - - What is the expected behavior? - - What are the edge cases? - - What are the potential pitfalls? - - How does this fit into the larger context of the codebase? - - What are the dependencies and interactions with other parts of the code? -3. Investigate the codebase. Explore relevant files, search for key functions, and gather context. -4. Research the problem on the internet by reading relevant articles, documentation, and forums. -5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. -6. Implement the fix incrementally. Make small, testable code changes. -7. Debug as needed. Use debugging techniques to isolate and resolve issues. -8. Test frequently. Run tests after each change to verify correctness. -9. Iterate until the root cause is fixed and all tests pass. -10. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete. - -Refer to the detailed sections below for more information on each step. - -## 1. Fetch Provided URLs -- If the user provides a URL, use the `webfetch` tool to retrieve the content of the provided URL. -- After fetching, review the content returned by the webfetch tool. -- If you find any additional URLs or links that are relevant, use the `webfetch` tool again to retrieve those links. -- Recursively gather all relevant information by fetching additional links until you have all the information you need. - -## 2. Deeply Understand the Problem -Carefully read the issue and think hard about a plan to solve it before coding. - -## 3. Codebase Investigation -- Explore relevant files and directories. -- Search for key functions, classes, or variables related to the issue. -- Read and understand relevant code snippets. -- Identify the root cause of the problem. -- Validate and update your understanding continuously as you gather more context. - -## 4. Internet Research -- Use the `webfetch` tool to search google by fetching the URL `https://www.google.com/search?q=your+search+query`. -- After fetching, review the content returned by the fetch tool. -- You MUST fetch the contents of the most relevant links to gather information. Do not rely on the summary that you find in the search results. -- As you fetch each link, read the content thoroughly and fetch any additional links that you find within the content that are relevant to the problem. -- Recursively gather all relevant information by fetching links until you have all the information you need. - -## 5. Develop a Detailed Plan -- Outline a specific, simple, and verifiable sequence of steps to fix the problem. -- Continue through the planned steps instead of ending your turn and asking the user what they want to do next. - -## 6. Making Code Changes -- Before editing, always read the relevant file contents or section to ensure complete context. -- Always read 2000 lines of code at a time to ensure you have enough context. -- If a patch is not applied correctly, attempt to reapply it. -- Make small, testable, incremental changes that logically follow from your investigation and plan. -- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it. - -## 7. Debugging -- Make code changes only if you have high confidence they can solve the problem -- When debugging, try to determine the root cause rather than addressing symptoms -- Debug for as long as needed to identify the root cause and identify a fix -- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening -- To test hypotheses, you can also add test statements or functions -- Revisit your assumptions if unexpected behavior occurs. - - -# Communication Guidelines -Always communicate clearly and concisely in a casual, friendly yet professional tone. - -"Let me fetch the URL you provided to gather more information." -"Ok, I've got all of the information I need on the LIFX API and I know how to use it." -"Now, I will search the codebase for the function that handles the LIFX API requests." -"I need to update several files here - stand by" -"OK! Now let's run the tests to make sure everything is working correctly." -"Whelp - I see we have some problems. Let's fix those up." - - -- Respond with clear, direct answers. Use bullet points and code blocks for structure. - Avoid unnecessary explanations, repetition, and filler. -- Always write code directly to the correct files. -- Do not display code to the user unless they specifically ask for it. -- Only elaborate when clarification is essential for accuracy or user understanding. - -# Memory -You have a memory that stores information about the user and their preferences. This memory is used to provide a more personalized experience. You can access and update this memory as needed. The memory is stored in a file called `.github/instructions/memory.instruction.md`. If the file is empty, you'll need to create it. - -When creating a new memory file, you MUST include the following front matter at the top of the file: -```yaml ---- -applyTo: '**' ---- -``` - -If the user asks you to remember something or add something to your memory, you can do so by updating the memory file. - -# Reading Files and Folders - -**Always check if you have already read a file, folder, or workspace structure before reading it again.** - -- If you have already read the content and it has not changed, do NOT re-read it. -- Only re-read files or folders if: - - You suspect the content has changed since your last read. - - You have made edits to the file or folder. - - You encounter an error that suggests the context may be stale or incomplete. -- Use your internal memory and previous context to avoid redundant reads. -- This will save time, reduce unnecessary operations, and make your workflow more efficient. - -# Writing Prompts -If you are asked to write a prompt, you should always generate the prompt in markdown format. - -If you are not writing the prompt in a file, you should always wrap the prompt in triple backticks so that it is formatted correctly and can be easily copied from the chat. - -# Git -If the user tells you to stage and commit, you may do so. - -You are NEVER allowed to stage and commit files automatically. diff --git a/packages/core/src/session/runner/system-prompt.ts b/packages/core/src/session/runner/system-prompt.ts deleted file mode 100644 index e8b97d83d0..0000000000 --- a/packages/core/src/session/runner/system-prompt.ts +++ /dev/null @@ -1,30 +0,0 @@ -export * as SessionRunnerSystemPrompt from "./system-prompt" - -import type { Model } from "@opencode-ai/ai" - -import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" -import PROMPT_BEAST from "./prompt/beast.txt" -import PROMPT_CODEX from "./prompt/codex.txt" -import PROMPT_DEFAULT from "./prompt/default.txt" -import PROMPT_GEMINI from "./prompt/gemini.txt" -import PROMPT_GPT from "./prompt/gpt.txt" -import PROMPT_KIMI from "./prompt/kimi.txt" -import PROMPT_TRINITY from "./prompt/trinity.txt" - -export function provider(model: Model) { - const id = model.id.toLowerCase() - if (id.includes("gpt-4") || id.includes("o1") || id.includes("o3")) return normalize(PROMPT_BEAST) - if (id.includes("gpt")) { - if (id.includes("codex")) return normalize(PROMPT_CODEX) - return normalize(PROMPT_GPT) - } - if (id.includes("gemini-")) return normalize(PROMPT_GEMINI) - if (id.includes("claude")) return normalize(PROMPT_ANTHROPIC) - if (id.includes("trinity")) return normalize(PROMPT_TRINITY) - if (id.includes("kimi")) return normalize(PROMPT_KIMI) - return normalize(PROMPT_DEFAULT) -} - -function normalize(prompt: string) { - return prompt.replaceAll("\r\n", "\n") -} diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/instructions.ts similarity index 95% rename from packages/core/src/skill/guidance.ts rename to packages/core/src/skill/instructions.ts index 692d92a3a5..1da106205d 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/instructions.ts @@ -1,4 +1,4 @@ -export * as SkillGuidance from "./guidance" +export * as SkillInstructions from "./instructions" import { makeLocationNode } from "../effect/app-node" import { Context, Effect, Layer, Schema } from "effect" @@ -60,7 +60,7 @@ export interface Interface { readonly load: (agent: AgentV2.Selection) => Effect.Effect } -export class Service extends Context.Service()("@opencode/v2/SkillGuidance") {} +export class Service extends Context.Service()("@opencode/v2/SkillInstructions") {} const layer = Layer.effect( Service, @@ -68,7 +68,7 @@ const layer = Layer.effect( const skills = yield* SkillV2.Service return Service.of({ - load: Effect.fn("SkillGuidance.load")(function* (selection) { + load: Effect.fn("SkillInstructions.load")(function* (selection) { const agent = selection.info if (!agent) return Instructions.empty const permitted = SkillV2.available(yield* skills.list(), agent) diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 88e9984d4a..324aac83cb 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -1,14 +1,14 @@ export * as State from "./state" -import { Context, Effect, Scope, Semaphore } from "effect" +import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect" /** * A replayable transform applied to a draft during reload. * * Domain drafts expose readable and writable state while preserving concise - * plugin/config code. Transforms may perform Effects before returning. + * plugin/config code. Transforms synchronously rebuild derived state. */ -type TransformCallback = (draft: DraftApi) => Effect.Effect | void +type TransformCallback = (draft: DraftApi) => void export type MakeDraft = (state: State) => DraftApi export interface Registration { @@ -34,6 +34,7 @@ type Batch = { const CurrentBatch = Context.Reference("@opencode/State/CurrentBatch", { defaultValue: () => undefined, }) +const reloadDebounce = 500 export function batch(effect: Effect.Effect) { return Effect.gen(function* () { @@ -73,6 +74,10 @@ export interface Interface extends Transformable { export function create(options: Options): Interface { let state = options.initial() let transforms: { run: TransformCallback }[] = [] + let generation = 0 + let requestedAt = 0 + let running = false + let waiters: { generation: number; done: Deferred.Deferred }[] = [] const semaphore = Semaphore.makeUnsafe(1) const commit = Effect.fn("State.commit")(function* (next: State) { @@ -82,9 +87,8 @@ export function create(options: Options): Inte }) const apply = (transform: TransformCallback, draft: DraftApi) => - Effect.suspend(() => { - const result = transform(draft) - return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void + Effect.sync(() => { + transform(draft) }) const materialize = Effect.fnUntraced(function* () { @@ -97,7 +101,39 @@ export function create(options: Options): Inte yield* commit(next) }) - const reload = () => semaphore.withPermit(materialize()) + const materializeReload = () => semaphore.withPermit(materialize()) + + const rebuild = (): Effect.Effect => + Effect.gen(function* () { + const clock = yield* Clock.Clock + const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe() + if (remaining > 0) yield* Effect.sleep(remaining) + if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild() + + const target = generation + const exit = yield* materializeReload().pipe(Effect.exit) + const completed = waiters.filter((waiter) => waiter.generation <= target) + waiters = waiters.filter((waiter) => waiter.generation > target) + yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), { + concurrency: "unbounded", + discard: true, + }) + if (generation > target) return yield* rebuild() + running = false + }) + + const reload = Effect.fnUntraced(function* () { + const done = Deferred.makeUnsafe() + const clock = yield* Clock.Clock + generation++ + requestedAt = clock.currentTimeMillisUnsafe() + waiters.push({ generation, done }) + if (!running) { + running = true + yield* rebuild().pipe(Effect.forkDetach) + } + return yield* Deferred.await(done) + }) const result: Interface = { get: () => state, @@ -117,7 +153,7 @@ export function create(options: Options): Inte return Effect.gen(function* () { const batch = yield* CurrentBatch if (batch?.active) { - batch.reloads.add(reload) + batch.reloads.add(materializeReload) return } yield* materialize() @@ -132,8 +168,8 @@ export function create(options: Options): Inte ) yield* Scope.addFinalizer(scope, dispose) const batch = yield* CurrentBatch - if (batch?.active) batch.reloads.add(reload) - else yield* reload() + if (batch?.active) batch.reloads.add(materializeReload) + else yield* materializeReload() return { dispose } }), ) diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 08c9fabefe..8e74bd5bb3 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -4,7 +4,7 @@ This folder owns Core's one local tool representation, process and Location regi ## Representations -- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Shipped built-ins and plugin tools use the same type. +- `tool.ts` defines the structural canonical `Tool.make({ description, input, output, execute, toModelOutput })` declaration. Shipped built-ins and plugin tools use the same type. - `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers. - `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding. @@ -12,7 +12,7 @@ Do not add a second executable entry type, registry-owned executor, authorizatio ## Construction -Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details. +Tool schemas and projection use `input` and `output` terminology. A tool value carries its schemas, executor, projection, and optional catalog permission directly so separately loaded plugin package instances can exchange it structurally. Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context: diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 57e8c0ef2a..6925b87793 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -196,7 +196,7 @@ export const Plugin = { ) .pipe(Effect.orDie) - yield* ctx.ai.hook("request", (event) => + yield* ctx.session.hook("context", (event) => Effect.sync(() => { const usePatch = event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt") diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 899e464929..8f73f7e90c 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -6,7 +6,6 @@ import { ToolFailure } from "@opencode-ai/ai" import { Effect, Schema } from "effect" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" -import { Image } from "../image" import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" @@ -35,7 +34,6 @@ export const Plugin = { effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) { const reader = yield* ReadToolFileSystem.Service const mutation = yield* LocationMutation.Service - const image = yield* Image.Service const permission = yield* PermissionV2.Service const sessionInstructions = yield* SessionInstructions.Service const fs = yield* FSUtil.Service @@ -50,6 +48,12 @@ export const Plugin = { "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", input: Input, output: Output, + structured: Schema.toEncoded(Output), + // Image base64 reaches the model through content items (normalized generically + // at tool settlement); persisting a second copy in structured would store the + // original unresized bytes in the message row. + toStructuredOutput: ({ output }) => + "encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output, toModelOutput: ({ input, output }) => { if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return [] @@ -117,21 +121,14 @@ export const Plugin = { Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void), ) - if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { - return yield* image - .normalize(resource, { ...content, encoding: "base64" }) - .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) - } - if ("encoding" in content && content.encoding === "base64") + if ("encoding" in content && content.encoding === "base64" && !SUPPORTED_IMAGE_MIMES.has(content.mime)) return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return content }).pipe( Effect.mapError((error) => { const message = error instanceof ReadToolFileSystem.BinaryFileError || - error instanceof ReadToolFileSystem.MediaIngestLimitError || - error instanceof Image.DecodeError || - error instanceof Image.SizeError + error instanceof ReadToolFileSystem.MediaIngestLimitError ? error.message : `Unable to read ${input.path}` return new ToolFailure({ message, error }) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 98120fc2d9..8d95a95eaf 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -3,6 +3,7 @@ export * as ToolRegistry from "./registry" import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai" import { Context, Effect, Layer, Scope } from "effect" import type { AgentV2 } from "../agent" +import { Image } from "../image" import { PermissionV2 } from "../permission" import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" @@ -57,6 +58,40 @@ const registryLayer = Layer.effect( Effect.gen(function* () { const resources = yield* ToolOutputStore.Service const toolHooks = yield* ToolHooks.Service + const image = yield* Image.Service + + type NormalizedItem = ToolOutput["content"][number] | "decode" | "size" + const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) { + const normalized = yield* Effect.forEach(content, (item): Effect.Effect => { + if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item) + // RFC 2397 permits parameters between the mime and ";base64". + const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1] + if (base64 === undefined) return Effect.succeed(item) + const resource = item.name ?? `${item.mime} tool output` + return image + .normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }) + .pipe( + Effect.map((result) => ({ + ...item, + uri: `data:${result.mime};base64,${result.content}`, + mime: result.mime, + })), + Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)), + Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)), + Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)), + ) + }) + const note = (reason: "decode" | "size", text: string) => { + const count = normalized.filter((item) => item === reason).length + if (count === 0) return [] + return [{ type: "text" as const, text: `[${count} image${count === 1 ? "" : "s"} omitted: ${text}]` }] + } + return [ + ...normalized.filter((item) => typeof item !== "string"), + ...note("decode", "could not be decoded."), + ...note("size", "could not be resized below the image size limit."), + ] + }) type Registration = { readonly tool: AnyTool readonly name: string @@ -84,10 +119,11 @@ const registryLayer = Layer.effect( agent: input.agent, messageID: input.messageID, callID: input.call.id, - progress: (update) => - input.progress?.({ - structured: update.structured, - content: (update.content ?? []).map((part) => + progress: (update) => { + const progress = input.progress + if (!progress) return Effect.void + return normalizeImages( + (update.content ?? []).map((part) => part.type === "text" ? { type: "text" as const, text: part.text } : { @@ -97,7 +133,8 @@ const registryLayer = Layer.effect( name: part.name, }, ), - }) ?? Effect.void, + ).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content }))) + }, }, ).pipe( Effect.map((output) => ({ output })), @@ -115,7 +152,7 @@ const registryLayer = Layer.effect( const bounded = yield* resources.bound({ sessionID: input.sessionID, callID: input.call.id, - output: pending.output, + output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) }, }) const result = ToolOutput.toResultValue(bounded.output) settlement = @@ -232,11 +269,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { export const node = makeLocationNode({ service: Service, layer, - deps: [ToolOutputStore.node, ToolHooks.node], + deps: [ToolOutputStore.node, ToolHooks.node, Image.node], }) export const toolsNode = makeLocationNode({ service: Tools.Service, layer, - deps: [ToolOutputStore.node, ToolHooks.node], + deps: [ToolOutputStore.node, ToolHooks.node, Image.node], }) diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 0cc2de9db5..1e459a674e 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -4,6 +4,7 @@ import { ToolFailure } from "@opencode-ai/ai" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { AgentV2 } from "../agent" +import { Config } from "../config" import { PluginRuntime } from "../plugin/runtime" import { PermissionV2 } from "../permission" import { SessionSchema } from "../session/schema" @@ -43,6 +44,7 @@ export const Plugin = { effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const agents = yield* AgentV2.Service + const config = yield* Config.Service const permission = yield* PermissionV2.Service const scope = yield* Scope.Scope @@ -123,6 +125,23 @@ export const Plugin = { (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), ), ) + let current = parent + let depth = 0 + while (current.parentID) { + depth++ + current = yield* runtime.session + .get(current.parentID) + .pipe( + Effect.mapError( + (error) => new ToolFailure({ message: `Parent session not found: ${current.parentID}`, error }), + ), + ) + } + const limit = Config.latest(yield* config.entries(), "experimental")?.subagent_depth ?? 1 + if (depth >= limit) + return yield* new ToolFailure({ + message: `Subagent depth limit reached (${limit}). Increase "experimental.subagent_depth" to allow nested subagents.`, + }) const agent = yield* agents.resolve(input.agent) if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) if (agent.mode === "primary") @@ -216,7 +235,7 @@ export const Plugin = { ) .pipe(Effect.orDie) - yield* ctx.ai.hook("request", (event) => + yield* ctx.session.hook("context", (event) => Effect.gen(function* () { const tool = event.tools[name] if (!tool) return diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 6e6cee5c5e..e3ecf7324f 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -175,6 +175,9 @@ export const Info = Schema.Struct({ primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Tools that should only be available to primary agents.", }), + subagent_depth: Schema.optional(NonNegativeInt).annotate({ + description: "Maximum subagent nesting depth. Defaults to 1.", + }), continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({ description: "Continue the agent loop when a tool call is denied", }), diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 15b464f776..63348e5480 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -77,6 +77,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) { commands: commands(info.command), instructions: info.instructions, references: info.references ?? info.reference, + experimental: experimental(info), plugins: info.plugin?.map((plugin) => typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, ), @@ -84,6 +85,31 @@ export function migrate(info: typeof ConfigV1.Info.Type) { } } +function experimental(info: typeof ConfigV1.Info.Type) { + const policies = [ + ...(info.enabled_providers === undefined + ? [] + : [ + { action: "provider.use" as const, resource: "*", effect: "deny" as const }, + ...info.enabled_providers.map((resource) => ({ + action: "provider.use" as const, + resource, + effect: "allow" as const, + })), + ]), + ...(info.disabled_providers ?? []).map((resource) => ({ + action: "provider.use" as const, + resource, + effect: "deny" as const, + })), + ] + if (info.experimental?.subagent_depth === undefined && !policies.length) return + return { + subagent_depth: info.experimental?.subagent_depth, + policies: policies.length ? policies : undefined, + } +} + function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly>) { const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries( tools ?? {}, diff --git a/packages/core/src/wellknown.ts b/packages/core/src/wellknown.ts new file mode 100644 index 0000000000..73eea6dcfb --- /dev/null +++ b/packages/core/src/wellknown.ts @@ -0,0 +1,194 @@ +export * as WellKnown from "./wellknown" + +import { Integration } from "@opencode-ai/schema/integration" +import { Context, Effect, Layer, Ref, Schema, Semaphore } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { isDeepStrictEqual } from "node:util" +import { makeGlobalNode } from "./effect/app-node" +import { httpClient } from "./effect/app-node-platform" +import { EventV2 } from "./event" +import { KV } from "./kv" + +export interface Auth extends Schema.Schema.Type {} +export const Auth = Schema.Struct({ + command: Schema.Array(Schema.String), + env: Schema.String, +}).annotate({ identifier: "WellKnown.Auth" }) + +export interface RemoteConfig extends Schema.Schema.Type {} +export const RemoteConfig = Schema.Struct({ + url: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}).annotate({ identifier: "WellKnown.RemoteConfig" }) + +export interface Config extends Schema.Schema.Type {} +export const Config = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: "WellKnown.Config" }) + +export interface Manifest extends Schema.Schema.Type {} +export const Manifest = Schema.Struct({ + auth: Schema.optional(Auth), + config: Schema.optional(Schema.NullOr(Config)), + remote_config: Schema.optional(RemoteConfig), +}).annotate({ identifier: "WellKnown.Manifest" }) + +export interface ResolveInput { + readonly origin: string + readonly variables?: Readonly> +} + +export interface Entry { + readonly origin: string + readonly integrationID: Integration.ID + readonly manifest: Manifest +} + +export interface Interface { + readonly entries: () => Effect.Effect + readonly snapshot: () => readonly Entry[] + readonly refresh: () => Effect.Effect + readonly add: (origin: string) => Effect.Effect + readonly remove: (origin: string) => Effect.Effect + readonly resolve: (entry: Entry, variables: Readonly>) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/WellKnown") {} + +export const Event = { + Updated: EventV2.ephemeral({ type: "wellknown.updated", schema: {} }), +} + +export const inspect = Effect.fn("WellKnown.inspect")(function* (origin: string) { + const url = `${origin.replace(/\/+$/, "")}/.well-known/opencode` + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + return yield* http.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(Manifest)), + Effect.mapError((cause) => new Error(`Failed to load wellknown manifest from ${url}`, { cause })), + ) +}) + +export const resolve = Effect.fn("WellKnown.resolve")(function* (input: ResolveInput) { + const manifest = yield* inspect(input.origin) + return yield* resolveEntry( + { origin: input.origin, integrationID: Integration.ID.make(input.origin.replace(/\/+$/, "")), manifest }, + input.variables ?? {}, + ) +}) + +const resolveEntry = Effect.fnUntraced(function* (entry: Entry, variables: Readonly>) { + const configs = entry.manifest.config ? [entry.manifest.config] : [] + if (!entry.manifest.remote_config) return configs + + const substitute = (value: string) => + value.replace(/\{env:([^}]+)\}/g, (_, name: string) => variables[name] ?? process.env[name] ?? "") + const url = substitute(entry.manifest.remote_config.url) + const headers = Object.fromEntries( + Object.entries(entry.manifest.remote_config.headers ?? {}).map(([key, value]) => [key, substitute(value)]), + ) + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + const remote = yield* http + .execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers))) + .pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(Config)), + Effect.mapError((cause) => new Error(`Failed to load wellknown remote config from ${url}`, { cause })), + ) + if (Schema.is(Config)(remote.config)) return [...configs, remote.config] + return [...configs, remote] +}) + +const sourcesKey = "wellknown:sources" +const Sources = Schema.Array(Schema.String) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const kv = yield* KV.Service + const events = yield* EventV2.Service + const cache = yield* Ref.make(new Map()) + const lock = Semaphore.makeUnsafe(1) + + const load = Effect.fn("WellKnown.load")(function* () { + const value = yield* kv.get(sourcesKey) + const origins = Schema.is(Sources)(value) ? value : [] + const current = yield* Ref.get(cache) + const entries = yield* Effect.forEach(origins, (origin) => { + const cached = current.get(origin) + if (cached) return Effect.succeed(cached) + return inspect(origin).pipe( + Effect.provideService(HttpClient.HttpClient, http), + Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })), + ) + }) + yield* Ref.set(cache, new Map(entries.map((entry) => [entry.origin, entry]))) + return entries + }) + + const refresh = Effect.fn("WellKnown.refresh")(function* () { + return yield* lock.withPermit( + Effect.gen(function* () { + const value = yield* kv.get(sourcesKey) + const origins = Schema.is(Sources)(value) ? value : [] + if (!origins.length) return false + const entries = yield* Effect.forEach(origins, (origin) => + inspect(origin).pipe( + Effect.provideService(HttpClient.HttpClient, http), + Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })), + ), + ) + const next = new Map(entries.map((entry) => [entry.origin, entry])) + const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next) + if (!changed) return false + yield* Ref.set(cache, next) + yield* events.publish(Event.Updated, {}) + return true + }), + ) + }) + + return Service.of({ + entries: load, + snapshot: () => Array.from(Ref.getUnsafe(cache).values()), + refresh, + add: Effect.fn("WellKnown.add")(function* (value) { + return yield* lock.withPermit( + Effect.gen(function* () { + const origin = value.replace(/\/+$/, "") + const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http)) + if (!manifest.auth) return yield* Effect.fail(new Error(`No authentication method found at ${origin}`)) + const entry = { origin, integrationID: Integration.ID.make(origin), manifest } + const sources = yield* kv.get(sourcesKey) + const origins = Schema.is(Sources)(sources) ? sources : [] + yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin]))) + yield* Ref.update(cache, (current) => new Map(current).set(origin, entry)) + yield* events.publish(Event.Updated, {}) + return entry + }), + ) + }), + remove: Effect.fn("WellKnown.remove")(function* (value) { + yield* lock.withPermit( + Effect.gen(function* () { + const origin = value.replace(/\/+$/, "") + const sources = yield* kv.get(sourcesKey) + const origins = Schema.is(Sources)(sources) ? sources : [] + yield* kv.set( + sourcesKey, + origins.filter((item) => item !== origin), + ) + yield* Ref.update(cache, (current) => { + const next = new Map(current) + next.delete(origin) + return next + }) + yield* events.publish(Event.Updated, {}) + }), + ) + }), + resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) { + return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)) + }), + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node, EventV2.node] }) diff --git a/packages/core/src/wellknown/plugin.ts b/packages/core/src/wellknown/plugin.ts new file mode 100644 index 0000000000..9be129a248 --- /dev/null +++ b/packages/core/src/wellknown/plugin.ts @@ -0,0 +1,36 @@ +export * as WellKnownPlugin from "./plugin" + +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Effect, Stream } from "effect" +import { EventV2 } from "../event" +import { WellKnown } from "../wellknown" + +export const Plugin = define({ + id: "opencode.wellknown", + effect: Effect.fn(function* (ctx) { + const events = yield* EventV2.Service + const wellknown = yield* WellKnown.Service + yield* wellknown.entries().pipe(Effect.orDie) + yield* ctx.integration.transform((draft) => { + wellknown.snapshot().forEach((entry) => { + if (!entry.manifest.auth) return + draft.update(entry.integrationID, (integration) => { + integration.name = new URL(entry.origin).hostname + }) + draft.method.update({ + integrationID: entry.integrationID, + method: { + id: "login", + type: "command", + label: "Log in", + command: [...entry.manifest.auth.command], + }, + }) + }) + }) + yield* events.subscribe(WellKnown.Event.Updated).pipe( + Stream.runForEach(() => ctx.integration.reload()), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index b60b6e560c..6a0ca3df7e 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" +import { TestClock } from "effect/testing" import { AgentV2 } from "@opencode-ai/core/agent" import { EventV2 } from "@opencode-ai/core/event" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -76,7 +77,9 @@ describe("AgentV2", () => { ) description = "New description" hidden = false - yield* agent.reload() + const reload = yield* agent.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("500 millis") + yield* Fiber.join(reload) expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) }), diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index ad667f288c..f14feaee5f 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Money } from "@opencode-ai/schema/money" import { Effect, Fiber, Layer, Stream } from "effect" +import { TestClock } from "effect/testing" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" @@ -261,7 +262,9 @@ describe("CatalogV2", () => { expect((yield* catalog.model.default())?.id).toBe(old) configured = false - yield* catalog.reload() + const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("500 millis") + yield* Fiber.join(reload) expect((yield* catalog.model.default())?.id).toBe(newest) }), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 94c84c3b02..d92a6a722c 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -8,7 +8,9 @@ import { ConfigModel } from "@opencode-ai/core/config/model" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Credential } from "@opencode-ai/core/credential" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -19,6 +21,8 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { WellKnown } from "@opencode-ai/core/wellknown" +import { Integration } from "@opencode-ai/schema/integration" import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" @@ -26,12 +30,46 @@ import { testEffect } from "../lib/effect" const it = testEffect(Layer.empty) const selection = Schema.decodeUnknownSync(ConfigModel.Selection) +const emptyCredentialNode = makeGlobalNode({ + service: Credential.Service, + layer: Layer.succeed( + Credential.Service, + Credential.Service.of({ + all: () => Effect.succeed([]), + list: () => Effect.succeed([]), + get: () => Effect.succeed(undefined), + create: () => Effect.die("unused Credential.create"), + update: () => Effect.die("unused Credential.update"), + remove: () => Effect.die("unused Credential.remove"), + }), + ), + deps: [], +}) + +const emptyWellknownNode = makeGlobalNode({ + service: WellKnown.Service, + layer: Layer.succeed( + WellKnown.Service, + WellKnown.Service.of({ + entries: () => Effect.succeed([]), + snapshot: () => [], + refresh: () => Effect.succeed(false), + add: () => Effect.die("unused Wellknown.add"), + remove: () => Effect.die("unused Wellknown.remove"), + resolve: () => Effect.die("unused Wellknown.resolve"), + }), + ), + deps: [], +}) + function testLayer( directory: string, globalDirectory = path.join(directory, "global"), projectDirectory = directory, vcs?: Project.Vcs, watcher?: Layer.Layer, + credentialNode = emptyCredentialNode, + wellknownNode = emptyWellknownNode, ) { const locationLayer = Layer.succeed( Location.Service, @@ -45,6 +83,8 @@ function testLayer( return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ [Location.node, locationLayer], [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })], + [Credential.node, credentialNode], + [WellKnown.node, wellknownNode], ...(watcher ? ([[Watcher.node, watcher]] as const) : []), ]) } @@ -125,6 +165,86 @@ describe("Config", () => { }), ) + it.live("loads authenticated wellknown config at highest priority", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" })) + await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) + }) + + const integrationID = Integration.ID.make("https://example.com") + let key = "secret" + const credentialNode = makeGlobalNode({ + service: Credential.Service, + layer: Layer.succeed( + Credential.Service, + Credential.Service.of({ + all: () => Effect.die("unused Credential.all"), + list: () => + Effect.succeed([ + new Credential.Info({ + id: Credential.ID.create(), + integrationID, + label: "default", + value: Credential.Key.make({ type: "key", key }), + }), + ]), + get: () => Effect.die("unused Credential.get"), + create: () => Effect.die("unused Credential.create"), + update: () => Effect.die("unused Credential.update"), + remove: () => Effect.die("unused Credential.remove"), + }), + ), + deps: [], + }) + const entry: WellKnown.Entry = { + origin: "https://example.com", + integrationID, + manifest: { auth: { command: ["login"], env: "TOKEN" } }, + } + const wellknownNode = makeGlobalNode({ + service: WellKnown.Service, + layer: Layer.succeed( + WellKnown.Service, + WellKnown.Service.of({ + entries: () => Effect.succeed([entry]), + snapshot: () => [entry], + refresh: () => Effect.succeed(false), + add: () => Effect.die("unused Wellknown.add"), + remove: () => Effect.die("unused Wellknown.remove"), + resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]), + }), + ), + deps: [], + }) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const events = yield* EventV2.Service + expect(Config.latest(yield* config.entries(), "shell")).toBe("secret") + const updated = yield* events + .subscribe(ConfigSchema.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + key = "next" + yield* events.publish(Integration.Event.ConnectionUpdated, { integrationID }) + expect(yield* Fiber.join(updated)).toHaveLength(1) + expect(Config.latest(yield* config.entries(), "shell")).toBe("next") + }).pipe( + Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)), + ) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.effect("detects v1 configuration from any v1-only top-level key", () => Effect.sync(() => { expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true) @@ -163,6 +283,31 @@ describe("Config", () => { }), ) + it.effect("migrates the v1 experimental subagent depth", () => + Effect.sync(() => { + expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2) + }), + ) + + it.effect("migrates v1 provider lists to policies", () => + Effect.sync(() => { + expect( + ConfigMigrateV1.migrate({ + enabled_providers: ["anthropic", "openai"], + disabled_providers: ["openai"], + }).experimental?.policies, + ).toEqual([ + { action: "provider.use", resource: "*", effect: "deny" }, + { action: "provider.use", resource: "anthropic", effect: "allow" }, + { action: "provider.use", resource: "openai", effect: "allow" }, + { action: "provider.use", resource: "openai", effect: "deny" }, + ]) + expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([ + { action: "provider.use", resource: "*", effect: "deny" }, + ]) + }), + ) + it.effect("migrates v1 provider setup options into AISDK settings", () => Effect.sync(() => { const migrated = ConfigMigrateV1.migrate({ @@ -282,9 +427,7 @@ describe("Config", () => { const config = yield* Config.Service yield* config.entries() - expect(targets).toEqual([ - { type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }, - ]) + expect(targets).toEqual([{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }]) }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher))) }), ), diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts new file mode 100644 index 0000000000..810f20ffd9 --- /dev/null +++ b/packages/core/test/config/policy.test.ts @@ -0,0 +1,95 @@ +import { describe, expect } from "bun:test" +import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config" +import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy" +import { EventV2 } from "@opencode-ai/core/event" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Effect, Schema } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const decode = Schema.decodeUnknownSync(Config.Info) + +const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) => + new Config.Document({ + type: "document", + info: decode({ + experimental: { + policies: items.map((item) => ({ action: "provider.use", ...item })), + }, + }), + }) + +const addPlugin = Effect.fn(function* (entries: () => Config.Entry[]) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* ConfigPolicyPlugin.Plugin.effect(host).pipe( + Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.sync(entries) })), + ) +}) + +describe("ConfigPolicyPlugin.Plugin", () => { + it.effect("filters plugin-provided providers with ordered wildcard policies", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => { + catalog.provider.update(ProviderV2.ID.openai, () => {}) + catalog.provider.update(ProviderV2.ID.anthropic, () => {}) + catalog.provider.update(ProviderV2.ID.make("company-internal"), () => {}) + }) + yield* addPlugin(() => [ + policies( + { effect: "deny", resource: "*" }, + { effect: "allow", resource: "anthropic" }, + { effect: "allow", resource: "company-*" }, + ), + ]) + + expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined() + expect(yield* catalog.provider.get(ProviderV2.ID.anthropic)).toBeDefined() + expect(yield* catalog.provider.get(ProviderV2.ID.make("company-internal"))).toBeDefined() + }), + ) + + it.effect("prevents project policy from overriding user-global policy", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) + yield* addPlugin(() => [ + policies({ effect: "deny", resource: "openai" }), + policies({ effect: "allow", resource: "openai" }), + ]) + + expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined() + }), + ) + + it.live("reloads changed policies", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const events = yield* EventV2.Service + let entries: Config.Entry[] = [policies({ effect: "deny", resource: "openai" })] + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) + yield* addPlugin(() => entries) + expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined() + + entries = [policies({ effect: "allow", resource: "openai" })] + yield* events.publish(ConfigSchema.Event.Updated, {}) + yield* waitUntil( + catalog.provider.get(ProviderV2.ID.openai).pipe(Effect.map((provider) => provider !== undefined)), + ) + }), + ) +}) + +const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect) { + for (let attempt = 0; attempt < 200; attempt++) { + if (yield* condition) return + yield* Effect.sleep("10 millis") + } + return yield* Effect.die("Timed out waiting for policy reload") +}) diff --git a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index 90154112d2..ddafa3a3e7 100644 --- a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +++ b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json @@ -13,7 +13,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are opencode, an agent - please keep going until the userโ€™s query is completely resolved, before ending your turn and yielding back to the user.\\n\\nYour thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.\\n\\nYou MUST iterate and keep going until the problem is solved.\\n\\nYou have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.\\n\\nOnly terminate your turn when you are sure that the problem is solved. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.\\n\\nTHE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH.\\n\\nYou must use the webfetch tool to recursively gather all information from URL's provided to you by the user, as well as any links you find in the content of those pages.\\n\\nYour knowledge on everything is out of date because your training date is in the past. \\n\\nYou CANNOT successfully complete this task without using Google to verify your\\nunderstanding of third party packages and dependencies is up to date. You must use the webfetch tool to search google for how to properly use libraries, packages, frameworks, dependencies, etc. every single time you install or implement one. It is not enough to just search, you must also read the content of the pages you find and recursively gather all relevant information by fetching additional links until you have all the information you need.\\n\\nAlways tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.\\n\\nIf the user request is \\\"resume\\\" or \\\"continue\\\" or \\\"try again\\\", check the previous conversation history to identify the next incomplete step. Continue from that step, and do not hand back control to the user until the request is complete. Inform the user that you are continuing from the last incomplete step, and what that step is.\\n\\nTake your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking tool if available. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.\\n\\nYou MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.\\n\\nYou MUST keep working until the problem is completely solved. Do not end your turn until you have completed the necessary work and verified that everything is working correctly. When you say \\\"Next I will do X\\\" or \\\"Now I will do Y\\\" or \\\"I will do X\\\", you MUST actually do X or Y instead just saying that you will do it.\\n\\nYou are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.\\n\\n# Workflow\\n1. Fetch any URL's provided by the user using the `webfetch` tool.\\n2. Understand the problem deeply. Carefully read the issue and think critically about what is required. Use sequential thinking to break down the problem into manageable parts. Consider the following:\\n - What is the expected behavior?\\n - What are the edge cases?\\n - What are the potential pitfalls?\\n - How does this fit into the larger context of the codebase?\\n - What are the dependencies and interactions with other parts of the code?\\n3. Investigate the codebase. Explore relevant files, search for key functions, and gather context.\\n4. Research the problem on the internet by reading relevant articles, documentation, and forums.\\n5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps.\\n6. Implement the fix incrementally. Make small, testable code changes.\\n7. Debug as needed. Use debugging techniques to isolate and resolve issues.\\n8. Test frequently. Run tests after each change to verify correctness.\\n9. Iterate until the root cause is fixed and all tests pass.\\n10. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete.\\n\\nRefer to the detailed sections below for more information on each step.\\n\\n## 1. Fetch Provided URLs\\n- If the user provides a URL, use the `webfetch` tool to retrieve the content of the provided URL.\\n- After fetching, review the content returned by the webfetch tool.\\n- If you find any additional URLs or links that are relevant, use the `webfetch` tool again to retrieve those links.\\n- Recursively gather all relevant information by fetching additional links until you have all the information you need.\\n\\n## 2. Deeply Understand the Problem\\nCarefully read the issue and think hard about a plan to solve it before coding.\\n\\n## 3. Codebase Investigation\\n- Explore relevant files and directories.\\n- Search for key functions, classes, or variables related to the issue.\\n- Read and understand relevant code snippets.\\n- Identify the root cause of the problem.\\n- Validate and update your understanding continuously as you gather more context.\\n\\n## 4. Internet Research\\n- Use the `webfetch` tool to search google by fetching the URL `https://www.google.com/search?q=your+search+query`.\\n- After fetching, review the content returned by the fetch tool.\\n- You MUST fetch the contents of the most relevant links to gather information. Do not rely on the summary that you find in the search results.\\n- As you fetch each link, read the content thoroughly and fetch any additional links that you find within the content that are relevant to the problem.\\n- Recursively gather all relevant information by fetching links until you have all the information you need.\\n\\n## 5. Develop a Detailed Plan \\n- Outline a specific, simple, and verifiable sequence of steps to fix the problem.\\n- Continue through the planned steps instead of ending your turn and asking the user what they want to do next.\\n\\n## 6. Making Code Changes\\n- Before editing, always read the relevant file contents or section to ensure complete context.\\n- Always read 2000 lines of code at a time to ensure you have enough context.\\n- If a patch is not applied correctly, attempt to reapply it.\\n- Make small, testable, incremental changes that logically follow from your investigation and plan.\\n- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it.\\n\\n## 7. Debugging\\n- Make code changes only if you have high confidence they can solve the problem\\n- When debugging, try to determine the root cause rather than addressing symptoms\\n- Debug for as long as needed to identify the root cause and identify a fix\\n- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening\\n- To test hypotheses, you can also add test statements or functions\\n- Revisit your assumptions if unexpected behavior occurs.\\n\\n\\n# Communication Guidelines\\nAlways communicate clearly and concisely in a casual, friendly yet professional tone. \\n\\n\\\"Let me fetch the URL you provided to gather more information.\\\"\\n\\\"Ok, I've got all of the information I need on the LIFX API and I know how to use it.\\\"\\n\\\"Now, I will search the codebase for the function that handles the LIFX API requests.\\\"\\n\\\"I need to update several files here - stand by\\\"\\n\\\"OK! Now let's run the tests to make sure everything is working correctly.\\\"\\n\\\"Whelp - I see we have some problems. Let's fix those up.\\\"\\n\\n\\n- Respond with clear, direct answers. Use bullet points and code blocks for structure. - Avoid unnecessary explanations, repetition, and filler. \\n- Always write code directly to the correct files.\\n- Do not display code to the user unless they specifically ask for it.\\n- Only elaborate when clarification is essential for accuracy or user understanding.\\n\\n# Memory\\nYou have a memory that stores information about the user and their preferences. This memory is used to provide a more personalized experience. You can access and update this memory as needed. The memory is stored in a file called `.github/instructions/memory.instruction.md`. If the file is empty, you'll need to create it. \\n\\nWhen creating a new memory file, you MUST include the following front matter at the top of the file:\\n```yaml\\n---\\napplyTo: '**'\\n---\\n```\\n\\nIf the user asks you to remember something or add something to your memory, you can do so by updating the memory file.\\n\\n# Reading Files and Folders\\n\\n**Always check if you have already read a file, folder, or workspace structure before reading it again.**\\n\\n- If you have already read the content and it has not changed, do NOT re-read it.\\n- Only re-read files or folders if:\\n - You suspect the content has changed since your last read.\\n - You have made edits to the file or folder.\\n - You encounter an error that suggests the context may be stale or incomplete.\\n- Use your internal memory and previous context to avoid redundant reads.\\n- This will save time, reduce unnecessary operations, and make your workflow more efficient.\\n\\n# Writing Prompts\\nIf you are asked to write a prompt, you should always generate the prompt in markdown format.\\n\\nIf you are not writing the prompt in a file, you should always wrap the prompt in triple backticks so that it is formatted correctly and can be easily copied from the chat.\\n\\n# Git \\nIf the user tells you to stage and commit, you may do so. \\n\\nYou are NEVER allowed to stage and commit files automatically.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are OpenCode, You and the user share the same workspace and collaborate to achieve the user's goals.\\n\\nYou are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.\\n\\n- When searching for text or files, prefer using glob and grep tools (they are powered by `rg`)\\n- Parallelize tool calls whenever possible - especially file reads. When independent tool calls have no dependencies, issue them together in the same assistant message. Never chain together shell commands with separators like `echo \\\"====\\\";` as this renders to the user poorly.\\n\\n## Editing Approach\\n\\n- The best changes are often the smallest correct changes.\\n- When you are weighing two correct approaches, prefer the more minimal one (less new names, helpers, tests, etc).\\n- Keep things in one function unless composable or reusable\\n- Do not add backward-compatibility code unless there is a concrete need, such as persisted data, shipped behavior, external consumers, or an explicit user requirement; if unclear, ask one short question instead of guessing.\\n\\n## Autonomy and persistence\\n\\nUnless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.\\n\\nPersist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.\\n\\nIf you notice unexpected changes in the worktree or staging area that you did not make, continue with your task. NEVER revert, undo, or modify changes you did not make unless the user explicitly asks you to. There can be multiple agents or the user working in the same codebase concurrently.\\n\\n## Editing constraints\\n\\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \\\"Assigns the value to the variable\\\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\\n- Always use apply_patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don't need to be done with apply_patch.\\n- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.\\n- You may be in a dirty git worktree.\\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\\n * If the changes are in unrelated files, just ignore them and don't revert them.\\n- Do not amend a commit unless explicitly requested to do so.\\n- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them, or were autogenerated. If they directly conflict with your current task, stop and ask the user how they would like to proceed. Otherwise, focus on the task at hand.\\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\\n- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.\\n\\n## Special user requests\\n\\nIf the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\\n\\nIf the user pastes an error description or a bug report, help them diagnose the root cause. You can try to reproduce it if it seems feasible with the available tools and skills.\\n\\nIf the user asks for a \\\"review\\\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\\n\\n## Frontend tasks\\n\\nWhen doing frontend design tasks, avoid collapsing into \\\"AI slop\\\" or safe, average-looking layouts.\\n- Ensure the page loads properly on both desktop and mobile\\n- For React code, prefer modern patterns including useEffectEvent, startTransition, and useDeferredValue when appropriate if used by the team. Do not add useMemo/useCallback by default unless already used; follow the repo's React Compiler guidance.\\n- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.\\n\\nException: If working within an existing website or design system, preserve the established patterns, structure, and visual language.\\n\\n# Working with the user\\n\\n## General\\n\\nDo not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (\\\"Done โ€”\\\", \\\"Got it\\\", \\\"Great question, \\\") or framing phrases.\\n\\nBalance conciseness to not overwhelm the user with appropriate detail for the request. Do not narrate abstractly; explain what you are doing and why.\\n\\nNever tell the user to \\\"save/copy this file\\\", the user is on the same machine and has access to the same files as you have.\\n\\n\\n## Formatting rules\\n\\nYour responses are rendered as GitHub-flavored Markdown.\\n\\nNever use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.\\n\\nHeaders are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **โ€ฆ**. Don't add a blank line.\\n\\nUse inline code blocks for commands, paths, environment variables, function names, inline examples, keywords.\\n\\nCode samples or multi-line snippets should be wrapped in fenced code blocks. Include a language tag when possible.\\n\\nDonโ€™t use emojis or em dashes unless explicitly instructed.\\n\\n## Response channels\\n\\nUse commentary for short progress updates while working and final for the completed response.\\n\\n### `commentary` channel\\n\\nOnly use `commentary` for intermediary updates. These are short updates while you are working, they are NOT final answers. Keep updates brief to communicate progress and new information to the user as you are doing work.\\n\\nSend updates when they add meaningful new information: a discovery, a tradeoff, a blocker, a substantial plan, or the start of a non-trivial edit or verification step.\\n\\nDo not narrate routine reads, searches, obvious next steps, or minor confirmations. Combine related progress into a single update.\\n\\nDo not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (\\\"Done โ€”\\\", \\\"Got it\\\", \\\"Great question\\\") or framing phrases.\\n\\nBefore substantial work, send a short update describing your first step. Before editing files, send an update describing the edit.\\n\\nAfter you have sufficient context, and the work is substantial you can provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).\\n\\n### `final` channel\\n\\nUse final for the completed response.\\n\\nStructure your final response if necessary. The complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.\\n\\nIf the user asks for a code explanation, include code references. For simple tasks, just state the outcome without heavy formatting.\\n\\nFor large or complex changes, lead with the solution, then explain what you did and why. For casual chat, just chat. If something couldnโ€™t be done (tests, builds, etc.), say so. Suggest next steps only when they are natural and useful; if you list options, use numbered items.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" }, "response": { "status": 200, diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts new file mode 100644 index 0000000000..6d7d4d78d7 --- /dev/null +++ b/packages/core/test/instruction-state.test.ts @@ -0,0 +1,425 @@ +import { describe, expect } from "bun:test" +import { and, asc, eq } from "drizzle-orm" +import { Effect, Schema } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" +import { Instructions } from "@opencode-ai/core/instructions" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { InstructionState } from "@opencode-ai/core/session/instruction-state" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { InstructionBlobTable, InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql" +import { testEffect } from "./lib/effect" + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) + +const source = (name: string, read: Effect.Effect) => + Instructions.make({ + key: Instructions.Key.make(name), + codec: Schema.toCodecJson(Schema.String), + read, + render: { + initial: String, + changed: (_previous, current) => current, + removed: (previous) => `Removed ${previous}`, + }, + }) + +const setup = (sessionID: SessionSchema.ID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "instruction-state-test", + directory: "/project", + title: "Instruction state test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + return { db, events: yield* EventV2.Service } + }) + +const instructionEvents = (db: Database.Interface["db"], sessionID: SessionSchema.ID) => + db + .select() + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, "session.instructions.updated.2"))) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie) + +const preview = (db: Database.Interface["db"], sessionID: SessionSchema.ID, instructions: Instructions.Instructions) => + Instructions.read(instructions).pipe( + Effect.flatMap((observed) => InstructionState.preview(db, sessionID, instructions, observed)), + ) + +describe("InstructionState", () => { + it.effect("observes each source once without publishing events or inserting blobs", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_observe") + const { db, events } = yield* setup(sessionID) + const reads = { first: 0, second: 0 } + const instructions = Instructions.combine([ + source( + "test/first", + Effect.sync(() => { + reads.first++ + return "first" + }), + ), + source( + "test/second", + Effect.sync(() => { + reads.second++ + return "second" + }), + ), + ]) + const published: EventV2.Payload[] = [] + const unsubscribe = yield* events.listen((event) => + Effect.sync(() => { + if (event.type === "session.instructions.updated") published.push(event) + }), + ) + + const observation = yield* InstructionState.observe(db, instructions, sessionID) + yield* unsubscribe + + expect(reads).toEqual({ first: 1, second: 1 }) + expect(observation).toEqual({ + sessionID, + initial: true, + current: { + "test/first": Instructions.hash("first"), + "test/second": Instructions.hash("second"), + }, + delta: { + "test/first": Instructions.hash("first"), + "test/second": Instructions.hash("second"), + }, + blobs: { + [Instructions.hash("first")]: "first", + [Instructions.hash("second")]: "second", + }, + }) + expect(published).toEqual([]) + expect(yield* instructionEvents(db, sessionID)).toEqual([]) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([]) + }), + ) + + it.effect("commits initial metadata and changed and removed deltas without rereading sources", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_commit") + const { db, events } = yield* setup(sessionID) + let current = "initial" + let retired: string | Instructions.Removed = "retired" + let reads = 0 + const instructions = Instructions.combine([ + source( + "test/current", + Effect.sync(() => { + reads++ + return current + }), + ), + source( + "test/retired", + Effect.sync(() => { + reads++ + return retired + }), + ), + ]) + const published: EventV2.Payload[] = [] + const unsubscribe = yield* events.listen((event) => + Effect.sync(() => { + if (event.type === "session.instructions.updated") published.push(event) + }), + ) + + const initial = yield* InstructionState.observe(db, instructions, sessionID) + expect(reads).toBe(2) + yield* InstructionState.commit(db, events, initial) + expect(reads).toBe(2) + + current = "changed" + retired = Instructions.removed + const changed = yield* InstructionState.observe(db, instructions, sessionID) + expect(reads).toBe(4) + expect(changed).toMatchObject({ + sessionID, + initial: false, + current: { "test/current": Instructions.hash("changed") }, + delta: { + "test/current": Instructions.hash("changed"), + "test/retired": "removed", + }, + blobs: { [Instructions.hash("changed")]: "changed" }, + }) + yield* InstructionState.commit(db, events, changed) + expect(reads).toBe(4) + yield* unsubscribe + + expect(published).toHaveLength(2) + expect(published[0]?.metadata).toEqual({ instructions: { initial: true } }) + expect(published[1]?.metadata).toBeUndefined() + expect((yield* instructionEvents(db, sessionID)).map((event) => event.data.delta)).toEqual([ + { + "test/current": Instructions.hash("initial"), + "test/retired": Instructions.hash("retired"), + }, + { + "test/current": Instructions.hash("changed"), + "test/retired": "removed", + }, + ]) + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({ + initial_values: { + "test/current": Instructions.hash("initial"), + "test/retired": Instructions.hash("retired"), + }, + current_values: { "test/current": Instructions.hash("changed") }, + }) + expect( + Object.fromEntries( + (yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).map((row) => [row.hash, row.value]), + ), + ).toEqual({ + [Instructions.hash("initial")]: "initial", + [Instructions.hash("retired")]: "retired", + [Instructions.hash("changed")]: "changed", + }) + }), + ) + + it.effect("keeps no-op observations free of events and blobs", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_noop") + const { db, events } = yield* setup(sessionID) + const instructions = source("test/context", Effect.succeed("unchanged")) + yield* InstructionState.prepare(db, events, instructions, sessionID) + const beforeEvents = yield* instructionEvents(db, sessionID) + const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) + + const observation = yield* InstructionState.observe(db, instructions, sessionID) + expect(observation).toEqual({ + sessionID, + initial: false, + current: { "test/context": Instructions.hash("unchanged") }, + delta: {}, + blobs: {}, + }) + yield* InstructionState.commit(db, events, observation) + + expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) + }), + ) + + it.effect("assembles a fresh private update without repairing a missing cache", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate") + const { db, events } = yield* setup(sessionID) + let value = "Initial context" + const instructions = source( + "test/context", + Effect.sync(() => value), + ) + yield* InstructionState.prepare(db, events, instructions, sessionID) + yield* db + .delete(InstructionStateTable) + .where(eq(InstructionStateTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) + value = "Changed context" + const beforeEvents = yield* instructionEvents(db, sessionID) + const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) + + const assembled = yield* preview(db, sessionID, instructions) + + expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" }) + expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) + expect( + yield* db + .select() + .from(InstructionStateTable) + .where(eq(InstructionStateTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie), + ).toBeUndefined() + }), + ) + + it.effect("reads through a stale cache without repairing it", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale") + const { db, events } = yield* setup(sessionID) + let value = "Initial context" + const instructions = source( + "test/context", + Effect.sync(() => value), + ) + yield* InstructionState.prepare(db, events, instructions, sessionID) + value = "Committed update" + yield* InstructionState.prepare(db, events, instructions, sessionID) + yield* db + .update(InstructionStateTable) + .set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } }) + .where(eq(InstructionStateTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) + value = "Private update" + const beforeEvents = yield* instructionEvents(db, sessionID) + const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) + const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie) + + const assembled = yield* preview(db, sessionID, instructions) + + expect(assembled.initial).toBe("Initial context") + expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"]) + expect(assembled.update).toBe("Private update") + expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState) + }), + ) + + it.effect("assembles initial instructions without persisting a baseline", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial") + const { db } = yield* setup(sessionID) + const instructions = source("test/context", Effect.succeed("Initial context")) + + expect(yield* preview(db, sessionID, instructions)).toEqual({ + initial: "Initial context", + updates: [], + update: "", + }) + expect(yield* instructionEvents(db, sessionID)).toEqual([]) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([]) + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined() + }), + ) + + it.effect("retains a committed value when fresh instructions are unavailable", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate_unavailable") + const { db, events } = yield* setup(sessionID) + let value: string | Instructions.Unavailable = "Committed context" + const instructions = source( + "test/context", + Effect.sync(() => value), + ) + yield* InstructionState.prepare(db, events, instructions, sessionID) + value = Instructions.unavailable + const beforeEvents = yield* instructionEvents(db, sessionID) + const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie) + const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie) + + expect(yield* preview(db, sessionID, instructions)).toEqual({ + initial: "Committed context", + updates: [], + update: "", + }) + expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs) + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState) + }), + ) + + it.effect("blocks an unavailable initial instruction without persisting a baseline", () => + Effect.gen(function* () { + const sessionID = SessionSchema.ID.make("ses_instruction_generate_blocked") + const { db } = yield* setup(sessionID) + const instructions = source("test/context", Effect.succeed(Instructions.unavailable)) + + const error = yield* preview(db, sessionID, instructions).pipe(Effect.flip) + + expect(error).toBeInstanceOf(Instructions.InitializationBlocked) + expect(error.keys).toEqual([Instructions.Key.make("test/context")]) + expect(yield* instructionEvents(db, sessionID)).toEqual([]) + expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([]) + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined() + }), + ) + + it.effect("keeps prepare equivalent to observe followed by commit", () => + Effect.gen(function* () { + const observedSessionID = SessionSchema.ID.make("ses_instruction_composed") + const preparedSessionID = SessionSchema.ID.make("ses_instruction_prepared") + const { db, events } = yield* setup(observedSessionID) + yield* setup(preparedSessionID) + let value: string | Instructions.Removed = "initial" + let observedReads = 0 + let preparedReads = 0 + const observedInstructions = source( + "test/context", + Effect.sync(() => { + observedReads++ + return value + }), + ) + const preparedInstructions = source( + "test/context", + Effect.sync(() => { + preparedReads++ + return value + }), + ) + + for (const next of ["initial", "changed", "changed", Instructions.removed] as const) { + value = next + yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe( + Effect.flatMap((observation) => InstructionState.commit(db, events, observation)), + ) + yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID) + } + + expect(observedReads).toBe(4) + expect(preparedReads).toBe(4) + expect((yield* instructionEvents(db, observedSessionID)).map((event) => event.data.delta)).toEqual( + (yield* instructionEvents(db, preparedSessionID)).map((event) => event.data.delta), + ) + const states = yield* db.select().from(InstructionStateTable).orderBy(asc(InstructionStateTable.session_id)).all() + expect(states).toHaveLength(2) + expect( + states.map((state) => ({ + epoch_start: state.epoch_start, + through_seq: state.through_seq, + initial_values: state.initial_values, + current_values: state.current_values, + })), + ).toEqual([ + { + epoch_start: 0, + through_seq: 2, + initial_values: { "test/context": Instructions.hash("initial") }, + current_values: {}, + }, + { + epoch_start: 0, + through_seq: 2, + initial_values: { "test/context": Instructions.hash("initial") }, + current_values: {}, + }, + ]) + }), + ) +}) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 0cbf46bb44..42d1ab50d3 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -29,6 +29,20 @@ const failingIt = testEffect( AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]), ) +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + describe("Integration", () => { it.effect("registers integrations through the editor", () => Effect.gen(function* () { @@ -151,6 +165,51 @@ describe("Integration", () => { }), ) + it.live("runs command authentication and stores the final output line", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + const integrationID = Integration.ID.make("company") + const methodID = Integration.MethodID.make("login") + yield* integrations.transform((editor) => + editor.method.update({ + integrationID, + method: { + id: methodID, + type: "command", + label: "Log in", + command: [ + process.execPath, + "-e", + 'console.error("https://example.com/login"); await Bun.sleep(50); console.log("secret")', + ], + }, + }), + ) + + const attempt = yield* integrations.command.connect({ integrationID, methodID, label: "Work" }) + const pending = yield* eventually( + integrations.command.status({ integrationID, attemptID: attempt.attemptID }), + (status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true, + ) + expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login\n" }) + + expect( + yield* eventually( + integrations.command.status({ integrationID, attemptID: attempt.attemptID }), + (status) => status.status === "complete", + ), + ).toEqual({ status: "complete", time: attempt.time }) + expect(yield* credentials.list(integrationID)).toEqual([ + expect.objectContaining({ + integrationID, + label: "Work", + value: Credential.Key.make({ type: "key", key: "secret" }), + }), + ]) + }), + ) + it.effect("completes code OAuth once and stores the credential", () => Effect.gen(function* () { const integrations = yield* Integration.Service @@ -181,14 +240,14 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {}, label: "Personal", }) expect(attempt.mode).toBe("code") - yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }) + yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID, code: "1234" }) expect((yield* credentials.list(integrationID))[0]).toEqual( expect.objectContaining({ @@ -230,12 +289,17 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) - expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf( - Integration.CodeRequiredError, - ) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) + expect( + yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID }).pipe(Effect.flip), + ).toBeInstanceOf(Integration.CodeRequiredError) expect(closed).toBe(false) - yield* integrations.attempt.cancel(attempt.attemptID) + yield* integrations.oauth.cancel({ + integrationID: Integration.ID.make("other"), + attemptID: attempt.attemptID, + }) + expect(closed).toBe(false) + yield* integrations.oauth.cancel({ integrationID, attemptID: attempt.attemptID }) expect(closed).toBe(true) expect(yield* credentials.list(integrationID)).toEqual([]) }), @@ -263,9 +327,9 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) yield* Effect.yieldNow - expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({ status: "complete", time: attempt.time, }) @@ -301,12 +365,12 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) - const exit = yield* integrations.attempt - .complete({ attemptID: attempt.attemptID, code: "1234" }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) + const exit = yield* integrations.oauth + .complete({ integrationID, attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true) - expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({ status: "failed", message: "credential persistence failed", time: attempt.time, @@ -337,11 +401,11 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10))) yield* TestClock.adjust(Duration.minutes(10)) yield* Effect.yieldNow - expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({ status: "expired", time: attempt.time, }) diff --git a/packages/core/test/kv.test.ts b/packages/core/test/kv.test.ts new file mode 100644 index 0000000000..3fd3797dde --- /dev/null +++ b/packages/core/test/kv.test.ts @@ -0,0 +1,25 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { KV } from "@opencode-ai/core/kv" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { testEffect } from "./lib/effect" + +const it = testEffect(LayerNode.compile(KV.node)) + +describe("KV", () => { + it.effect("stores, replaces, and removes JSON values", () => + Effect.gen(function* () { + const kv = yield* KV.Service + expect(yield* kv.get("wellknown:sources")).toBeUndefined() + + yield* kv.set("wellknown:sources", ["https://example.com"]) + expect(yield* kv.get("wellknown:sources")).toEqual(["https://example.com"]) + + yield* kv.set("wellknown:sources", ["https://example.com", "https://example.org"]) + expect(yield* kv.get("wellknown:sources")).toEqual(["https://example.com", "https://example.org"]) + + yield* kv.remove("wellknown:sources") + expect(yield* kv.get("wellknown:sources")).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/lib/image.ts b/packages/core/test/lib/image.ts new file mode 100644 index 0000000000..b899bc9168 --- /dev/null +++ b/packages/core/test/lib/image.ts @@ -0,0 +1,7 @@ +import { Image } from "@opencode-ai/core/image" +import { Effect, Layer } from "effect" + +/** Passthrough resizer for tests that build ToolRegistry.node without a Location. */ +export const imagePassthrough = Layer.mock(Image.Service, { + normalize: (_resource, content) => Effect.succeed(content), +}) diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 9adf3c240b..e3fdccb13a 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -44,7 +44,7 @@ export const registerToolPlugin = (plugin: { Effect.gen(function* () { const tools = yield* Tools.Service const context = host({ - ai: { + session: { hook: () => Effect.succeed({ dispose: Effect.void }), }, tool: { diff --git a/packages/core/test/mcp-instructions.test.ts b/packages/core/test/mcp-instructions.test.ts new file mode 100644 index 0000000000..14e0e671f0 --- /dev/null +++ b/packages/core/test/mcp-instructions.test.ts @@ -0,0 +1,150 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { MCP } from "@opencode-ai/core/mcp/index" +import { McpInstructions } from "@opencode-ai/core/mcp/instructions" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { McpTool } from "@opencode-ai/core/tool/mcp" +import { it } from "./lib/effect" +import { readInitial, readUpdate } from "./lib/instructions" + +const build = AgentV2.ID.make("build") + +const selection = (permissions: PermissionV2.Ruleset = []) => { + const info = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions }) + return { id: info.id, info } +} + +const instructions = (server: string, text: string) => + new MCP.ServerInstructions({ server: MCP.ServerName.make(server), instructions: text }) + +const tool = (server: string, name = "search") => new MCP.Tool({ server: MCP.ServerName.make(server), name }) + +const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) => + AppNodeBuilder.build(McpInstructions.node, [ + [ + MCP.node, + Layer.mock(MCP.Service, { + instructions: () => Effect.succeed(catalog()), + tools: () => Effect.succeed(tools()), + }), + ], + ]) + +describe("McpInstructions", () => { + it.effect("renders instructions for servers with at least one permitted tool", () => + Effect.gen(function* () { + const service = yield* McpInstructions.Service + const generation = yield* service + .load( + selection([ + { action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" }, + { action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" }, + ]), + ) + .pipe(Effect.flatMap(readInitial)) + + expect(generation.text).toBe( + [ + "", + ' ', + ' Use tools from this server through `execute` under `tools["alpha"]`.', + " Alpha line one", + " Alpha line two", + " ", + ' ', + ' Use tools from this server through `execute` under `tools["beta"]`.', + " Beta instructions", + " ", + "", + ].join("\n"), + ) + }).pipe( + Effect.provide( + layer( + () => [ + instructions("beta", "Beta instructions"), + instructions("unused", "No tools"), + instructions("hidden", "Denied tool"), + instructions("alpha", "Alpha line one\nAlpha line two"), + ], + () => [tool("alpha"), tool("alpha", "restricted"), tool("beta"), tool("hidden")], + ), + ), + ), + ) + + it.effect("omits instructions when the agent cannot use execute", () => + Effect.gen(function* () { + const service = yield* McpInstructions.Service + const generation = yield* service + .load(selection([{ action: "execute", resource: "*", effect: "deny" }])) + .pipe(Effect.flatMap(readInitial)) + + expect(generation.text).toBe("") + }).pipe( + Effect.provide( + layer( + () => [instructions("alpha", "Alpha instructions")], + () => [tool("alpha")], + ), + ), + ), + ) + + it.effect("renders additions, changes, and removal", () => { + let catalog = [instructions("alpha", "Alpha instructions")] + const tools = [tool("alpha"), tool("beta")] + return Effect.gen(function* () { + const service = yield* McpInstructions.Service + const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial)) + + catalog = [instructions("alpha", "Alpha instructions"), instructions("beta", "Beta instructions")] + const added = yield* readUpdate(yield* service.load(selection()), initialized) + expect(added.text).toBe( + [ + "New MCP server instructions are available in addition to those previously listed:", + ' ', + ' Use tools from this server through `execute` under `tools["beta"]`.', + " Beta instructions", + " ", + ].join("\n"), + ) + + catalog = [instructions("alpha", "Updated alpha"), instructions("beta", "Beta instructions")] + const changed = yield* readUpdate(yield* service.load(selection()), added) + expect(changed.text).toBe( + [ + "The available MCP server instructions have changed. This list supersedes the previous one.", + "", + ' ', + ' Use tools from this server through `execute` under `tools["alpha"]`.', + " Updated alpha", + " ", + ' ', + ' Use tools from this server through `execute` under `tools["beta"]`.', + " Beta instructions", + " ", + "", + ].join("\n"), + ) + + catalog = [instructions("beta", "Beta instructions")] + const removed = yield* readUpdate(yield* service.load(selection()), changed) + expect(removed.text).toBe("Instructions for the following MCP servers are no longer available: alpha.") + + catalog = [] + expect((yield* readUpdate(yield* service.load(selection()), removed)).text).toBe( + "MCP server instructions are no longer available.", + ) + }).pipe( + Effect.provide( + layer( + () => catalog, + () => tools, + ), + ), + ) + }) +}) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index f6b39e86aa..fd6f99f538 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -29,7 +29,9 @@ import { McpTool } from "@opencode-ai/core/tool/mcp" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" +import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" +import { imagePassthrough } from "./lib/image" import { location } from "./fixture/location" import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" @@ -187,15 +189,20 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe active: unusedIntegration, resolve: unusedIntegration, key: unusedIntegration, - oauth: unusedIntegration, update: unusedIntegration, remove: unusedIntegration, }, - attempt: { + oauth: { + connect: unusedIntegration, status: unusedIntegration, complete: unusedIntegration, cancel: unusedIntegration, }, + command: { + connect: unusedIntegration, + status: unusedIntegration, + cancel: unusedIntegration, + }, }), Layer.mock(Credential.Service, {}), ), @@ -245,6 +252,7 @@ const it = testEffect( [PermissionV2.node, permissions], [EventV2.node, events], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [Image.node, imagePassthrough], ]), ) diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts index 1223aa0c94..4fdf9a9c54 100644 --- a/packages/core/test/plugin-hooks.test.ts +++ b/packages/core/test/plugin-hooks.test.ts @@ -12,17 +12,17 @@ const layer = PluginHooks.node.implementation as Layer.Layer { - it.effect("registers scoped AI hooks and triggers them sequentially", () => + it.effect("registers scoped session hooks and triggers them sequentially", () => Effect.gen(function* () { const hooks = yield* PluginHooks.Service const seen: string[] = [] - yield* hooks.register("ai", "request", (event) => + yield* hooks.register("session", "context", (event) => Effect.sync(() => { seen.push("first") event.system.push(SystemPart.make("second")) }), ) - yield* hooks.register("ai", "request", (event) => + yield* hooks.register("session", "context", (event) => Effect.sync(() => { seen.push(event.system[1]?.text ?? "missing") event.messages = [Message.user("changed")] @@ -37,7 +37,7 @@ describe("PluginHooks", () => { tools: {}, } - expect(yield* hooks.trigger("ai", "request", event)).toBe(event) + expect(yield* hooks.trigger("session", "context", event)).toBe(event) expect(seen).toEqual(["first", "second"]) expect(event.messages).toEqual([Message.user("changed")]) }), diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index ae7c66ec83..07a06e7744 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -5,22 +5,27 @@ import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" +import type { + IntegrationCommandMethod, + IntegrationEnvMethod, + IntegrationKeyMethod, + IntegrationOAuthMethod, +} from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" -type Overrides = Partial> +type Overrides = Partial> & { + readonly session?: Partial +} export function host(overrides: Overrides = {}): PluginContext { return { options: {}, agent: overrides.agent ?? { + get: () => Effect.die("unused agent.get"), list: () => Effect.die("unused agent.list"), transform: () => Effect.die("unused agent.transform"), reload: () => Effect.die("unused agent.reload"), }, - ai: overrides.ai ?? { - hook: () => Effect.die("unused ai.hook"), - }, aisdk: overrides.aisdk ?? { hook: () => Effect.die("unused aisdk.hook"), }, @@ -30,6 +35,7 @@ export function host(overrides: Overrides = {}): PluginContext { get: () => Effect.die("unused catalog.provider.get"), }, model: { + get: () => Effect.die("unused catalog.model.get"), list: () => Effect.die("unused catalog.model.list"), default: () => Effect.die("unused catalog.model.default"), }, @@ -49,12 +55,17 @@ export function host(overrides: Overrides = {}): PluginContext { get: () => Effect.die("unused integration.get"), connect: { key: () => Effect.die("unused integration.connect.key"), - oauth: () => Effect.die("unused integration.connect.oauth"), }, - attempt: { - status: () => Effect.die("unused integration.attempt.status"), - complete: () => Effect.die("unused integration.attempt.complete"), - cancel: () => Effect.die("unused integration.attempt.cancel"), + oauth: { + connect: () => Effect.die("unused integration.oauth.connect"), + status: () => Effect.die("unused integration.oauth.status"), + complete: () => Effect.die("unused integration.oauth.complete"), + cancel: () => Effect.die("unused integration.oauth.cancel"), + }, + command: { + connect: () => Effect.die("unused integration.command.connect"), + status: () => Effect.die("unused integration.command.status"), + cancel: () => Effect.die("unused integration.command.cancel"), }, transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), @@ -80,18 +91,21 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused tool.transform"), hook: () => Effect.die("unused tool.hook"), }, - session: overrides.session ?? { - create: () => Effect.die("unused session.create"), - get: () => Effect.die("unused session.get"), - prompt: () => Effect.die("unused session.prompt"), - command: () => Effect.die("unused session.command"), - interrupt: () => Effect.die("unused session.interrupt"), + session: { + hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")), + create: overrides.session?.create ?? (() => Effect.die("unused session.create")), + get: overrides.session?.get ?? (() => Effect.die("unused session.get")), + prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")), + command: overrides.session?.command ?? (() => Effect.die("unused session.command")), + synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")), + interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")), }, } } export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { return { + get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))), list: () => Effect.die("unused agent.list"), reload: agent.reload, transform: (callback) => @@ -122,6 +136,10 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog" get: () => Effect.die("unused catalog.provider.get"), }, model: { + get: (providerID, modelID) => + catalog.model + .get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)) + .pipe(Effect.map((value) => value && modelInfo(value))), list: () => Effect.die("unused catalog.model.list"), default: () => Effect.die("unused catalog.model.default"), }, @@ -193,12 +211,17 @@ export function integrationHost(integration: Integration.Interface): PluginConte get: () => Effect.die("unused integration.get"), connect: { key: () => Effect.die("unused integration.connect.key"), - oauth: () => Effect.die("unused integration.connect.oauth"), }, - attempt: { - status: () => Effect.die("unused integration.attempt.status"), - complete: () => Effect.die("unused integration.attempt.complete"), - cancel: () => Effect.die("unused integration.attempt.cancel"), + oauth: { + connect: () => Effect.die("unused integration.oauth.connect"), + status: () => Effect.die("unused integration.oauth.status"), + complete: () => Effect.die("unused integration.oauth.complete"), + cancel: () => Effect.die("unused integration.oauth.cancel"), + }, + command: { + connect: () => Effect.die("unused integration.command.connect"), + status: () => Effect.die("unused integration.command.status"), + cancel: () => Effect.die("unused integration.command.cancel"), }, reload: integration.reload, connection: { @@ -281,6 +304,17 @@ export function integrationHost(integration: Integration.Interface): PluginConte }) return } + if (input.method.type === "command") { + draft.method.update({ + integrationID: Integration.ID.make(input.integrationID), + method: { + ...input.method, + id: Integration.MethodID.make(input.method.id), + command: [...input.method.command], + }, + }) + return + } draft.method.update({ integrationID: Integration.ID.make(input.integrationID), method: input.method, @@ -296,6 +330,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte function method(value: Integration.Method) { if (value.type === "env") return { type: value.type, names: [...value.names] } if (value.type === "key") return { type: value.type, label: value.label } + if (value.type === "command") return { ...value, command: [...value.command] } return { type: value.type, id: value.id, @@ -308,10 +343,17 @@ function method(value: Integration.Method) { } function internalMethod( - value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod, + value: IntegrationOAuthMethod | IntegrationCommandMethod | IntegrationKeyMethod | IntegrationEnvMethod, ): Integration.Method { if (value.type === "env") return value if (value.type === "key") return value + if (value.type === "command") { + return { + ...value, + id: Integration.MethodID.make(value.id), + command: [...value.command], + } + } return { ...value, id: Integration.MethodID.make(value.id), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index e7577faa6a..34d8e1f975 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -1,24 +1,81 @@ import { describe, expect } from "bun:test" import { Message, SystemPart } from "@opencode-ai/ai" -import { Effect, Schema } from "effect" +import { DateTime, Effect, Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginPromise } from "@opencode-ai/core/plugin/promise" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionPending } from "@opencode-ai/core/session/pending" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Plugin } from "@opencode-ai/plugin/v2" -import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" +import { host as testHost } from "./host" const it = testEffect(PluginTestLayer) describe("fromPromise", () => { + it.effect("forwards synthetic session input", () => + Effect.gen(function* () { + const input = { + sessionID: "ses_synthetic", + id: "msg_synthetic", + text: "Background work completed", + description: null, + metadata: { shellID: "shell_1" }, + delivery: null, + resume: null, + } + let seen: unknown + const host = testHost({ + session: { + synthetic: (value) => { + seen = value + return Effect.succeed( + SessionPending.Synthetic.make({ + admittedSeq: 1, + id: SessionMessage.ID.make(input.id), + sessionID: SessionV2.ID.make(input.sessionID), + timeCreated: DateTime.makeUnsafe(0), + type: "synthetic", + data: { + text: input.text, + metadata: input.metadata, + }, + delivery: "queue", + }), + ) + }, + }, + }) + + yield* PluginPromise.fromPromise( + Plugin.define({ + id: "promise-session-synthetic", + setup: async (ctx) => { + await ctx.session.synthetic(input) + }, + }), + ).effect(host) + + expect(seen).toEqual({ + ...input, + description: undefined, + delivery: undefined, + resume: undefined, + }) + }), + ) + it.effect("forwards standard client reads", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service @@ -48,6 +105,37 @@ describe("fromPromise", () => { }), ) + it.effect("forwards direct agent and model reads", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.description = "Reviews code" + }), + ) + yield* catalog.transform((draft) => + draft.model.update(ProviderV2.ID.make("test"), ModelV2.ID.make("alias"), (model) => { + model.modelID = ModelV2.ID.make("gpt-5") + }), + ) + + yield* PluginPromise.fromPromise( + Plugin.define({ + id: "promise-direct-reads", + setup: async (ctx) => { + expect(await ctx.agent.get("reviewer")).toMatchObject({ description: "Reviews code" }) + expect(await ctx.agent.get("missing")).toBeUndefined() + expect(await ctx.catalog.model.get("test", "alias")).toMatchObject({ modelID: "gpt-5" }) + expect(await ctx.catalog.model.get("test", "missing")).toBeUndefined() + }, + }), + ).effect(host) + }), + ) + it.effect("loads a promise plugin and registers a transform hook", () => Effect.gen(function* () { const agents = yield* AgentV2.Service @@ -77,24 +165,24 @@ describe("fromPromise", () => { }), ) - it.effect("forwards AI request hooks", () => + it.effect("forwards session context hooks", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const hooks = yield* PluginHooks.Service const host = yield* PluginHost.make(plugin) yield* PluginPromise.fromPromise( Plugin.define({ - id: "promise-ai-request", + id: "promise-session-context", setup: async (ctx) => { - await ctx.ai.hook("request", (event) => { + await ctx.session.hook("context", (event) => { event.system.push(SystemPart.make("Promise hook")) delete event.tools.echo }) }, }), ).effect(host) - const event: AIHooks["request"] = { - sessionID: SessionV2.ID.make("ses_promise_ai_request"), + const event: SessionHooks["context"] = { + sessionID: SessionV2.ID.make("ses_promise_session_context"), agent: AgentV2.ID.make("build"), model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), system: [SystemPart.make("Initial")], @@ -102,7 +190,7 @@ describe("fromPromise", () => { tools: { echo: { description: "Echo", input: { type: "object" } } }, } - yield* hooks.trigger("ai", "request", event) + yield* hooks.trigger("session", "context", event) expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"]) expect(event.tools).toEqual({}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index dfb0e82d0a..1de22a03dd 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -124,13 +124,17 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { yield* addPlugin() const integrations = yield* Integration.Service - const attempt = yield* integrations.connection.oauth({ - integrationID: Integration.ID.make("opencode"), + const integrationID = Integration.ID.make("opencode") + const attempt = yield* integrations.oauth.connect({ + integrationID, methodID: Integration.MethodID.make("device"), inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` }, }) expect(attempt.url).toBe(`${server.url.origin}/verify`) - yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete") + yield* eventually( + integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }), + (status) => status.status === "complete", + ) expect(requests).toContain("POST /console/auth/device/code") expect(requests).toContain("POST /console/auth/device/token") @@ -147,8 +151,8 @@ describe("OpencodePlugin", () => { it.effect("rejects non-HTTP OpenCode servers", () => Effect.gen(function* () { yield* addPlugin() - const error = yield* (yield* Integration.Service).connection - .oauth({ + const error = yield* (yield* Integration.Service).oauth + .connect({ integrationID: Integration.ID.make("opencode"), methodID: Integration.MethodID.make("device"), inputs: { server: "ftp://console.example.com" }, diff --git a/packages/core/test/plugin/system-prompt.test.ts b/packages/core/test/plugin/system-prompt.test.ts new file mode 100644 index 0000000000..724ab681b1 --- /dev/null +++ b/packages/core/test/plugin/system-prompt.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test" +import { SystemPart } from "@opencode-ai/ai" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt" +import { SessionV2 } from "@opencode-ai/core/session" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" +import { Effect } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" +import PROMPT_META from "../../src/plugin/system-prompt/meta.txt" +import PROMPT_DEFAULT from "../../src/session/runner/prompt/base.txt" + +const it = testEffect(PluginTestLayer) +const fallback = PROMPT_DEFAULT +const makeHost = Effect.gen(function* () { + const plugins = yield* PluginV2.Service + return yield* PluginHost.make(plugins) +}) + +const context = (id: string, system = fallback): SessionHooks["context"] => ({ + sessionID: SessionV2.ID.make("ses_system_prompt"), + agent: AgentV2.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }), + system: [SystemPart.make(system)], + messages: [], + tools: {}, +}) + +describe("SystemPromptPlugin", () => { + test("uses V2 vocabulary in the Meta prompt", () => { + expect(PROMPT_META).toContain("webfetch tool") + expect(PROMPT_META).toContain("subagent tool") + expect(PROMPT_META).toContain("shell tool") + expect(PROMPT_META).toContain("read for reading files") + expect(PROMPT_META).toContain("edit for editing") + expect(PROMPT_META).toContain("write for creating files") + expect(PROMPT_META).toContain("https://v2.opencode.ai/llms.txt") + expect(PROMPT_META).not.toMatch( + /TodoWrite|Task tool|WebFetch|\bBash\b|Read for reading files|Edit for editing|Write for creating files|https:\/\/opencode\.ai\/docs/, + ) + }) + + test("uses granular IDs with a common prefix", () => { + expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([ + "opencode.system-prompt.openai", + "opencode.system-prompt.google", + "opencode.system-prompt.anthropic", + "opencode.system-prompt.kimi", + "opencode.system-prompt.arcee", + "opencode.system-prompt.meta", + ]) + }) + + it.effect("selects model-lab prompts through session context hooks", () => + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + const pluginHost = yield* makeHost + yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { + discard: true, + }) + const cases = [ + ["gpt-5", "You are OpenCode, You and the user share the same workspace"], + ["gpt-4.1", "You are OpenCode, You and the user share the same workspace"], + ["o3", "You are OpenCode, You and the user share the same workspace"], + ["gpt-5-codex", "## Editing constraints"], + ["gemini-2.5-pro", "# Core Mandates"], + ["claude-sonnet-4", "# Professional objectivity"], + ["kimi-k2", "# Prompt and Tool Use"], + ["trinity", "what command should I run to list files"], + ["meta/muse-spark-1.1", "OpenCode powered by Meta Muse Spark"], + ["llama-3.3", "You are opencode, an interactive CLI tool"], + ] as const + + yield* Effect.forEach( + cases, + ([id, expected]) => { + const event = context(id) + return hooks + .trigger("session", "context", event) + .pipe(Effect.tap(() => Effect.sync(() => expect(event.system[0]?.text).toContain(expected)))) + }, + { discard: true }, + ) + }), + ) + + it.effect("preserves an explicit agent system prompt", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const hooks = yield* PluginHooks.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("build"), (agent) => { + agent.system = "Custom agent prompt" + }), + ) + const pluginHost = yield* makeHost + yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { + discard: true, + }) + const event = context("gpt-5", "Custom agent prompt") + + yield* hooks.trigger("session", "context", event) + + expect(event.system.map((part) => part.text)).toEqual(["Custom agent prompt"]) + }), + ) + + it.effect("allows one model-lab prompt plugin to be enabled independently", () => + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + const pluginHost = yield* makeHost + yield* SystemPromptPlugin.GooglePlugin.effect(pluginHost) + const gemini = context("gemini-2.5-pro") + const claude = context("claude-sonnet-4") + + yield* hooks.trigger("session", "context", gemini) + yield* hooks.trigger("session", "context", claude) + + expect(gemini.system[0]?.text).toContain("# Core Mandates") + expect(claude.system[0]?.text).toBe(fallback) + }), + ) + + it.effect("selects against the catalog model ID instead of its alias", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const hooks = yield* PluginHooks.Service + const pluginHost = yield* makeHost + yield* catalog.transform((draft) => { + draft.model.update(Provider.ID.make("test"), Model.ID.make("openai-alias"), (model) => { + model.modelID = Model.ID.make("gpt-5") + }) + draft.model.update(Provider.ID.make("test"), Model.ID.make("gpt-5-alias"), (model) => { + model.modelID = Model.ID.make("custom-model") + }) + draft.model.update(Provider.ID.make("test"), Model.ID.make("codex-family-alias"), (model) => { + model.modelID = Model.ID.make("custom-deployment") + model.family = Model.Family.make("gpt-codex") + }) + }) + yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost) + const physicalOpenAI = context("openai-alias") + const physicalCustom = context("gpt-5-alias") + const familyOpenAI = context("codex-family-alias") + + yield* hooks.trigger("session", "context", physicalOpenAI) + yield* hooks.trigger("session", "context", physicalCustom) + yield* hooks.trigger("session", "context", familyOpenAI) + + expect(physicalOpenAI.system[0]?.text).toContain("You are OpenCode, You and the user share the same workspace") + expect(physicalCustom.system[0]?.text).toBe(fallback) + expect(familyOpenAI.system[0]?.text).toContain("## Editing constraints") + }), + ) +}) diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-instructions.test.ts similarity index 69% rename from packages/core/test/reference-guidance.test.ts rename to packages/core/test/reference-instructions.test.ts index cdd3b36898..5cfac6903b 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-instructions.test.ts @@ -3,18 +3,18 @@ import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AbsolutePath } from "@opencode-ai/core/schema" import { Reference } from "@opencode-ai/core/reference" -import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions" import { it } from "./lib/effect" import { readInitial, readUpdate } from "./lib/instructions" -const guidanceLayer = (referenceLayer: Layer.Layer) => - AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]]) +const instructionsLayer = (referenceLayer: Layer.Layer) => + AppNodeBuilder.build(ReferenceInstructions.node, [[Reference.node, referenceLayer]]) -describe("ReferenceGuidance", () => { +describe("ReferenceInstructions", () => { it.effect("lists available references in the instructions", () => Effect.gen(function* () { - const guidance = yield* ReferenceGuidance.Service - const generation = yield* readInitial(yield* guidance.load()) + const instructions = yield* ReferenceInstructions.Service + const generation = yield* readInitial(yield* instructions.load()) expect(generation.text).toContain("") expect(generation.text).toContain("docs") @@ -22,7 +22,7 @@ describe("ReferenceGuidance", () => { expect(generation.text).toContain("Use for product documentation") }).pipe( Effect.provide( - guidanceLayer( + instructionsLayer( Layer.mock(Reference.Service, { list: () => Effect.succeed([ @@ -43,22 +43,22 @@ describe("ReferenceGuidance", () => { ), ) - it.effect("omits guidance when no references are available", () => + it.effect("omits instructions when no references are available", () => Effect.gen(function* () { - const guidance = yield* ReferenceGuidance.Service - const generation = yield* readInitial(yield* guidance.load()) + const instructions = yield* ReferenceInstructions.Service + const generation = yield* readInitial(yield* instructions.load()) expect(generation.text).toBe("") - }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))), + }).pipe(Effect.provide(instructionsLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))), ) it.effect("omits references without descriptions", () => Effect.gen(function* () { - const guidance = yield* ReferenceGuidance.Service - const generation = yield* readInitial(yield* guidance.load()) + const instructions = yield* ReferenceInstructions.Service + const generation = yield* readInitial(yield* instructions.load()) expect(generation.text).toBe("") }).pipe( Effect.provide( - guidanceLayer( + instructionsLayer( Layer.mock(Reference.Service, { list: () => Effect.succeed([ @@ -84,11 +84,11 @@ describe("ReferenceGuidance", () => { }) let references = [reference("docs", "Use for product documentation")] return Effect.gen(function* () { - const guidance = yield* ReferenceGuidance.Service - const initialized = yield* readInitial(yield* guidance.load()) + const instructions = yield* ReferenceInstructions.Service + const initialized = yield* readInitial(yield* instructions.load()) references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")] - const added = yield* readUpdate(yield* guidance.load(), initialized) + const added = yield* readUpdate(yield* instructions.load(), initialized) expect(added.text).toBe( [ "New project references are available in addition to those previously listed:", @@ -101,9 +101,11 @@ describe("ReferenceGuidance", () => { ) references = [reference("examples", "Use for examples")] - expect((yield* readUpdate(yield* guidance.load(), added)).text).toBe( + expect((yield* readUpdate(yield* instructions.load(), added)).text).toBe( "The following project references are no longer available and must not be used: docs.", ) - }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) })))) + }).pipe( + Effect.provide(instructionsLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) }))), + ) }) }) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts new file mode 100644 index 0000000000..a66ac6a69e --- /dev/null +++ b/packages/core/test/session-generate.test.ts @@ -0,0 +1,303 @@ +import { expect } from "bun:test" +import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { llmClient } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" +import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" +import { Instructions } from "@opencode-ai/core/instructions" +import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins" +import { Location } from "@opencode-ai/core/location" +import { McpInstructions } from "@opencode-ai/core/mcp/instructions" +import { ModelV2 } from "@opencode-ai/core/model" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionGenerate } from "@opencode-ai/core/session/generate" +import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node" +import { InstructionState } from "@opencode-ai/core/session/instruction-state" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { + InstructionBlobTable, + InstructionStateTable, + SessionMessageTable, + SessionPendingTable, + SessionTable, +} from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SkillInstructions } from "@opencode-ai/core/skill/instructions" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { asc, eq } from "drizzle-orm" +import { Effect, Layer, Schema, Stream } from "effect" +import { testEffect } from "./lib/effect" + +const requests: LLMRequest[] = [] +let instruction: string | Instructions.Unavailable = "Initial context" +const sessionID = SessionSchema.ID.make("ses_generate_test") + +const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route }) +const client = Layer.mock(LLMClient.Service)({ + prepare: () => Effect.die(new Error("unused")), + stream: () => Stream.die(new Error("unused")), + generate: (request) => + Effect.sync(() => { + requests.push(request) + const response = LLMResponse.fromEvents([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "generate" }), + LLMEvent.textDelta({ id: "generate", text: "Transient answer" }), + LLMEvent.textEnd({ id: "generate" }), + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }), + LLMEvent.finish({ reason: "stop" }), + ]) + if (!response) throw new Error("Incomplete generate response") + return response + }), +}) +const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) +const builtins = Layer.mock(InstructionBuiltIns.Service, { + load: () => + Effect.succeed( + Instructions.make({ + key: Instructions.Key.make("test/context"), + codec: Schema.toCodecJson(Schema.String), + read: Effect.sync(() => instruction), + render: { + initial: String, + changed: (_previous, current) => current, + }, + }), + ), +}) +const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) +const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) +const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) +const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) +const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void }) + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionProjector.node, + SessionStore.node, + AgentV2.node, + InstructionBuiltIns.node, + PluginHooks.node, + SessionGenerateNode.node, + ]), + [ + [llmClient, client], + [SessionRunnerModel.node, models], + [InstructionBuiltIns.node, builtins], + [InstructionDiscovery.node, discovery], + [SkillInstructions.node, skills], + [ReferenceInstructions.node, references], + [McpInstructions.node, mcp], + [PluginSupervisor.node, plugins], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + ], + ), +) + +const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID) => + Effect.all({ + sequence: EventV2.latestSequence(db, sessionID), + events: db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, sessionID)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie), + messages: db + .select() + .from(SessionMessageTable) + .where(eq(SessionMessageTable.session_id, sessionID)) + .orderBy(asc(SessionMessageTable.seq)) + .all() + .pipe(Effect.orDie), + pending: db + .select() + .from(SessionPendingTable) + .where(eq(SessionPendingTable.session_id, sessionID)) + .orderBy(asc(SessionPendingTable.admitted_seq)) + .all() + .pipe(Effect.orDie), + instructions: db + .select() + .from(InstructionStateTable) + .where(eq(InstructionStateTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie), + blobs: db.select().from(InstructionBlobTable).orderBy(asc(InstructionBlobTable.hash)).all().pipe(Effect.orDie), + session: db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie), + }) + +const userTexts = (request: LLMRequest) => + request.messages.flatMap((message) => + message.role === "user" + ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) + : [], + ) + +const setup = Effect.gen(function* () { + const { db } = yield* Database.Service + const events = yield* EventV2.Service + const agents = yield* AgentV2.Service + const instructionBuiltIns = yield* InstructionBuiltIns.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("build"), (agent) => { + agent.mode = "primary" + }), + ) + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "generate-test", + directory: "/project", + title: "Generate test", + version: "test", + agent: AgentV2.ID.make("build"), + }) + .run() + .pipe(Effect.orDie) + return { db, events, instructions: yield* instructionBuiltIns.load(sessionID) } +}) + +it.effect("generates from fresh settled Session context without durable mutation", () => + Effect.gen(function* () { + requests.length = 0 + instruction = "Initial context" + const { db, events, instructions } = yield* setup + yield* InstructionState.prepare(db, events, instructions, sessionID) + const existing = SessionMessage.ID.create() + yield* events.publish(SessionEvent.InputAdmitted, { + sessionID, + inputID: existing, + input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" }, + }) + yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing }) + const settledAssistant = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { + sessionID, + assistantMessageID: settledAssistant, + agent: AgentV2.ID.make("build"), + model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") }, + }) + yield* events.publish(SessionEvent.Text.Started, { + sessionID, + assistantMessageID: settledAssistant, + ordinal: 0, + }) + yield* events.publish(SessionEvent.Text.Ended, { + sessionID, + assistantMessageID: settledAssistant, + ordinal: 0, + text: "Settled partial answer", + }) + const activeAssistant = SessionMessage.ID.create() + yield* events.publish(SessionEvent.Step.Started, { + sessionID, + assistantMessageID: activeAssistant, + agent: AgentV2.ID.make("build"), + model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") }, + }) + yield* events.publish(SessionEvent.Tool.Input.Started, { + sessionID, + assistantMessageID: activeAssistant, + callID: "active-call", + name: "echo", + }) + yield* events.publish(SessionEvent.Tool.Input.Ended, { + sessionID, + assistantMessageID: activeAssistant, + callID: "active-call", + text: "{}", + }) + yield* events.publish(SessionEvent.Tool.Called, { + sessionID, + assistantMessageID: activeAssistant, + callID: "active-call", + input: {}, + executed: false, + }) + yield* events.publish(SessionEvent.InputAdmitted, { + sessionID, + inputID: SessionMessage.ID.create(), + input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" }, + }) + instruction = "Changed context" + const before = yield* durableState(db, sessionID) + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "context", (event) => + Effect.sync(() => { + event.system = [SystemPart.make("Hooked system"), ...event.system] + }), + ) + + const generate = yield* SessionGenerate.Service + const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" }) + + expect(result).toBe("Transient answer") + expect(requests).toHaveLength(1) + expect(requests[0]?.model).toBe(model) + expect(requests[0]?.system[0]?.text).toBe("Hooked system") + expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context") + expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID }) + expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } }) + expect( + requests[0]?.messages.flatMap((message) => + message.role === "system" + ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) + : [], + ), + ).toEqual(["Changed context"]) + expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"]) + expect( + requests[0]?.messages.flatMap((message) => + message.role === "assistant" + ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) + : [], + ), + ).toEqual(["Settled partial answer"]) + expect(requests[0]?.tools).toEqual([]) + expect(requests[0]?.toolChoice).toMatchObject({ type: "none" }) + expect(yield* durableState(db, sessionID)).toEqual(before) + }), +) + +it.effect("blocks unavailable initial instructions before generation", () => + Effect.gen(function* () { + requests.length = 0 + instruction = Instructions.unavailable + const { db } = yield* setup + const before = yield* durableState(db, sessionID) + const generate = yield* SessionGenerate.Service + + const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(Instructions.InitializationBlocked) + expect(requests).toEqual([]) + expect(yield* durableState(db, sessionID)).toEqual(before) + }), +) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 7ea2633f62..b86e19444a 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect" import { mkdtemp, rm } from "fs/promises" import { tmpdir } from "os" import path from "path" @@ -24,7 +24,10 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionPending } from "@opencode-ai/core/session/pending" import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationServices } from "@opencode-ai/core/location-services" import { testEffect } from "./lib/effect" +import { imagePassthrough } from "./lib/image" const executionCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = [] @@ -49,10 +52,22 @@ const execution = Layer.succeed( awaitIdle: () => Effect.void, }), ) +const locations = Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + () => + // Attachment admission only needs the location-scoped Image service. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + imagePassthrough as unknown as Layer.Layer, + ), +) const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), - [[SessionExecution.node, execution]], + [ + [SessionExecution.node, execution], + [LocationServiceMap.node, locations], + ], ), ) const sessionID = SessionV2.ID.make("ses_prompt_test") diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 19f7d1999f..c2f942cd09 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -556,7 +556,10 @@ describe("SessionRunnerModel", () => { }) const packages = [ ["@opencode-ai/ai/providers/google-vertex", "accessToken"], - ["@opencode-ai/ai/providers/google-vertex/anthropic", "accessToken"], + ["@opencode-ai/ai/providers/google-vertex/gemini", "accessToken"], + ["@opencode-ai/ai/providers/google-vertex/chat", "accessToken"], + ["@opencode-ai/ai/providers/google-vertex/responses", "accessToken"], + ["@opencode-ai/ai/providers/google-vertex/messages", "accessToken"], ["@opencode-ai/ai/providers/anthropic", "authToken"], ["@opencode-ai/ai/providers/anthropic-compatible", "authToken"], ] as const diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 58893bbb0f..beee5d476d 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -1,6 +1,7 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat" import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/ai/route" +import { Catalog } from "@opencode-ai/core/catalog" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" @@ -32,15 +33,18 @@ import { Location } from "@opencode-ai/core/location" import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { Instructions } from "@opencode-ai/core/instructions" -import { SkillGuidance } from "@opencode-ai/core/skill/guidance" -import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" -import { McpGuidance } from "@opencode-ai/core/mcp/guidance" +import { SkillInstructions } from "@opencode-ai/core/skill/instructions" +import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions" +import { McpInstructions } from "@opencode-ai/core/mcp/instructions" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" +import { agentHost, catalogHost, host } from "./plugin/host" const cassetteName = "session-runner/openai-chat-streams-text" const cassetteDirectory = path.resolve(import.meta.dir, "fixtures/recordings") @@ -72,11 +76,27 @@ const model = OpenAIChat.route const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) -const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) -const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) -const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) +const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) +const referenceInstructions = Layer.mock(ReferenceInstructions.Service, { + load: () => Effect.succeed(Instructions.empty), +}) +const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })) +const promptCatalog = Layer.mock(Catalog.Service, { + provider: { + get: () => Effect.succeed(undefined), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + }, + model: { + get: () => Effect.succeed(undefined), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + default: () => Effect.succeed(undefined), + small: () => Effect.succeed(undefined), + }, +}) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -84,9 +104,9 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [InstructionBuiltIns.node, systemContext], [InstructionDiscovery.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillGuidance.node, skillGuidance], - [ReferenceGuidance.node, referenceGuidance], - [McpGuidance.node, mcpGuidance], + [SkillInstructions.node, skillInstructions], + [ReferenceInstructions.node, referenceInstructions], + [McpInstructions.node, mcpInstructions], [Config.node, config], [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], @@ -116,12 +136,14 @@ const it = testEffect( SessionProjector.node, SessionStore.node, AgentV2.node, + Catalog.node, + PluginHooks.node, ToolRegistry.node, SessionRunnerModel.node, InstructionBuiltIns.node, InstructionDiscovery.node, - SkillGuidance.node, - ReferenceGuidance.node, + SkillInstructions.node, + ReferenceInstructions.node, Config.node, Snapshot.node, SessionRunnerLLM.node, @@ -130,13 +152,14 @@ const it = testEffect( [ [LayerNodePlatform.llmClient, client], [PermissionV2.node, permission], + [Catalog.node, promptCatalog], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [SessionRunnerModel.node, models], [InstructionBuiltIns.node, systemContext], [InstructionDiscovery.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillGuidance.node, skillGuidance], - [ReferenceGuidance.node, referenceGuidance], + [SkillInstructions.node, skillInstructions], + [ReferenceInstructions.node, referenceInstructions], [Config.node, config], [Snapshot.node, Snapshot.noopLayer], [PluginSupervisor.node, pluginSupervisor], @@ -150,11 +173,19 @@ describe("SessionRunnerLLM recorded", () => { it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () => Effect.gen(function* () { const agents = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const hooks = yield* PluginHooks.Service yield* agents.transform((draft) => draft.update(AgentV2.ID.make("build"), (agent) => { agent.mode = "primary" }), ) + const pluginHost = host({ + agent: agentHost(agents), + catalog: catalogHost(catalog), + session: { hook: (name, callback) => hooks.register("session", name, callback) }, + }) + yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true }) const { db } = yield* Database.Service yield* db .insert(ProjectTable) diff --git a/packages/core/test/session-runner-system-prompt.test.ts b/packages/core/test/session-runner-system-prompt.test.ts deleted file mode 100644 index a367d913e5..0000000000 --- a/packages/core/test/session-runner-system-prompt.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Model } from "@opencode-ai/ai" -import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat" -import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" - -const prompt = (id: string) => - SessionRunnerSystemPrompt.provider(Model.make({ id, provider: "test", route: OpenAIChat.route })) - -describe("SessionRunnerSystemPrompt", () => { - test("selects the legacy provider-family prompts from the model id", () => { - expect(prompt("gpt-5")).toContain("You are OpenCode, You and the user share the same workspace") - expect(prompt("gpt-4.1")).toContain("THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH") - expect(prompt("o3")).toContain("THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH") - expect(prompt("gpt-5-codex")).toContain("## Editing constraints") - expect(prompt("gemini-2.5-pro")).toContain("# Core Mandates") - expect(prompt("claude-sonnet-4")).toContain("# Professional objectivity") - expect(prompt("kimi-k2")).toContain("# Prompt and Tool Use") - expect(prompt("trinity")).toContain("what command should I run to list files") - expect(prompt("llama-3.3")).toContain("You are opencode, an interactive CLI tool") - }) -}) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index c488c1f7fc..6cb012312b 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/core/tool/tool" import { AgentV2 } from "@opencode-ai/core/agent" import type { PermissionV2 } from "@opencode-ai/core/permission" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Image } from "@opencode-ai/core/image" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" @@ -28,7 +29,28 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { ) }, }) -const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) +const imageStore = Layer.mock(Image.Service, { + normalize: (resource, content) => { + if (resource === "corrupt.png") return Effect.fail(new Image.DecodeError({ resource })) + if (resource === "too-large.png") + return Effect.fail( + new Image.SizeError({ + resource, + width: 9_000, + height: 9_000, + bytes: content.content.length, + maxWidth: 2_000, + maxHeight: 2_000, + maxBytes: 5, + }), + ) + return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" }) + }, +}) +const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [ + [ToolOutputStore.node, outputStore], + [Image.node, imageStore], +]) const it = testEffect(registryLayer) const identity = { agent: AgentV2.ID.make("build"), @@ -107,17 +129,6 @@ describe("ToolRegistry", () => { }), ) - it.effect("reuses model definitions across requests", () => - Effect.gen(function* () { - const service = yield* ToolRegistry.Service - yield* service.register({ echo: make() }, { codemode: false }) - const first = yield* toolDefinitions(service) - const second = yield* toolDefinitions(service) - - expect(second[0]).toBe(first[0]) - }), - ) - it.effect("removes a scoped registration", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service @@ -266,6 +277,75 @@ describe("ToolRegistry", () => { }), ) + it.effect("normalizes image tool output at settlement and drops unresizable images", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + yield* service.register({ + snapshot: Tool.make({ + description: "Return images", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => Effect.succeed({ text }), + toModelOutput: ({ output }) => [ + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" }, + { type: "text", text: output.text }, + ], + }), + }, { codemode: false }) + + const settlement = yield* settleTool(service, call("snapshot")) + expect(settlement.output?.content).toEqual([ + { type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" }, + { type: "text", text: "snapshot" }, + { type: "text", text: "[1 image omitted: could not be decoded.]" }, + { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, + ]) + }), + ) + + it.effect("normalizes image progress content before it is published", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + yield* service.register({ + progressive: Tool.make({ + description: "Emit image progress", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }, context) => + context + .progress({ + structured: { stage: "capture" }, + content: [ + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, + ], + }) + .pipe(Effect.as({ text })), + }), + }, { codemode: false }) + + const updates: ToolRegistry.Progress[] = [] + yield* settleTool(service, { + ...call("progressive"), + progress: (update) => + Effect.sync(() => { + updates.push(update) + }), + }) + expect(updates).toEqual([ + { + structured: { stage: "capture" }, + content: [ + { type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" }, + { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, + ], + }, + ]) + }), + ) + it.effect("enforces transformed codecs at execution and projection boundaries", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 2585355fe7..0bb14b020f 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -15,6 +15,7 @@ import { type LLMRequest, } from "@opencode-ai/ai" import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat" +import { Catalog } from "@opencode-ai/core/catalog" import { Database } from "@opencode-ai/core/database/database" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -41,10 +42,10 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" -import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt" import { QuestionTool } from "@opencode-ai/core/tool/question" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { AgentV2 } from "@opencode-ai/core/agent" @@ -62,9 +63,9 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { Instructions } from "@opencode-ai/core/instructions" import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" -import { SkillGuidance } from "@opencode-ai/core/skill/guidance" -import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" -import { McpGuidance } from "@opencode-ai/core/mcp/guidance" +import { SkillInstructions } from "@opencode-ai/core/skill/instructions" +import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions" +import { McpInstructions } from "@opencode-ai/core/mcp/instructions" import { ModelV2 } from "@opencode-ai/core/model" import { Location } from "@opencode-ai/core/location" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -72,6 +73,8 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, S import { TestClock } from "effect/testing" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" +import { agentHost, catalogHost, host } from "./plugin/host" +import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt" const requests: LLMRequest[] = [] let response: LLMEvent[] = [] @@ -137,7 +140,7 @@ const reply = { ], } const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) -const defaultSystem = SessionRunnerSystemPrompt.provider(model) +const defaultSystem = PROMPT_DEFAULT const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route }) const compactModel = Model.make({ id: "compact", @@ -315,7 +318,7 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, { ), }) const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) -const skillGuidance = Layer.mock(SkillGuidance.Service, { +const skillInstructions = Layer.mock(SkillInstructions.Service, { load: (agent) => Effect.succeed( skillBaselines.has(agent.id) @@ -332,8 +335,10 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { : Instructions.empty, ), }) -const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) -const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) +const referenceInstructions = Layer.mock(ReferenceInstructions.Service, { + load: () => Effect.succeed(Instructions.empty), +}) +const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const config = Layer.succeed( Config.Service, Config.Service.of({ @@ -358,6 +363,20 @@ const pluginSupervisor = Layer.succeed( flush: Effect.suspend(() => pluginFlushHook), }), ) +const promptCatalog = Layer.mock(Catalog.Service, { + provider: { + get: () => Effect.succeed(undefined), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + }, + model: { + get: () => Effect.succeed(undefined), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + default: () => Effect.succeed(undefined), + small: () => Effect.succeed(undefined), + }, +}) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -365,11 +384,11 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [InstructionBuiltIns.node, systemContext], [InstructionDiscovery.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillGuidance.node, skillGuidance], - [ReferenceGuidance.node, referenceGuidance], + [SkillInstructions.node, skillInstructions], + [ReferenceInstructions.node, referenceInstructions], [PermissionV2.node, permission], [Config.node, config], - [McpGuidance.node, mcpGuidance], + [McpInstructions.node, mcpInstructions], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [PluginSupervisor.node, pluginSupervisor], ]) @@ -398,6 +417,7 @@ const it = testEffect( SessionProjector.node, SessionStore.node, AgentV2.node, + Catalog.node, ToolRegistry.node, ToolRegistry.toolsNode, PluginHooks.node, @@ -406,8 +426,8 @@ const it = testEffect( InstructionBuiltIns.node, InstructionDiscovery.node, InstructionEntry.node, - SkillGuidance.node, - ReferenceGuidance.node, + SkillInstructions.node, + ReferenceInstructions.node, Config.node, Snapshot.node, SessionRunnerLLM.node, @@ -417,12 +437,13 @@ const it = testEffect( [ [LayerNodePlatform.llmClient, client], [PermissionV2.node, permission], + [Catalog.node, promptCatalog], [SessionRunnerModel.node, models], [InstructionBuiltIns.node, systemContext], [InstructionDiscovery.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillGuidance.node, skillGuidance], - [ReferenceGuidance.node, referenceGuidance], + [SkillInstructions.node, skillInstructions], + [ReferenceInstructions.node, referenceInstructions], [Snapshot.node, Snapshot.noopLayer], [SessionExecution.node, execution], [Config.node, config], @@ -455,6 +476,17 @@ const insertSession = (id: SessionV2.ID) => const setup = Effect.gen(function* () { const { db } = yield* Database.Service + const agents = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const hooks = yield* PluginHooks.Service + const pluginHost = host({ + agent: agentHost(agents), + catalog: catalogHost(catalog), + session: { hook: (name, callback) => hooks.register("session", name, callback) }, + }) + yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { + discard: true, + }) requests.length = 0 authorizations.length = 0 executions.length = 0 @@ -478,7 +510,6 @@ const setup = Effect.gen(function* () { toolExecutionsReady = 5 activeToolExecutions = 0 maxActiveToolExecutions = 0 - const agents = yield* AgentV2.Service yield* agents.transform((draft) => draft.update(AgentV2.ID.make("build"), (agent) => { agent.mode = "primary" @@ -777,11 +808,11 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => }) describe("SessionRunnerLLM", () => { - it.effect("applies AI request hooks without exposing unavailable tools", () => + it.effect("applies session context hooks without exposing unavailable tools", () => Effect.gen(function* () { const session = yield* setup const hooks = yield* PluginHooks.Service - yield* hooks.register("ai", "request", (event) => + yield* hooks.register("session", "context", (event) => Effect.sync(() => { event.system = [SystemPart.make("Hooked system")] event.messages = [Message.user("Hooked message")] @@ -1395,7 +1426,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("updates selected-agent skill guidance after an agent switch", () => + it.effect("updates selected-agent skill instructions after an agent switch", () => Effect.gen(function* () { const session = yield* setup const events = yield* EventV2.Service @@ -3997,11 +4028,19 @@ describe("SessionRunnerLLM", () => { { attempt: 5, at: 30_000 }, ]) expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5) - expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) + const assistant = requireAssistant(yield* session.context(sessionID)) + expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([ + { type: "session.step.started.1" }, + { type: "session.step.started.1" }, + { type: "session.step.started.1" }, + { type: "session.step.started.1" }, + { type: "session.step.started.1" }, + { type: "session.step.failed.1" }, + ]) }), ) - it.effect("counts retry attempts against the agent step allowance", () => + it.effect("retries a physical attempt without consuming the logical agent step", () => Effect.gen(function* () { const session = yield* setup const agents = yield* AgentV2.Service @@ -4010,21 +4049,36 @@ describe("SessionRunnerLLM", () => { agent.steps = 2 }), ) - yield* admit(session, "Bound retries by steps") + yield* admit(session, "Retry without consuming a step") const failure = providerUnavailable() responseStream = Stream.fail(failure) - streamFailure = failure + responses = [reply.tool("call-after-retry", "echo", { text: "recovered" }), reply.stop()] const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow yield* TestClock.adjust("2 seconds") - expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure) + yield* Fiber.join(run) - expect(requests).toHaveLength(2) + expect(requests).toHaveLength(3) + expect(requests[0]?.toolChoice).toBeUndefined() + expect(requests[0]?.tools.map((tool) => tool.name)).toContain("echo") + expect(requests[1]?.toolChoice).toBeUndefined() + expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo") + expect(requests[1]?.messages.at(-1)).not.toMatchObject({ + role: "assistant", + content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], + }) + expect(requests[2]?.toolChoice).toMatchObject({ type: "none" }) + expect(requests[2]?.tools).toEqual([]) + expect(requests[2]?.messages.at(-1)).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], + }) + expect(executions).toEqual(["recovered"]) const eventTypes = yield* recordedEventTypes(sessionID) - expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2) + expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(3) expect(eventTypes.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1) - expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1) + expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(2) }), ) diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/instructions.test.ts similarity index 77% rename from packages/core/test/skill/guidance.test.ts rename to packages/core/test/skill/instructions.test.ts index c2816661d9..9ca431bc6b 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/instructions.test.ts @@ -5,7 +5,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" -import { SkillGuidance } from "@opencode-ai/core/skill/guidance" +import { SkillInstructions } from "@opencode-ai/core/skill/instructions" import { it } from "../lib/effect" import { readInitial, readUpdate } from "../lib/instructions" @@ -40,11 +40,11 @@ const manual = SkillV2.Info.make({ }) const layer = (list: () => SkillV2.Info[]) => - AppNodeBuilder.build(SkillGuidance.node, [ + AppNodeBuilder.build(SkillInstructions.node, [ [SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })], ]) -describe("SkillGuidance", () => { +describe("SkillInstructions", () => { it.effect("renders described agent skills and updates the complete available list", () => { const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), @@ -52,8 +52,8 @@ describe("SkillGuidance", () => { }) let skills = [hidden, denied, manual, effect] return Effect.gen(function* () { - const guidance = yield* SkillGuidance.Service - const initialized = yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) + const instructions = yield* SkillInstructions.Service + const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) expect(initialized.text).toBe( [ @@ -72,7 +72,7 @@ describe("SkillGuidance", () => { skills = [] expect( - yield* guidance + yield* instructions .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap((context) => readUpdate(context, initialized))), ).toMatchObject({ text: "Skill guidance is no longer available. Do not use any previously listed skill." }) @@ -90,11 +90,11 @@ describe("SkillGuidance", () => { }) let skills = [effect] return Effect.gen(function* () { - const guidance = yield* SkillGuidance.Service - const initialized = yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) + const instructions = yield* SkillInstructions.Service + const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) skills = [effect, debugging] - const added = yield* guidance + const added = yield* instructions .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap((context) => readUpdate(context, initialized))) expect(added.text).toBe( @@ -109,7 +109,7 @@ describe("SkillGuidance", () => { ) skills = [debugging] - const removed = yield* guidance + const removed = yield* instructions .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap((context) => readUpdate(context, added))) expect(removed.text).toBe("The following skill IDs are no longer available and must not be used: effect.") @@ -120,12 +120,12 @@ describe("SkillGuidance", () => { const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) let skills = [effect] return Effect.gen(function* () { - const guidance = yield* SkillGuidance.Service - const initialized = yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) + const instructions = yield* SkillInstructions.Service + const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial)) skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })] expect( - yield* guidance + yield* instructions .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap((context) => readUpdate(context, initialized))), ).toMatchObject({ @@ -136,18 +136,18 @@ describe("SkillGuidance", () => { }).pipe(Effect.provide(layer(() => skills))) }) - it.effect("omits guidance when the selected agent denies all skills", () => { + it.effect("omits instructions when the selected agent denies all skills", () => { const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [{ action: "skill", resource: "*", effect: "deny" }], }) return Effect.gen(function* () { - const guidance = yield* SkillGuidance.Service - expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("") + const instructions = yield* SkillInstructions.Service + expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("") }).pipe(Effect.provide(layer(() => [effect]))) }) - it.effect("omits guidance when a resource-specific denial follows the global denial", () => { + it.effect("omits instructions when a resource-specific denial follows the global denial", () => { const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [ @@ -156,8 +156,8 @@ describe("SkillGuidance", () => { ], }) return Effect.gen(function* () { - const guidance = yield* SkillGuidance.Service - expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("") + const instructions = yield* SkillInstructions.Service + expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("") }).pipe(Effect.provide(layer(() => [effect]))) }) @@ -170,14 +170,14 @@ describe("SkillGuidance", () => { ], }) return Effect.gen(function* () { - const guidance = yield* SkillGuidance.Service - expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toContain( - "Effect", - ) + const instructions = yield* SkillInstructions.Service + expect( + (yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text, + ).toContain("Effect") }).pipe(Effect.provide(layer(() => [effect]))) }) - it.effect("omits guidance when a specifically allowed skill is denied again", () => { + it.effect("omits instructions when a specifically allowed skill is denied again", () => { const agent = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions: [ @@ -187,8 +187,8 @@ describe("SkillGuidance", () => { ], }) return Effect.gen(function* () { - const guidance = yield* SkillGuidance.Service - expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("") + const instructions = yield* SkillInstructions.Service + expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("") }).pipe(Effect.provide(layer(() => [effect]))) }) }) diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index 505cd724e7..f2120356c6 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { State } from "@opencode-ai/core/state" import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" +import { TestClock } from "effect/testing" import { testEffect } from "./lib/effect" const it = testEffect(Layer.empty) @@ -35,7 +36,7 @@ describe("State", () => { }), ) - it.effect("runs effectful transforms during every reload", () => + it.effect("runs transforms during every reload", () => Effect.gen(function* () { let value = "first" const state = State.create({ @@ -43,15 +44,15 @@ describe("State", () => { draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), }) - yield* state.transform((editor) => - Effect.sync(() => { - editor.add(value) - }), - ) + yield* state.transform((editor) => { + editor.add(value) + }) expect(state.get().values).toEqual(["first"]) value = "second" - yield* state.reload() + const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("500 millis") + yield* Fiber.join(reload) expect(state.get().values).toEqual(["second"]) }), ) @@ -112,4 +113,30 @@ describe("State", () => { expect(finalized).toBe(2) }), ) + + it.effect("debounces reload bursts", () => + Effect.gen(function* () { + let finalized = 0 + const state = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + finalize: () => Effect.sync(() => finalized++), + }) + yield* state.transform((draft) => { + draft.add("value") + }) + finalized = 0 + + const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("250 millis") + const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true })) + yield* TestClock.adjust("499 millis") + expect(finalized).toBe(0) + yield* TestClock.adjust("1 millis") + yield* Fiber.join(first) + yield* Fiber.join(second) + + expect(finalized).toBe(1) + }), + ) }) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 1c91de9966..f3ef03bf79 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -8,7 +8,9 @@ import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { QuestionTool } from "@opencode-ai/core/tool/question" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" +import { imagePassthrough } from "./lib/image" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" @@ -84,6 +86,7 @@ const it = testEffect( [PermissionV2.node, permission], [Form.node, form], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [Image.node, imagePassthrough], ]), ) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index c5e7eaac48..6437e3f128 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -291,6 +291,9 @@ describe("ReadTool", () => { name: "pixel.png", mime: "image/png", encoding: "base64", + // Image base64 is carried by the content file item only; structured is slimmed + // so the original bytes are never persisted twice. + content: "", }) expect(settled.output?.content).toMatchObject([ { type: "text", text: "Image read successfully" }, @@ -364,7 +367,7 @@ describe("ReadTool", () => { }), ) - it.effect("rejects invalid image data returned by the filesystem", () => + it.effect("drops undecodable image data at settlement", () => Effect.gen(function* () { readResult = { uri: "file:///truncated.png", @@ -381,11 +384,17 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } }, }), - ).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" }) + ).toEqual({ + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "text", text: "[1 image omitted: could not be decoded.]" }, + ], + }) }), ) - it.effect("rejects oversized images when resizing is disabled", () => + it.effect("drops oversized images at settlement when resizing is disabled", () => Effect.gen(function* () { const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1) @@ -409,14 +418,20 @@ describe("ReadTool", () => { }), ] const registry = yield* ToolRegistry.Service - const result = yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } }, - }) - expect(result.type).toBe("error") - if (result.type === "error") expect(result.value).toContain("exceeding configured limits 4x2000") + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } }, + }), + ).toEqual({ + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, + ], + }) }), ) @@ -460,7 +475,7 @@ describe("ReadTool", () => { }), ) - it.effect("enforces max base64 bytes after resize attempts", () => + it.effect("drops images that cannot fit max base64 bytes after resize attempts", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" readResult = { @@ -481,14 +496,20 @@ describe("ReadTool", () => { }), ] const registry = yield* ToolRegistry.Service - const result = yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } }, - }) - expect(result.type).toBe("error") - if (result.type === "error") expect(result.value).toContain("/1 bytes") + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } }, + }), + ).toEqual({ + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, + ], + }) }), ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 8d81917d8c..8cc3eb25df 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -12,7 +12,9 @@ import { SkillTool } from "@opencode-ai/core/tool/skill" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" +import { Image } from "@opencode-ai/core/image" import { it } from "./lib/effect" +import { imagePassthrough } from "./lib/image" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" @@ -90,6 +92,7 @@ describe("SkillTool", () => { [PermissionV2.node, permission], [SkillV2.node, skills], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [Image.node, imagePassthrough], ], ) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index e9b4a5c56e..fc257a7d40 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" +import path from "path" import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -164,6 +165,77 @@ describe("SubagentTool", () => { ), ) + it.live("prevents subagents from launching subagents by default", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const sessions = yield* SessionV2.Service + const root = yield* sessions.create({ location }) + const parent = yield* sessions.create({ parentID: root.id, title: "parent" }) + yield* withSubagent(parent.location) + const locations = yield* LocationServiceMap.Service + const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* waitForTool(registry, SubagentTool.name) + + expect( + yield* executeTool(registry, { + sessionID: parent.id, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-nested-subagent", + name: SubagentTool.name, + input: { agent: "reviewer", description: "nested", prompt: "should fail" }, + }, + }), + ).toEqual({ type: "error", value: expect.stringContaining("Subagent depth limit reached (1)") }) + expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0) + }), + ), + ), + ) + + it.live("allows nested subagents up to the configured depth", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_depth: 2 } })), + ) + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const sessions = yield* SessionV2.Service + const root = yield* sessions.create({ location }) + const parent = yield* sessions.create({ parentID: root.id, title: "parent", model: parentModel }) + yield* withSubagent(parent.location) + const locations = yield* LocationServiceMap.Service + const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* waitForTool(registry, SubagentTool.name) + + const settled = yield* settleTool(registry, { + sessionID: parent.id, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-configured-nested-subagent", + name: SubagentTool.name, + input: { agent: "reviewer", description: "nested", prompt: "should run" }, + }, + }) + + expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) + expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id) + }), + ), + ), + ) + it.live("runs a foreground child session and returns the final assistant text", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 421c09f876..fc38296c9b 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -11,7 +11,9 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebFetchTool } from "@opencode-ai/core/tool/webfetch" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" +import { imagePassthrough } from "./lib/image" import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" const webFetchToolNode = makeLocationNode({ @@ -50,6 +52,7 @@ const toolLayer = (replacements: LayerNode.Replacements = []) => AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, webFetchToolNode]), [ [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [Image.node, imagePassthrough], ...replacements, ]) const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]])) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 53984ef69b..8d1cc1e679 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -10,7 +10,9 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" +import { imagePassthrough } from "./lib/image" import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" const webSearchToolNode = makeLocationNode({ @@ -138,6 +140,7 @@ const it = testEffect( [LayerNodePlatform.httpClient, http], [WebSearchTool.configNode, websearchConfig], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [Image.node, imagePassthrough], ], ), ) diff --git a/packages/core/test/wellknown.test.ts b/packages/core/test/wellknown.test.ts new file mode 100644 index 0000000000..08ecbfc8b6 --- /dev/null +++ b/packages/core/test/wellknown.test.ts @@ -0,0 +1,119 @@ +import { expect } from "bun:test" +import { Effect, Fiber, Stream } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { KV } from "@opencode-ai/core/kv" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { WellKnown } from "@opencode-ai/core/wellknown" +import { testEffect } from "./lib/effect" + +const it = testEffect(FetchHttpClient.layer) +const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node, EventV2.node]))) + +it.live("loads embedded and remote configuration", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/.well-known/opencode") { + return Response.json({ + auth: { command: ["login"], env: "TOKEN" }, + config: { model: "embedded/model" }, + remote_config: { + url: `${url.origin}/config/{env:TOKEN}`, + headers: { authorization: "Bearer {env:TOKEN}" }, + }, + }) + } + if (url.pathname === "/config/secret" && request.headers.get("authorization") === "Bearer secret") { + return Response.json({ config: { model: "remote/model" } }) + } + return new Response("Not found", { status: 404 }) + }, + }), + ), + (server) => + Effect.gen(function* () { + const origin = server.url.origin + expect(yield* WellKnown.inspect(`${origin}/`)).toEqual({ + auth: { command: ["login"], env: "TOKEN" }, + config: { model: "embedded/model" }, + remote_config: { + url: `${origin}/config/{env:TOKEN}`, + headers: { authorization: "Bearer {env:TOKEN}" }, + }, + }) + expect(yield* WellKnown.resolve({ origin, variables: { TOKEN: "secret" } })).toEqual([ + { model: "embedded/model" }, + { model: "remote/model" }, + ]) + }), + (server) => Effect.promise(() => server.stop(true)), + ), +) + +serviceIt.live("persists sources in one KV value", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => Response.json({ auth: { command: ["login"], env: "TOKEN" } }), + }), + ), + (server) => + Effect.gen(function* () { + const wellknown = yield* WellKnown.Service + const kv = yield* KV.Service + const events = yield* EventV2.Service + const changed = yield* events + .subscribe(WellKnown.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const entry = yield* wellknown.add(`${server.url.origin}/`) + + expect(entry.origin).toBe(server.url.origin) + expect(yield* kv.get("wellknown:sources")).toEqual([server.url.origin]) + expect(yield* wellknown.entries()).toEqual([entry]) + expect(yield* Fiber.join(changed)).toHaveLength(1) + + yield* wellknown.remove(server.url.origin) + expect(yield* kv.get("wellknown:sources")).toEqual([]) + expect(yield* wellknown.entries()).toEqual([]) + }), + (server) => Effect.promise(() => server.stop(true)), + ), +) + +serviceIt.live("refreshes changed manifests", () => + Effect.acquireUseRelease( + Effect.sync(() => { + let command = "first" + return { + server: Bun.serve({ + port: 0, + fetch: () => Response.json({ auth: { command: [command], env: "TOKEN" } }), + }), + update: () => { + command = "second" + }, + } + }), + ({ server, update }) => + Effect.gen(function* () { + const wellknown = yield* WellKnown.Service + const events = yield* EventV2.Service + yield* wellknown.add(server.url.origin) + expect(yield* wellknown.refresh()).toBe(false) + + const changed = yield* events + .subscribe(WellKnown.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + update() + expect(yield* wellknown.refresh()).toBe(true) + expect(yield* Fiber.join(changed)).toHaveLength(1) + expect(wellknown.snapshot()[0]?.manifest.auth?.command).toEqual(["second"]) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), +) diff --git a/packages/docs/build/plugins.mdx b/packages/docs/build/plugins.mdx index ad9cff8a3e..01a8b5746b 100644 --- a/packages/docs/build/plugins.mdx +++ b/packages/docs/build/plugins.mdx @@ -169,15 +169,15 @@ and plugin options. | Capability | Available operations | | ---------------------- | -------------------------------------------------------------------------------------------- | -| `ctx.agent` | `list`, `transform`, `reload` | +| `ctx.agent` | `list`, `get`, `transform`, `reload` | | `ctx.catalog.provider` | `list`, `get` | -| `ctx.catalog.model` | `list`, `default` | +| `ctx.catalog.model` | `list`, `get`, `default` | | `ctx.catalog` | `transform`, `reload` | | `ctx.command` | `list`, `transform`, `reload` | | `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution | | `ctx.plugin` | `list` currently active plugin IDs | | `ctx.reference` | `list`, `transform`, `reload` | -| `ctx.session` | `create`, `get`, `prompt`, `command`, `interrupt`, and `hook` | +| `ctx.session` | `create`, `get`, `prompt`, `command`, `synthetic`, `interrupt`, and `hook` | | `ctx.skill` | `list`, `transform`, `reload` | | `ctx.tool` | `transform` and `hook` | | `ctx.aisdk` | `hook` | diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md index 74fd92889b..ba1da75dba 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -17,6 +17,7 @@ Implementation checklist: - [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions. - [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click. - [x] Add reusable JSON-RPC WebSocket server at the manifest's UI endpoint. +- [x] Add `simulation.handshake` protocol, role, identity, version, and capability negotiation to both control endpoints. - [x] Expose `ui.state`, `ui.action`, `ui.render`. - [x] Expose `trace.list`, `trace.clear`, `trace.export`. - [x] Wire visible V1/full-TUI renderer path through the same action protocol. diff --git a/packages/opencode/specs/simulation/simulation.md b/packages/opencode/specs/simulation/simulation.md index 7682fad0c4..32924fdabd 100644 --- a/packages/opencode/specs/simulation/simulation.md +++ b/packages/opencode/specs/simulation/simulation.md @@ -69,6 +69,7 @@ This is important because the frontend has direct access to the renderer, screen Protocol: - JSON-RPC 2.0 over WebSocket. +- Clients negotiate protocol version, endpoint role, and capabilities with `simulation.handshake` before using endpoint methods. - Loopback only. - `OPENCODE_DRIVE` names a manifest in the opencode-drive registry, or is `1` for the unnamed default endpoints. - The manifest supplies exact loopback `ui` and `backend` WebSocket endpoints. @@ -77,6 +78,42 @@ Protocol: The app should not send JSON-RPC requests back to the driver in the first milestone. The driver sends requests; the app responds and emits notifications/events as useful. +The canonical handshake request is: + +```ts +{ + jsonrpc: "2.0" + id: string | number | null + method: "simulation.handshake" + params: { + client: { + name: string + version: string + } + expectedRole: "ui" | "backend" + offeredVersions: Array + requiredCapabilities: Array + optionalCapabilities: Array + } +} +``` + +The response result is: + +```ts +{ + protocolVersion: 1 + role: "ui" | "backend" + server: { + name: string + version: string + } + capabilities: Array +} +``` + +Capabilities are open strings. Each endpoint advertises only methods and notifications it actually implements. A role mismatch, no supported offered protocol version, or a missing required capability fails the request; unsupported optional capabilities do not. + Initial method groups: - `ui.state`: return screen, elements, focus, and generated possible actions. diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 9bb688aedd..af38a3d94f 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1528,7 +1528,7 @@ describe("session.message-v2.fromError", () => { test("classifies ZlibError from fetch as retryable APIError", () => { const zlibError = new Error( - 'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()', + 'ZlibError fetching "https://example.com/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()', ) ;(zlibError as any).code = "ZlibError" ;(zlibError as any).errno = 0 @@ -1543,7 +1543,7 @@ describe("session.message-v2.fromError", () => { test("classifies ZlibError as AbortedError when abort context is provided", () => { const zlibError = new Error( - 'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()', + 'ZlibError fetching "https://example.com/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()', ) ;(zlibError as any).code = "ZlibError" ;(zlibError as any).errno = 0 diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 084f6c03ff..825a5c0f08 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -29,6 +29,7 @@ "@opencode-ai/client": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@standard-schema/spec": "^1.1.0", "effect": "catalog:", "zod": "catalog:" }, diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index 50ee83615b..8ce7ddb414 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -80,11 +80,11 @@ yield * Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. -AI request context is mutable immediately before provider dispatch: +Session context is mutable immediately before provider dispatch: ```ts yield * - ctx.ai.hook("request", (event) => + ctx.session.hook("context", (event) => Effect.sync(() => { event.tools.read.description = "Read a file using narrow line ranges." delete event.tools.write diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index d5125cca2a..e14777af77 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -12,6 +12,9 @@ export interface AgentDraft { } export interface AgentDomain extends AgentApi { + readonly get: (id: string) => Effect.Effect readonly transform: Transform readonly reload: () => Effect.Effect } + +type AgentGetOutput = Effect.Success["list"]>>["data"][number] diff --git a/packages/plugin/src/v2/effect/ai.ts b/packages/plugin/src/v2/effect/ai.ts deleted file mode 100644 index 306d53cd17..0000000000 --- a/packages/plugin/src/v2/effect/ai.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Message, SystemPart } from "@opencode-ai/ai" -import type { Agent } from "@opencode-ai/schema/agent" -import type { Model } from "@opencode-ai/schema/model" -import type { Session } from "@opencode-ai/schema/session" -import type { JsonSchema } from "effect" -import type { Hooks } from "./registration.js" - -export interface AIRequest { - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly model: Model.Ref - system: Array - messages: Array - tools: Record -} - -export interface AIHooks { - readonly request: AIRequest -} - -export interface AIDomain { - readonly hook: Hooks -} diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index b96ceec4a8..834578dd60 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -27,6 +27,11 @@ export interface CatalogDraft { } export interface CatalogDomain extends CatalogApi { + readonly model: CatalogApi["model"] & { + readonly get: (providerID: string, modelID: string) => Effect.Effect + } readonly transform: Transform readonly reload: () => Effect.Effect } + +type ModelGetOutput = Effect.Success["model"]["list"]>>["data"][number] diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 7af3542b6c..12bb71945e 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -2,6 +2,7 @@ import type { ConnectionInfo, CredentialOAuth, CredentialValue, + IntegrationCommandMethod, IntegrationEnvMethod, IntegrationInputs, IntegrationKeyMethod, @@ -35,6 +36,10 @@ export type IntegrationOAuthMethodRegistration = { } export type IntegrationMethodRegistration = | IntegrationOAuthMethodRegistration + | { + readonly integrationID: string + readonly method: IntegrationCommandMethod + } | { readonly integrationID: string readonly method: IntegrationKeyMethod @@ -56,7 +61,7 @@ export interface IntegrationDraft { } } -export interface IntegrationDomain extends IntegrationApi { +export interface IntegrationDomain extends Omit, "wellknown"> { readonly transform: Transform readonly reload: () => Effect.Effect readonly connection: { diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 7be0b228e1..3b0b6eb918 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -2,7 +2,6 @@ import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { PluginOptions } from "../options.js" import type { AgentDomain } from "./agent.js" -import type { AIDomain } from "./ai.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" import type { CommandDomain } from "./command.js" @@ -16,7 +15,6 @@ import type { ToolDomain } from "./tool.js" export interface Context { readonly options: PluginOptions readonly agent: AgentDomain - readonly ai: AIDomain readonly aisdk: AISDKDomain readonly catalog: CatalogDomain readonly command: CommandDomain diff --git a/packages/plugin/src/v2/effect/session.ts b/packages/plugin/src/v2/effect/session.ts index 40da735cbe..836f4431c6 100644 --- a/packages/plugin/src/v2/effect/session.ts +++ b/packages/plugin/src/v2/effect/session.ts @@ -1,3 +1,27 @@ import type { SessionApi } from "@opencode-ai/client/effect/api" +import type { Message, SystemPart } from "@opencode-ai/ai" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" -export type SessionDomain = Pick, "create" | "get" | "prompt" | "command" | "interrupt"> +export interface SessionContext { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly context: SessionContext +} + +export type SessionDomain = Pick< + SessionApi, + "create" | "get" | "prompt" | "command" | "synthetic" | "interrupt" +> & { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 8668570f8f..59ef488433 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -4,7 +4,8 @@ import { Agent } from "@opencode-ai/schema/agent" import type { LLM } from "@opencode-ai/schema/llm" import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" -import { Effect, JsonSchema, Schema, type Scope } from "effect" +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" +import { Effect, JsonSchema, Schema } from "effect" import type { Hooks, Transform } from "./registration.js" export interface Context { @@ -20,7 +21,34 @@ export interface Progress { readonly content?: ReadonlyArray } -export type SchemaType = Schema.Codec +export type StandardSchemaType = StandardSchemaV1 & + StandardJSONSchemaV1 +export type SchemaType = Schema.Codec | StandardSchemaType +type IsAny = 0 extends 1 & A ? true : false +export type InputValue = + IsAny extends true + ? any + : S extends Schema.Codec + ? A + : S extends StandardSchemaV1 + ? A + : never +export type OutputValue = + IsAny extends true + ? any + : S extends Schema.Codec + ? A + : S extends StandardSchemaV1 + ? A + : never +export type EncodedValue = + IsAny extends true + ? any + : S extends Schema.Codec + ? A + : S extends StandardSchemaV1 + ? A + : never type ToolDefinition = { readonly name: string @@ -45,16 +73,6 @@ type ToolOutput = { readonly content: ReadonlyArray } -declare const TypeId: unique symbol - -export interface Definition, Output extends SchemaType> { - readonly [TypeId]: { - readonly _Input: Input - readonly _Output: Output - } -} - -export type AnyTool = Definition export class Failure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { message: Schema.String, error: Schema.optional(Schema.Defect()), @@ -70,26 +88,24 @@ export type Content = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } -type Config< +export type Definition< Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, + Structured extends SchemaType, + Output extends SchemaType = any, > = { readonly description: string readonly input: Input readonly output: Output readonly structured?: Structured + readonly permission?: string readonly toStructuredOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] - }) => Schema.Schema.Type - readonly execute: ( - input: Schema.Schema.Type, - context: Context, - ) => Effect.Effect, Failure> + readonly input: InputValue + readonly output: EncodedValue + }) => OutputValue + readonly execute: (input: InputValue, context: Context) => Effect.Effect, Failure> readonly toModelOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] + readonly input: InputValue + readonly output: EncodedValue }) => ReadonlyArray } @@ -103,109 +119,25 @@ export type DynamicOutput = { * time (MCP servers, plugin manifests). Input is passed through as `unknown`; * `execute` returns the already-projected structured value and model content. */ -type DynamicConfig = { +export type DynamicDefinition = { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema readonly outputSchema?: JsonSchema.JsonSchema + readonly permission?: string readonly execute: (input: unknown, context: Context) => Effect.Effect } -type Runtime = { - readonly permission?: string - readonly definition: (name: string) => ToolDefinition - readonly settle: (call: ToolCall, context: Context) => Effect.Effect -} - -const runtimes = new WeakMap() +export type AnyTool = Definition | DynamicDefinition export function make< Input extends SchemaType, Output extends SchemaType, Structured extends SchemaType = Output, ->(config: Config): Definition -export function make(config: DynamicConfig): AnyTool -export function make(config: Config | DynamicConfig): AnyTool { - if ("jsonSchema" in config) return makeDynamic(config) - return makeTyped(config) -} - -function makeTyped< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, ->(config: Config): Definition { - const tool = Object.freeze({}) as Definition - const definitions = new Map() - runtimes.set(tool, { - definition: (name) => { - const cached = definitions.get(name) - if (cached) return cached - const definition: ToolDefinition = { - name, - description: config.description, - inputSchema: toJsonSchema(config.input), - outputSchema: toJsonSchema(config.structured ?? config.output), - } - definitions.set(name, definition) - return definition - }, - settle: (call, context) => - Schema.decodeUnknownEffect(config.input)(call.input).pipe( - Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })), - Effect.flatMap((input) => - config.execute(input, context).pipe( - Effect.flatMap((output) => - Schema.encodeEffect(config.output)(output).pipe( - Effect.flatMap((output) => { - if (!config.structured || !config.toStructuredOutput) - return Effect.succeed({ output, structured: output }) - return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe( - Effect.map((structured) => ({ output, structured })), - ) - }), - Effect.mapError( - (error) => - new Failure({ - message: `Tool returned an invalid value for its output schema: ${error.message}`, - }), - ), - ), - ), - Effect.map(({ output, structured }) => ({ - structured, - content: - config.toModelOutput?.({ input, output }).map(toModelContent) ?? - (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), - })), - ), - ), - ), - }) - return tool -} - -function makeDynamic(config: DynamicConfig): AnyTool { - const tool = Object.freeze({}) as AnyTool - const definitions = new Map() - runtimes.set(tool, { - definition: (name) => { - const cached = definitions.get(name) - if (cached) return cached - const definition: ToolDefinition = { - name, - description: config.description, - inputSchema: config.jsonSchema, - outputSchema: config.outputSchema, - } - definitions.set(name, definition) - return definition - }, - settle: (call, context) => - config - .execute(call.input, context) - .pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))), - }) - return tool +>(config: Definition): Definition +export function make(config: DynamicDefinition): DynamicDefinition +export function make(config: AnyTool): AnyTool +export function make(config: AnyTool): AnyTool { + return config } function toModelContent(part: Content) { @@ -230,23 +162,102 @@ export const registrationEntries = (tools: Readonly>, gr } }) -export const withPermission = , Output extends SchemaType>( - tool: Definition, +export const withPermission = ( + tool: T, permission: string, -) => { - const decorated = Object.freeze({}) as Definition - runtimes.set(decorated, { ...runtimeOf(tool), permission }) - return decorated +): Omit & { + readonly permission: string +} => ({ ...tool, permission }) + +export const permission = (tool: AnyTool, name: string) => tool.permission ?? name + +export const definition = (name: string, tool: AnyTool): ToolDefinition => + "jsonSchema" in tool + ? { + name, + description: tool.description, + inputSchema: tool.jsonSchema, + outputSchema: tool.outputSchema, + } + : { + name, + description: tool.description, + inputSchema: inputJsonSchema(tool.input), + outputSchema: outputJsonSchema(tool.structured ?? tool.output), + } + +export const settle = (tool: AnyTool, call: ToolCall, context: Context): Effect.Effect => + Effect.gen(function* () { + if ("jsonSchema" in tool) { + const output = yield* tool.execute(call.input, context) + return { structured: output.structured, content: output.content.map(toModelContent) } + } + + const input = yield* decodeInput(tool.input, call.input) + const value = yield* tool.execute(input, context) + const output = yield* encodeOutput(tool.output, value) + const structured = + tool.structured && tool.toStructuredOutput + ? yield* encodeOutput(tool.structured, tool.toStructuredOutput({ input, output })) + : output + return { + structured, + content: + tool.toModelOutput?.({ input, output }).map(toModelContent) ?? + (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), + } + }) + +function decodeInput(schema: SchemaType, value: unknown): Effect.Effect { + if (Schema.isSchema(schema)) + return Schema.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })), + ) + return validateStandard(schema, value, "Invalid tool input") } -export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name -export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name) -export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context) +function encodeOutput(schema: SchemaType, value: unknown): Effect.Effect { + if (Schema.isSchema(schema)) + return Schema.encodeEffect(schema)(value).pipe( + Effect.mapError( + (error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }), + ), + ) + return validateStandard(schema, value, "Tool returned an invalid value for its output schema") +} -function runtimeOf(tool: AnyTool) { - const runtime = runtimes.get(tool) - if (!runtime) throw new TypeError("Invalid Tool value") - return runtime +function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect { + return Effect.gen(function* () { + const pending = yield* Effect.try({ + try: () => schema["~standard"].validate(value), + catch: (error) => standardFailure(prefix, error), + }) + const result = + pending instanceof Promise + ? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) }) + : pending + if (result.issues) + return yield* Effect.fail( + new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }), + ) + return result.value + }) +} + +function standardFailure(prefix: string, error: unknown) { + return new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` }) +} + +function inputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { + if (!Schema.isSchema(schema)) + return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema + return toJsonSchema(schema) +} + +function outputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { + if (!Schema.isSchema(schema)) + return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema + return toJsonSchema(schema) } function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema { diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md index 268a98a94b..e23a231e4f 100644 --- a/packages/plugin/src/v2/promise/README.md +++ b/packages/plugin/src/v2/promise/README.md @@ -85,10 +85,10 @@ await ctx.aisdk.hook("language", (event) => { }) ``` -AI request context is mutable immediately before provider dispatch: +Session context is mutable immediately before provider dispatch: ```ts -await ctx.ai.hook("request", (event) => { +await ctx.session.hook("context", (event) => { event.tools.read.description = "Read a file using narrow line ranges." delete event.tools.write }) diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts index bfffdebb4e..af5a1f6850 100644 --- a/packages/plugin/src/v2/promise/agent.ts +++ b/packages/plugin/src/v2/promise/agent.ts @@ -5,6 +5,9 @@ import type { Transform } from "./registration.js" export type { AgentDraft } export interface AgentDomain extends AgentApi { + readonly get: (id: string) => Promise readonly transform: Transform readonly reload: () => Promise } + +type AgentGetOutput = Awaited>["data"][number] diff --git a/packages/plugin/src/v2/promise/ai.ts b/packages/plugin/src/v2/promise/ai.ts deleted file mode 100644 index 306d53cd17..0000000000 --- a/packages/plugin/src/v2/promise/ai.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Message, SystemPart } from "@opencode-ai/ai" -import type { Agent } from "@opencode-ai/schema/agent" -import type { Model } from "@opencode-ai/schema/model" -import type { Session } from "@opencode-ai/schema/session" -import type { JsonSchema } from "effect" -import type { Hooks } from "./registration.js" - -export interface AIRequest { - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly model: Model.Ref - system: Array - messages: Array - tools: Record -} - -export interface AIHooks { - readonly request: AIRequest -} - -export interface AIDomain { - readonly hook: Hooks -} diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts index e0a51f5e52..26cc25caa3 100644 --- a/packages/plugin/src/v2/promise/catalog.ts +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -5,6 +5,11 @@ import type { Transform } from "./registration.js" export type { CatalogDraft, CatalogProviderRecord } export interface CatalogDomain extends CatalogApi { + readonly model: CatalogApi["model"] & { + readonly get: (providerID: string, modelID: string) => Promise + } readonly transform: Transform readonly reload: () => Promise } + +type ModelGetOutput = Awaited>["data"][number] diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index 76e5a99eb5..e666b83b8a 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -5,7 +5,7 @@ import type { Transform } from "./registration.js" export type { IntegrationDraft, IntegrationMethodRegistration } -export interface IntegrationDomain extends IntegrationApi { +export interface IntegrationDomain extends Omit { readonly transform: Transform readonly reload: () => Promise readonly connection: { diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index 1928ca3672..2e05b1fded 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,7 +1,6 @@ import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginOptions } from "../options.js" import type { AgentDomain } from "./agent.js" -import type { AIDomain } from "./ai.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" import type { CommandDomain } from "./command.js" @@ -15,7 +14,6 @@ import type { ToolDomain } from "./tool.js" export interface Context { readonly options: PluginOptions readonly agent: AgentDomain - readonly ai: AIDomain readonly aisdk: AISDKDomain readonly catalog: CatalogDomain readonly command: CommandDomain diff --git a/packages/plugin/src/v2/promise/session.ts b/packages/plugin/src/v2/promise/session.ts index c521c8fddd..387d6fe007 100644 --- a/packages/plugin/src/v2/promise/session.ts +++ b/packages/plugin/src/v2/promise/session.ts @@ -1,3 +1,24 @@ import type { SessionApi } from "@opencode-ai/client/promise/api" +import type { Message, SystemPart } from "@opencode-ai/ai" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" -export type SessionDomain = Pick +export interface SessionContext { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly context: SessionContext +} + +export type SessionDomain = Pick & { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts index 3f85aaae54..91b9b2c1e2 100644 --- a/packages/plugin/src/v2/promise/tool.ts +++ b/packages/plugin/src/v2/promise/tool.ts @@ -1,8 +1,4 @@ import type { Tool } from "../effect/tool.js" -import type { Agent } from "@opencode-ai/schema/agent" -import type { Session } from "@opencode-ai/schema/session" -import type { SessionMessage } from "@opencode-ai/schema/session-message" -import type { JsonSchema, Schema } from "effect" import type { Hooks, Transform } from "./registration.js" export type Context = Omit & { @@ -16,71 +12,28 @@ export type Definition< Input extends SchemaType, Output extends SchemaType, Structured extends SchemaType = Output, -> = { +> = Omit, "execute" | "permission"> & { readonly name: string readonly options?: RegisterOptions - readonly description: string - readonly input: Input - readonly output: Output - readonly structured?: Structured - readonly toStructuredOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] - }) => Schema.Schema.Type - readonly execute: ( - input: Schema.Schema.Type, - context: Context, - ) => Promise> - readonly toModelOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] - }) => ReadonlyArray + readonly execute: (input: Tool.InputValue, context: Context) => Promise> } -export type DynamicDefinition = { +export type DynamicDefinition = Omit & { readonly name: string readonly options?: RegisterOptions - readonly description: string - readonly jsonSchema: JsonSchema.JsonSchema - readonly outputSchema?: JsonSchema.JsonSchema readonly execute: (input: unknown, context: Context) => Promise } export type AnyTool = Definition | DynamicDefinition -export interface ToolExecuteBeforeEvent { - readonly tool: string - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - input: unknown -} - -export interface ToolExecuteAfterEvent { - readonly tool: string - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - readonly input: unknown - result: Tool.ToolExecuteAfterEvent["result"] - output?: Tool.ToolExecuteAfterEvent["output"] - outputPaths?: ReadonlyArray -} - -export interface RegisterOptions { - readonly group?: string - /** Defaults to true. False exposes the tool directly to the provider. */ - readonly codemode?: boolean -} +export type ToolExecuteBeforeEvent = Tool.ToolExecuteBeforeEvent +export type ToolExecuteAfterEvent = Tool.ToolExecuteAfterEvent +export type RegisterOptions = Tool.RegisterOptions export interface ToolDraft { - add< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, - >(tool: Definition): void + add, Output extends SchemaType, Structured extends SchemaType = Output>( + tool: Definition, + ): void add(tool: DynamicDefinition): void } diff --git a/packages/plugin/test/tool.test.ts b/packages/plugin/test/tool.test.ts new file mode 100644 index 0000000000..c22ddbec9a --- /dev/null +++ b/packages/plugin/test/tool.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from "bun:test" +import { Agent } from "@opencode-ai/schema/agent" +import { Session } from "@opencode-ai/schema/session" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Effect, Schema } from "effect" +import * as Tool from "../src/v2/effect/tool" + +const context = { + sessionID: Session.ID.make("ses_test"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_test"), + callID: "call_test", + progress: () => Effect.void, +} satisfies Tool.Context + +test("tools remain valid across separate module instances", async () => { + const ForeignTool = await import(`${new URL("../src/v2/effect/tool.ts", import.meta.url).href}?foreign`) + const config = { + description: "Foreign tool", + input: Schema.Struct({ value: Schema.String }), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ ok: true }), + } + const tool = ForeignTool.make(config) + + expect(Tool.definition("foreign", tool)).toEqual({ + name: "foreign", + description: "Foreign tool", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], + additionalProperties: false, + }, + }) + expect(await Effect.runPromise(Tool.settle(tool, { input: { value: "input" } }, context))).toEqual({ + structured: { ok: true }, + content: [], + }) +}) + +test("portable schemas validate and describe typed tools", async () => { + const input: Tool.StandardSchemaType<{ count: string }, { count: number }> = { + "~standard": { + version: 1, + vendor: "test", + validate: (value) => { + if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "string") + return { issues: [{ message: "count must be numeric" }] } + const count = Number(value.count) + return Number.isFinite(count) ? { value: { count } } : { issues: [{ message: "count must be numeric" }] } + }, + jsonSchema: { + input: () => ({ type: "object", properties: { count: { type: "string" } } }), + output: () => ({ type: "object", properties: { count: { type: "number" } } }), + }, + }, + } + const output: Tool.StandardSchemaType = { + "~standard": { + version: 1, + vendor: "test", + validate: (value) => ({ value: String(value) }), + jsonSchema: { + input: () => ({ type: "number" }), + output: () => ({ type: "string" }), + }, + }, + } + const tool = Tool.make({ + description: "Portable tool", + input, + output, + execute: ({ count }) => Effect.succeed(count + 1), + }) + + expect(Tool.definition("portable", tool)).toEqual({ + name: "portable", + description: "Portable tool", + inputSchema: { type: "object", properties: { count: { type: "string" } } }, + outputSchema: { type: "string" }, + }) + expect(await Effect.runPromise(Tool.settle(tool, { input: { count: "41" } }, context))).toEqual({ + structured: "42", + content: [{ type: "text", text: "42" }], + }) +}) + +test("portable schema failures become tool failures", async () => { + const input: Tool.StandardSchemaType = { + "~standard": { + version: 1, + vendor: "test", + validate: () => ({ issues: [{ message: "expected a string" }] }), + jsonSchema: { + input: () => ({ type: "string" }), + output: () => ({ type: "string" }), + }, + }, + } + const tool = Tool.make({ + description: "Failing tool", + input, + output: input, + execute: Effect.succeed, + }) + + const error = await Effect.runPromiseExit(Tool.settle(tool, { input: 1 }, context)) + expect(error.toString()).toContain("Invalid tool input: expected a string") +}) + +test("two-parameter Definition annotations retain their original meaning", () => { + const input = Schema.Struct({ value: Schema.String }) + const output = Schema.Struct({ value: Schema.String, internal: Schema.Boolean }) + const structured = Schema.Struct({ value: Schema.String }) + const tool: Tool.Definition = Tool.make({ + description: "Annotated tool", + input, + output, + structured, + toStructuredOutput: ({ output }) => ({ value: output.value }), + execute: ({ value }) => Effect.succeed({ value, internal: true }), + }) + + expect(tool.structured).toBe(structured) +}) diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index a6ec4d5282..9d88e002da 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -37,6 +37,22 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") }), ), ) + .add( + HttpApiEndpoint.post("integration.wellknown.add", "/api/experimental/integration/wellknown", { + query: LocationQuery, + payload: Schema.Struct({ url: Schema.String }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.experimental.integration.wellknown.add", + summary: "Add wellknown integration", + description: "Discover and persist an experimental wellknown integration source.", + }), + ), + ) .add( HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", { params: { integrationID: Integration.ID }, @@ -58,7 +74,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") ), ) .add( - HttpApiEndpoint.post("integration.connect.oauth", "/api/integration/:integrationID/connect/oauth", { + HttpApiEndpoint.post("integration.oauth.connect", "/api/integration/:integrationID/connect/oauth", { params: { integrationID: Integration.ID }, query: LocationQuery, payload: Schema.Struct({ @@ -72,59 +88,113 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.integration.connect.oauth", + identifier: "v2.integration.oauth.connect", summary: "Begin OAuth connection", description: "Start an OAuth attempt and return the authorization details.", }), ), ) .add( - HttpApiEndpoint.get("integration.attempt.status", "/api/integration/attempt/:attemptID", { - params: { attemptID: Integration.AttemptID }, + HttpApiEndpoint.get("integration.oauth.status", "/api/integration/:integrationID/connect/oauth/:attemptID", { + params: { integrationID: Integration.ID, attemptID: Integration.AttemptID }, query: LocationQuery, success: Location.response(Integration.AttemptStatus), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.integration.attempt.status", + identifier: "v2.integration.oauth.status", summary: "Get OAuth attempt status", description: "Poll the current status of an OAuth attempt.", }), ), ) .add( - HttpApiEndpoint.post("integration.attempt.complete", "/api/integration/attempt/:attemptID/complete", { - params: { attemptID: Integration.AttemptID }, - query: LocationQuery, - payload: Schema.Struct({ code: Schema.optional(Schema.String) }), - success: HttpApiSchema.NoContent, - error: InvalidRequestError, - }) + HttpApiEndpoint.post( + "integration.oauth.complete", + "/api/integration/:integrationID/connect/oauth/:attemptID/complete", + { + params: { integrationID: Integration.ID, attemptID: Integration.AttemptID }, + query: LocationQuery, + payload: Schema.Struct({ code: Schema.optional(Schema.String) }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }, + ) .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.integration.attempt.complete", + identifier: "v2.integration.oauth.complete", summary: "Complete OAuth connection", description: "Complete a code-based OAuth attempt and store the resulting credential.", }), ), ) .add( - HttpApiEndpoint.delete("integration.attempt.cancel", "/api/integration/attempt/:attemptID", { - params: { attemptID: Integration.AttemptID }, + HttpApiEndpoint.delete("integration.oauth.cancel", "/api/integration/:integrationID/connect/oauth/:attemptID", { + params: { integrationID: Integration.ID, attemptID: Integration.AttemptID }, query: LocationQuery, success: HttpApiSchema.NoContent, }) .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.integration.attempt.cancel", + identifier: "v2.integration.oauth.cancel", summary: "Cancel OAuth connection", description: "Cancel an OAuth attempt and release its resources.", }), ), ) + .add( + HttpApiEndpoint.post("integration.command.connect", "/api/integration/:integrationID/connect/command", { + params: { integrationID: Integration.ID }, + query: LocationQuery, + payload: Schema.Struct({ + methodID: Integration.MethodID, + label: Schema.optional(Schema.String), + }), + success: Location.response(Integration.CommandAttempt), + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.command.connect", + summary: "Begin command connection", + description: "Start a command authentication attempt.", + }), + ), + ) + .add( + HttpApiEndpoint.get("integration.command.status", "/api/integration/:integrationID/connect/command/:attemptID", { + params: { integrationID: Integration.ID, attemptID: Integration.AttemptID }, + query: LocationQuery, + success: Location.response(Integration.CommandAttemptStatus), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.command.status", + summary: "Get command attempt status", + description: "Poll the current status and output of a command authentication attempt.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("integration.command.cancel", "/api/integration/:integrationID/connect/command/:attemptID", { + params: { integrationID: Integration.ID, attemptID: Integration.AttemptID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.integration.command.cancel", + summary: "Cancel command connection", + description: "Cancel a command authentication attempt and terminate its process.", + }), + ), + ) .annotateMerge( OpenApi.annotations({ title: "integration", description: "Integration discovery and authentication routes." }), ) diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 6caa559710..0e573c4313 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -56,6 +56,14 @@ export const OAuthMethod = Schema.Struct({ prompts: optional(Schema.Array(Prompt)), }).annotate({ identifier: "Integration.OAuthMethod" }) +export interface CommandMethod extends Schema.Schema.Type {} +export const CommandMethod = Schema.Struct({ + id: MethodID, + type: Schema.Literal("command"), + label: Schema.String, + command: Schema.Array(Schema.String), +}).annotate({ identifier: "Integration.CommandMethod" }) + export interface KeyMethod extends Schema.Schema.Type {} export const KeyMethod = Schema.Struct({ type: Schema.Literal("key"), @@ -68,7 +76,7 @@ export const EnvMethod = Schema.Struct({ names: Schema.Array(Schema.String), }).annotate({ identifier: "Integration.EnvMethod" }) -export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]) +export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMethod]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Integration.Method" }) export type Method = typeof Method.Type @@ -128,3 +136,19 @@ export const AttemptStatus = Schema.Union([ .pipe(Schema.toTaggedUnion("status")) .annotate({ identifier: "Integration.AttemptStatus" }) export type AttemptStatus = typeof AttemptStatus.Type + +export interface CommandAttempt extends Schema.Schema.Type {} +export const CommandAttempt = Schema.Struct({ + attemptID: AttemptID, + time: AttemptTime, +}).annotate({ identifier: "Integration.CommandAttempt" }) + +export const CommandAttemptStatus = Schema.Union([ + Schema.Struct({ status: Schema.Literal("pending"), message: optional(Schema.String), time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("complete"), time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: AttemptTime }), + Schema.Struct({ status: Schema.Literal("expired"), time: AttemptTime }), +]) + .pipe(Schema.toTaggedUnion("status")) + .annotate({ identifier: "Integration.CommandAttemptStatus" }) +export type CommandAttemptStatus = typeof CommandAttemptStatus.Type diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 32bc2d2074..a7c84c3815 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -280,6 +280,8 @@ import type { V2DebugLocationListResponses, V2EventSubscribeErrors, V2EventSubscribeResponses, + V2ExperimentalIntegrationWellknownAddErrors, + V2ExperimentalIntegrationWellknownAddResponses, V2FormRequestListErrors, V2FormRequestListResponses, V2FsFindErrors, @@ -294,20 +296,26 @@ import type { V2HealthGetResponses, V2HealthStopErrors, V2HealthStopResponses, - V2IntegrationAttemptCancelErrors, - V2IntegrationAttemptCancelResponses, - V2IntegrationAttemptCompleteErrors, - V2IntegrationAttemptCompleteResponses, - V2IntegrationAttemptStatusErrors, - V2IntegrationAttemptStatusResponses, + V2IntegrationCommandCancelErrors, + V2IntegrationCommandCancelResponses, + V2IntegrationCommandConnectErrors, + V2IntegrationCommandConnectResponses, + V2IntegrationCommandStatusErrors, + V2IntegrationCommandStatusResponses, V2IntegrationConnectKeyErrors, V2IntegrationConnectKeyResponses, - V2IntegrationConnectOauthErrors, - V2IntegrationConnectOauthResponses, V2IntegrationGetErrors, V2IntegrationGetResponses, V2IntegrationListErrors, V2IntegrationListResponses, + V2IntegrationOauthCancelErrors, + V2IntegrationOauthCancelResponses, + V2IntegrationOauthCompleteErrors, + V2IntegrationOauthCompleteResponses, + V2IntegrationOauthConnectErrors, + V2IntegrationOauthConnectResponses, + V2IntegrationOauthStatusErrors, + V2IntegrationOauthStatusResponses, V2LocationGetErrors, V2LocationGetResponses, V2McpListErrors, @@ -6827,13 +6835,15 @@ export class Connect extends HeyApiClient { }, }) } +} +export class Oauth2 extends HeyApiClient { /** * Begin OAuth connection * * Start an OAuth attempt and return the authorization details. */ - public oauth( + public connect( parameters: { integrationID: string location?: { @@ -6863,8 +6873,8 @@ export class Connect extends HeyApiClient { ], ) return (options?.client ?? this.client).post< - V2IntegrationConnectOauthResponses, - V2IntegrationConnectOauthErrors, + V2IntegrationOauthConnectResponses, + V2IntegrationOauthConnectErrors, ThrowOnError >({ url: "/api/integration/{integrationID}/connect/oauth", @@ -6877,9 +6887,7 @@ export class Connect extends HeyApiClient { }, }) } -} -export class Attempt extends HeyApiClient { /** * Cancel OAuth connection * @@ -6887,6 +6895,7 @@ export class Attempt extends HeyApiClient { */ public cancel( parameters: { + integrationID: string attemptID: string location?: { directory?: string | null @@ -6900,6 +6909,7 @@ export class Attempt extends HeyApiClient { [ { args: [ + { in: "path", key: "integrationID" }, { in: "path", key: "attemptID" }, { in: "query", key: "location" }, ], @@ -6907,11 +6917,11 @@ export class Attempt extends HeyApiClient { ], ) return (options?.client ?? this.client).delete< - V2IntegrationAttemptCancelResponses, - V2IntegrationAttemptCancelErrors, + V2IntegrationOauthCancelResponses, + V2IntegrationOauthCancelErrors, ThrowOnError >({ - url: "/api/integration/attempt/{attemptID}", + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}", ...options, ...params, }) @@ -6924,6 +6934,7 @@ export class Attempt extends HeyApiClient { */ public status( parameters: { + integrationID: string attemptID: string location?: { directory?: string | null @@ -6937,6 +6948,7 @@ export class Attempt extends HeyApiClient { [ { args: [ + { in: "path", key: "integrationID" }, { in: "path", key: "attemptID" }, { in: "query", key: "location" }, ], @@ -6944,11 +6956,11 @@ export class Attempt extends HeyApiClient { ], ) return (options?.client ?? this.client).get< - V2IntegrationAttemptStatusResponses, - V2IntegrationAttemptStatusErrors, + V2IntegrationOauthStatusResponses, + V2IntegrationOauthStatusErrors, ThrowOnError >({ - url: "/api/integration/attempt/{attemptID}", + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}", ...options, ...params, }) @@ -6961,6 +6973,7 @@ export class Attempt extends HeyApiClient { */ public complete( parameters: { + integrationID: string attemptID: string location?: { directory?: string | null @@ -6975,6 +6988,7 @@ export class Attempt extends HeyApiClient { [ { args: [ + { in: "path", key: "integrationID" }, { in: "path", key: "attemptID" }, { in: "query", key: "location" }, { in: "body", key: "code" }, @@ -6983,11 +6997,11 @@ export class Attempt extends HeyApiClient { ], ) return (options?.client ?? this.client).post< - V2IntegrationAttemptCompleteResponses, - V2IntegrationAttemptCompleteErrors, + V2IntegrationOauthCompleteResponses, + V2IntegrationOauthCompleteErrors, ThrowOnError >({ - url: "/api/integration/attempt/{attemptID}/complete", + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}/complete", ...options, ...params, headers: { @@ -6999,6 +7013,132 @@ export class Attempt extends HeyApiClient { } } +export class Command2 extends HeyApiClient { + /** + * Begin command connection + * + * Start a command authentication attempt. + */ + public connect( + parameters: { + integrationID: string + location?: { + directory?: string | null + workspace?: string | null + } | null + methodID?: string + label?: string | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "integrationID" }, + { in: "query", key: "location" }, + { in: "body", key: "methodID" }, + { in: "body", key: "label" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2IntegrationCommandConnectResponses, + V2IntegrationCommandConnectErrors, + ThrowOnError + >({ + url: "/api/integration/{integrationID}/connect/command", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Cancel command connection + * + * Cancel a command authentication attempt and terminate its process. + */ + public cancel( + parameters: { + integrationID: string + attemptID: string + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "integrationID" }, + { in: "path", key: "attemptID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + V2IntegrationCommandCancelResponses, + V2IntegrationCommandCancelErrors, + ThrowOnError + >({ + url: "/api/integration/{integrationID}/connect/command/{attemptID}", + ...options, + ...params, + }) + } + + /** + * Get command attempt status + * + * Poll the current status and output of a command authentication attempt. + */ + public status( + parameters: { + integrationID: string + attemptID: string + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "integrationID" }, + { in: "path", key: "attemptID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + V2IntegrationCommandStatusResponses, + V2IntegrationCommandStatusErrors, + ThrowOnError + >({ + url: "/api/integration/{integrationID}/connect/command/{attemptID}", + ...options, + ...params, + }) + } +} + export class Integration extends HeyApiClient { /** * List integrations @@ -7060,9 +7200,72 @@ export class Integration extends HeyApiClient { return (this._connect ??= new Connect({ client: this.client })) } - private _attempt?: Attempt - get attempt(): Attempt { - return (this._attempt ??= new Attempt({ client: this.client })) + private _oauth?: Oauth2 + get oauth(): Oauth2 { + return (this._oauth ??= new Oauth2({ client: this.client })) + } + + private _command?: Command2 + get command(): Command2 { + return (this._command ??= new Command2({ client: this.client })) + } +} + +export class Wellknown extends HeyApiClient { + /** + * Add wellknown integration + * + * Discover and persist an experimental wellknown integration source. + */ + public add( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + url?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "url" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2ExperimentalIntegrationWellknownAddResponses, + V2ExperimentalIntegrationWellknownAddErrors, + ThrowOnError + >({ + url: "/api/experimental/integration/wellknown", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Integration2 extends HeyApiClient { + private _wellknown?: Wellknown + get wellknown(): Wellknown { + return (this._wellknown ??= new Wellknown({ client: this.client })) + } +} + +export class Experimental2 extends HeyApiClient { + private _integration?: Integration2 + get integration(): Integration2 { + return (this._integration ??= new Integration2({ client: this.client })) } } @@ -7486,7 +7689,7 @@ export class Fs extends HeyApiClient { } } -export class Command2 extends HeyApiClient { +export class Command3 extends HeyApiClient { /** * List commands * @@ -8357,6 +8560,11 @@ export class V2 extends HeyApiClient { return (this._integration ??= new Integration({ client: this.client })) } + private _experimental?: Experimental2 + get experimental(): Experimental2 { + return (this._experimental ??= new Experimental2({ client: this.client })) + } + private _mcp?: Mcp2 get mcp(): Mcp2 { return (this._mcp ??= new Mcp2({ client: this.client })) @@ -8387,9 +8595,9 @@ export class V2 extends HeyApiClient { return (this._fs ??= new Fs({ client: this.client })) } - private _command?: Command2 - get command(): Command2 { - return (this._command ??= new Command2({ client: this.client })) + private _command?: Command3 + get command(): Command3 { + return (this._command ??= new Command3({ client: this.client })) } private _skill?: Skill diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 764c8dd60a..b24a95f5d4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3196,7 +3196,11 @@ export type IntegrationInputs = { [key: string]: string } -export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod +export type IntegrationMethod = + | IntegrationOAuthMethod + | IntegrationCommandMethod + | IntegrationKeyMethod + | IntegrationEnvMethod export type IntegrationRef = { id: string @@ -5709,6 +5713,13 @@ export type IntegrationOAuthMethod = { prompts?: Array } +export type IntegrationCommandMethod = { + id: string + type: "command" + label: string + command: Array +} + export type IntegrationKeyMethod = { type: "key" label?: string @@ -5781,6 +5792,46 @@ export type IntegrationAttemptStatus = } } +export type IntegrationCommandAttempt = { + attemptID: string + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } +} + +export type IntegrationCommandAttemptStatus = + | { + status: "pending" + message?: string + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "complete" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "failed" + message: string + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + | { + status: "expired" + time: { + created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + } + export type McpStatusConnected2 = { status: "connected" } @@ -16808,6 +16859,44 @@ export type V2IntegrationGetResponses = { export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2IntegrationGetResponses] +export type V2ExperimentalIntegrationWellknownAddData = { + body: { + url: string + } + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/experimental/integration/wellknown" +} + +export type V2ExperimentalIntegrationWellknownAddErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ExperimentalIntegrationWellknownAddError = + V2ExperimentalIntegrationWellknownAddErrors[keyof V2ExperimentalIntegrationWellknownAddErrors] + +export type V2ExperimentalIntegrationWellknownAddResponses = { + /** + * + */ + 204: void +} + +export type V2ExperimentalIntegrationWellknownAddResponse = + V2ExperimentalIntegrationWellknownAddResponses[keyof V2ExperimentalIntegrationWellknownAddResponses] + export type V2IntegrationConnectKeyData = { body: { key: string @@ -16847,7 +16936,7 @@ export type V2IntegrationConnectKeyResponses = { export type V2IntegrationConnectKeyResponse = V2IntegrationConnectKeyResponses[keyof V2IntegrationConnectKeyResponses] -export type V2IntegrationConnectOauthData = { +export type V2IntegrationOauthConnectData = { body: { methodID: string inputs: { @@ -16867,7 +16956,7 @@ export type V2IntegrationConnectOauthData = { url: "/api/integration/{integrationID}/connect/oauth" } -export type V2IntegrationConnectOauthErrors = { +export type V2IntegrationOauthConnectErrors = { /** * InvalidRequestError */ @@ -16878,9 +16967,9 @@ export type V2IntegrationConnectOauthErrors = { 401: UnauthorizedError } -export type V2IntegrationConnectOauthError = V2IntegrationConnectOauthErrors[keyof V2IntegrationConnectOauthErrors] +export type V2IntegrationOauthConnectError = V2IntegrationOauthConnectErrors[keyof V2IntegrationOauthConnectErrors] -export type V2IntegrationConnectOauthResponses = { +export type V2IntegrationOauthConnectResponses = { /** * Success */ @@ -16890,12 +16979,13 @@ export type V2IntegrationConnectOauthResponses = { } } -export type V2IntegrationConnectOauthResponse = - V2IntegrationConnectOauthResponses[keyof V2IntegrationConnectOauthResponses] +export type V2IntegrationOauthConnectResponse = + V2IntegrationOauthConnectResponses[keyof V2IntegrationOauthConnectResponses] -export type V2IntegrationAttemptCancelData = { +export type V2IntegrationOauthCancelData = { body?: never path: { + integrationID: string attemptID: string } query?: { @@ -16904,10 +16994,10 @@ export type V2IntegrationAttemptCancelData = { workspace?: string | null } | null } - url: "/api/integration/attempt/{attemptID}" + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}" } -export type V2IntegrationAttemptCancelErrors = { +export type V2IntegrationOauthCancelErrors = { /** * InvalidRequestError */ @@ -16918,21 +17008,22 @@ export type V2IntegrationAttemptCancelErrors = { 401: UnauthorizedError } -export type V2IntegrationAttemptCancelError = V2IntegrationAttemptCancelErrors[keyof V2IntegrationAttemptCancelErrors] +export type V2IntegrationOauthCancelError = V2IntegrationOauthCancelErrors[keyof V2IntegrationOauthCancelErrors] -export type V2IntegrationAttemptCancelResponses = { +export type V2IntegrationOauthCancelResponses = { /** * */ 204: void } -export type V2IntegrationAttemptCancelResponse = - V2IntegrationAttemptCancelResponses[keyof V2IntegrationAttemptCancelResponses] +export type V2IntegrationOauthCancelResponse = + V2IntegrationOauthCancelResponses[keyof V2IntegrationOauthCancelResponses] -export type V2IntegrationAttemptStatusData = { +export type V2IntegrationOauthStatusData = { body?: never path: { + integrationID: string attemptID: string } query?: { @@ -16941,10 +17032,10 @@ export type V2IntegrationAttemptStatusData = { workspace?: string | null } | null } - url: "/api/integration/attempt/{attemptID}" + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}" } -export type V2IntegrationAttemptStatusErrors = { +export type V2IntegrationOauthStatusErrors = { /** * InvalidRequestError */ @@ -16955,9 +17046,9 @@ export type V2IntegrationAttemptStatusErrors = { 401: UnauthorizedError } -export type V2IntegrationAttemptStatusError = V2IntegrationAttemptStatusErrors[keyof V2IntegrationAttemptStatusErrors] +export type V2IntegrationOauthStatusError = V2IntegrationOauthStatusErrors[keyof V2IntegrationOauthStatusErrors] -export type V2IntegrationAttemptStatusResponses = { +export type V2IntegrationOauthStatusResponses = { /** * Success */ @@ -16967,14 +17058,15 @@ export type V2IntegrationAttemptStatusResponses = { } } -export type V2IntegrationAttemptStatusResponse = - V2IntegrationAttemptStatusResponses[keyof V2IntegrationAttemptStatusResponses] +export type V2IntegrationOauthStatusResponse = + V2IntegrationOauthStatusResponses[keyof V2IntegrationOauthStatusResponses] -export type V2IntegrationAttemptCompleteData = { +export type V2IntegrationOauthCompleteData = { body: { code?: string | null } path: { + integrationID: string attemptID: string } query?: { @@ -16983,10 +17075,10 @@ export type V2IntegrationAttemptCompleteData = { workspace?: string | null } | null } - url: "/api/integration/attempt/{attemptID}/complete" + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}/complete" } -export type V2IntegrationAttemptCompleteErrors = { +export type V2IntegrationOauthCompleteErrors = { /** * InvalidRequestError */ @@ -16997,18 +17089,140 @@ export type V2IntegrationAttemptCompleteErrors = { 401: UnauthorizedError } -export type V2IntegrationAttemptCompleteError = - V2IntegrationAttemptCompleteErrors[keyof V2IntegrationAttemptCompleteErrors] +export type V2IntegrationOauthCompleteError = V2IntegrationOauthCompleteErrors[keyof V2IntegrationOauthCompleteErrors] -export type V2IntegrationAttemptCompleteResponses = { +export type V2IntegrationOauthCompleteResponses = { /** * */ 204: void } -export type V2IntegrationAttemptCompleteResponse = - V2IntegrationAttemptCompleteResponses[keyof V2IntegrationAttemptCompleteResponses] +export type V2IntegrationOauthCompleteResponse = + V2IntegrationOauthCompleteResponses[keyof V2IntegrationOauthCompleteResponses] + +export type V2IntegrationCommandConnectData = { + body: { + methodID: string + label?: string | null + } + path: { + integrationID: string + } + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/integration/{integrationID}/connect/command" +} + +export type V2IntegrationCommandConnectErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationCommandConnectError = + V2IntegrationCommandConnectErrors[keyof V2IntegrationCommandConnectErrors] + +export type V2IntegrationCommandConnectResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: IntegrationCommandAttempt + } +} + +export type V2IntegrationCommandConnectResponse = + V2IntegrationCommandConnectResponses[keyof V2IntegrationCommandConnectResponses] + +export type V2IntegrationCommandCancelData = { + body?: never + path: { + integrationID: string + attemptID: string + } + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/integration/{integrationID}/connect/command/{attemptID}" +} + +export type V2IntegrationCommandCancelErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationCommandCancelError = V2IntegrationCommandCancelErrors[keyof V2IntegrationCommandCancelErrors] + +export type V2IntegrationCommandCancelResponses = { + /** + * + */ + 204: void +} + +export type V2IntegrationCommandCancelResponse = + V2IntegrationCommandCancelResponses[keyof V2IntegrationCommandCancelResponses] + +export type V2IntegrationCommandStatusData = { + body?: never + path: { + integrationID: string + attemptID: string + } + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/integration/{integrationID}/connect/command/{attemptID}" +} + +export type V2IntegrationCommandStatusErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2IntegrationCommandStatusError = V2IntegrationCommandStatusErrors[keyof V2IntegrationCommandStatusErrors] + +export type V2IntegrationCommandStatusResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: IntegrationCommandAttemptStatus + } +} + +export type V2IntegrationCommandStatusResponse = + V2IntegrationCommandStatusResponses[keyof V2IntegrationCommandStatusResponses] export type V2McpListData = { body?: never diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 6c29d58776..a9cd73fa83 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -4,6 +4,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { InvalidRequestError } from "@opencode-ai/protocol/errors" import { response } from "../location" +import { WellKnown } from "@opencode-ai/core/wellknown" const authorize = (effect: Effect.Effect) => effect.pipe( @@ -33,6 +34,22 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" return yield* response(service.get(ctx.params.integrationID)) }), ) + .handle( + "integration.wellknown.add", + Effect.fn(function* (ctx) { + const wellknown = yield* WellKnown.Service + const integration = yield* Integration.Service + yield* wellknown + .add(ctx.payload.url) + .pipe( + Effect.mapError( + (error) => new InvalidRequestError({ message: error.message, kind: "well_known_discovery" }), + ), + ) + yield* integration.reload() + return HttpApiSchema.NoContent.make() + }), + ) .handle( "integration.connect.key", Effect.fn(function* (ctx) { @@ -48,12 +65,12 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" }), ) .handle( - "integration.connect.oauth", + "integration.oauth.connect", Effect.fn(function* (ctx) { const service = yield* Integration.Service return yield* response( authorize( - service.connection.oauth({ + service.oauth.connect({ integrationID: ctx.params.integrationID, methodID: ctx.payload.methodID, inputs: ctx.payload.inputs, @@ -64,39 +81,91 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" }), ) .handle( - "integration.attempt.status", + "integration.oauth.status", Effect.fn(function* (ctx) { const service = yield* Integration.Service - return yield* response(service.attempt.status(ctx.params.attemptID)) + return yield* response( + service.oauth.status({ + integrationID: ctx.params.integrationID, + attemptID: ctx.params.attemptID, + }), + ) }), ) .handle( - "integration.attempt.complete", + "integration.oauth.complete", Effect.fn(function* (ctx) { const service = yield* Integration.Service - yield* service.attempt.complete({ attemptID: ctx.params.attemptID, code: ctx.payload.code }).pipe( - Effect.mapError( - (error) => - new InvalidRequestError({ - message: - error._tag === "Integration.CodeRequired" - ? "Authorization code is required" - : "Authentication failed", - kind: - error._tag === "Integration.CodeRequired" - ? "integration_code_required" - : "integration_authorization", - }), - ), - ) + yield* service.oauth + .complete({ + integrationID: ctx.params.integrationID, + attemptID: ctx.params.attemptID, + code: ctx.payload.code, + }) + .pipe( + Effect.mapError( + (error) => + new InvalidRequestError({ + message: + error._tag === "Integration.CodeRequired" + ? "Authorization code is required" + : "Authentication failed", + kind: + error._tag === "Integration.CodeRequired" + ? "integration_code_required" + : "integration_authorization", + }), + ), + ) return HttpApiSchema.NoContent.make() }), ) .handle( - "integration.attempt.cancel", + "integration.oauth.cancel", Effect.fn(function* (ctx) { const service = yield* Integration.Service - yield* service.attempt.cancel(ctx.params.attemptID) + yield* service.oauth.cancel({ + integrationID: ctx.params.integrationID, + attemptID: ctx.params.attemptID, + }) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "integration.command.connect", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + return yield* response( + authorize( + service.command.connect({ + integrationID: ctx.params.integrationID, + methodID: ctx.payload.methodID, + label: ctx.payload.label, + }), + ), + ) + }), + ) + .handle( + "integration.command.status", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + return yield* response( + service.command.status({ + integrationID: ctx.params.integrationID, + attemptID: ctx.params.attemptID, + }), + ) + }), + ) + .handle( + "integration.command.cancel", + Effect.fn(function* (ctx) { + const service = yield* Integration.Service + yield* service.command.cancel({ + integrationID: ctx.params.integrationID, + attemptID: ctx.params.attemptID, + }) return HttpApiSchema.NoContent.make() }), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 05e76f0f97..7a1a1d82be 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -16,6 +16,7 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { WellKnown } from "@opencode-ai/core/wellknown" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Context, Effect, Layer, Option } from "effect" @@ -44,6 +45,7 @@ const applicationServices = LayerNode.group([ PermissionSaved.node, PtyTicket.node, Credential.node, + WellKnown.node, PtyEnvironment.node, LocationServiceMap.node, SessionRestart.node, @@ -85,7 +87,7 @@ function makeRoutes( Layer.flatMap((context) => { const services = Layer.succeedContext(context) const requestServices = Layer.merge( - Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)), + Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)), ServerInfo.layer(serviceURLs), ) return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( @@ -109,5 +111,4 @@ function simulateEnabled() { return !!process.env.OPENCODE_SIMULATE } -export const webHandler = () => - HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices))) +export const webHandler = () => HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices))) diff --git a/packages/simulation/src/backend/simulated-provider.ts b/packages/simulation/src/backend/simulated-provider.ts index 69f01184aa..98ef54fe42 100644 --- a/packages/simulation/src/backend/simulated-provider.ts +++ b/packages/simulation/src/backend/simulated-provider.ts @@ -1,3 +1,4 @@ +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Cause, Context, Effect, Fiber, FiberSet, Layer, PubSub, Queue, Ref, Schema, Semaphore, Stream } from "effect" import { SimulationControlServer } from "../control-server" import { SimulationProtocol } from "../protocol" @@ -211,6 +212,15 @@ function handle( request: SimulationProtocol.Backend.Request, ) { switch (request.method) { + case "simulation.handshake": + return SimulationProtocol.Handshake.dispatch( + { + role: "backend", + server: { name: "opencode", version: InstallationVersion }, + capabilities: SimulationProtocol.Backend.Capabilities, + }, + request.params, + ) case "llm.attach": return controllerLock.withPermit( Effect.gen(function* () { diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index 533ddeb3c6..f26bb2eaf8 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,3 +1,4 @@ +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect } from "effect" import { SimulationControlServer } from "../control-server" import { SimulationProtocol } from "../protocol" @@ -6,6 +7,15 @@ import { SimulationRenderer } from "./renderer" function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) { switch (request.method) { + case "simulation.handshake": + return SimulationProtocol.Handshake.dispatch( + { + role: "ui", + server: { name: "opencode", version: InstallationVersion }, + capabilities: SimulationProtocol.Frontend.Capabilities, + }, + request.params, + ) case "ui.capture": return SimulationActions.capture(harness) case "ui.screenshot": diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index 55f08733bb..9a15098333 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -48,7 +48,136 @@ export namespace JsonRpc { } } +export namespace Handshake { + export const ProtocolVersion = Schema.Literal(1) + export type ProtocolVersion = Schema.Schema.Type + + export const Capability = Schema.NonEmptyString + export type Capability = Schema.Schema.Type + + export const EndpointRole = Schema.Literals(["ui", "backend"]) + export type EndpointRole = Schema.Schema.Type + + export const Identity = Schema.Struct({ + name: Schema.NonEmptyString, + version: Schema.NonEmptyString, + }) + export interface Identity extends Schema.Schema.Type {} + + export const Params = Schema.Struct({ + client: Identity, + expectedRole: EndpointRole, + offeredVersions: Schema.Array( + Schema.Int.check(Schema.isGreaterThan(0)), + ).check(Schema.isMinLength(1), Schema.isUnique()), + requiredCapabilities: Schema.Array(Capability).check(Schema.isUnique()), + optionalCapabilities: Schema.Array(Capability).check(Schema.isUnique()), + }) + export interface Params extends Schema.Schema.Type {} + + export const Response = Schema.Struct({ + protocolVersion: ProtocolVersion, + role: EndpointRole, + server: Identity, + capabilities: Schema.Array(Capability), + }) + export interface Response extends Schema.Schema.Type {} + + export const Request = Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literal("simulation.handshake"), + params: Params, + }) + export interface Request extends Schema.Schema.Type {} + + export interface DispatchAction { + readonly role: EndpointRole + readonly server: Identity + readonly capabilities: ReadonlyArray + } + + export class RoleMismatchError extends Schema.TaggedErrorClass()( + "SimulationHandshake.RoleMismatchError", + { + expected: EndpointRole, + actual: EndpointRole, + message: Schema.String, + }, + ) {} + + export class UnsupportedProtocolError extends Schema.TaggedErrorClass()( + "SimulationHandshake.UnsupportedProtocolError", + { + offered: Schema.Array(Schema.Number), + supported: Schema.Array(ProtocolVersion), + message: Schema.String, + }, + ) {} + + export class MissingCapabilityError extends Schema.TaggedErrorClass()( + "SimulationHandshake.MissingCapabilityError", + { + missing: Schema.Array(Capability), + message: Schema.String, + }, + ) {} + + export function dispatch(action: DispatchAction, params: Params) { + return Effect.gen(function* () { + if (params.expectedRole !== action.role) { + return yield* Effect.fail( + new RoleMismatchError({ + expected: params.expectedRole, + actual: action.role, + message: `Expected simulation endpoint role ${params.expectedRole}, received ${action.role}`, + }), + ) + } + if (!params.offeredVersions.includes(1)) { + return yield* Effect.fail( + new UnsupportedProtocolError({ + offered: params.offeredVersions, + supported: [1], + message: "No mutually supported simulation protocol version", + }), + ) + } + const installed = new Set(action.capabilities) + const missing = params.requiredCapabilities.filter((capability) => !installed.has(capability)) + if (missing.length > 0) { + return yield* Effect.fail( + new MissingCapabilityError({ + missing, + message: `Simulation endpoint is missing required capabilities: ${missing.join(", ")}`, + }), + ) + } + return { + protocolVersion: 1, + role: action.role, + server: action.server, + capabilities: Array.from(installed), + } satisfies Response + }) + } +} + export namespace Frontend { + export const Capabilities = [ + "ui.type", + "ui.press", + "ui.enter", + "ui.arrow", + "ui.focus", + "ui.click", + "ui.resize", + "ui.matches", + "ui.screenshot", + "ui.state", + "ui.capture", + "ui.recording.finish", + ] as const satisfies ReadonlyArray + export const KeyModifiers = Schema.Struct({ ctrl: Schema.optional(Schema.Boolean), shift: Schema.optional(Schema.Boolean), @@ -149,6 +278,7 @@ export namespace Frontend { export interface ResizeParams extends Schema.Schema.Type {} export const Request = Schema.Union([ + Handshake.Request, Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }), @@ -173,6 +303,15 @@ export namespace Frontend { } export namespace Backend { + export const Capabilities = [ + "llm.attach", + "llm.chunk", + "llm.finish", + "llm.disconnect", + "llm.pending", + "llm.request", + ] as const satisfies ReadonlyArray + export const Item = Schema.Union([ Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }), Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }), @@ -203,6 +342,7 @@ export namespace Backend { export interface DisconnectParams extends Schema.Schema.Type {} export const Request = Schema.Union([ + Handshake.Request, Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }), diff --git a/packages/simulation/test/frontend-server.test.ts b/packages/simulation/test/frontend-server.test.ts index 451675d2e2..849adfb6f7 100644 --- a/packages/simulation/test/frontend-server.test.ts +++ b/packages/simulation/test/frontend-server.test.ts @@ -19,6 +19,30 @@ test("scopes the frontend control server and reports malformed JSON", async () = Queue.offerUnsafe(messages, JSON.parse(String(event.data))) }) + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "simulation.handshake", + params: { + client: { name: "test", version: "test" }, + expectedRole: "ui", + offeredVersions: [1], + requiredCapabilities: ["ui.state"], + optionalCapabilities: [], + }, + }), + ) + expect(yield* Queue.take(messages)).toMatchObject({ + id: 0, + result: { + protocolVersion: 1, + role: "ui", + server: { name: "opencode", version: expect.any(String) }, + capabilities: expect.arrayContaining(["ui.state", "ui.capture"]), + }, + }) + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ui.state" })) expect(yield* Queue.take(messages)).toMatchObject({ id: 1, diff --git a/packages/simulation/test/protocol.test.ts b/packages/simulation/test/protocol.test.ts index 5d0ea4c970..bd516bea99 100644 --- a/packages/simulation/test/protocol.test.ts +++ b/packages/simulation/test/protocol.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from "bun:test" -import { Frontend } from "../src/protocol" +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Backend, Frontend, Handshake } from "../src/protocol" test("decodes ui.matches text params", () => { expect( @@ -19,3 +20,104 @@ test("decodes ui.matches text params", () => { }), ).toThrow() }) + +const params: Handshake.Params = { + client: { name: "opencode-drive", version: "test" }, + expectedRole: "ui", + offeredVersions: [1], + requiredCapabilities: ["ui.state"], + optionalCapabilities: ["ui.capture", "future.capability"], +} + +const ui: Handshake.DispatchAction = { + role: "ui", + server: { name: "opencode", version: "test" }, + capabilities: Frontend.Capabilities, +} + +describe("simulation.handshake", () => { + test("decodes through both endpoint request protocols", () => { + const request = { + jsonrpc: "2.0" as const, + id: 1, + method: "simulation.handshake" as const, + params, + } + expect(Frontend.decodeRequest(request)).toEqual(request) + expect(Backend.decodeRequest({ ...request, params: { ...params, expectedRole: "backend" } })).toMatchObject({ + method: "simulation.handshake", + params: { expectedRole: "backend" }, + }) + }) + + test("rejects invalid version and capability declarations", () => { + const request = { + jsonrpc: "2.0" as const, + id: 1, + method: "simulation.handshake" as const, + params, + } + expect(() => + Frontend.decodeRequest({ + ...request, + params: { ...params, offeredVersions: [] }, + }), + ).toThrow() + expect(() => + Frontend.decodeRequest({ + ...request, + params: { ...params, requiredCapabilities: ["ui.state", "ui.state"] }, + }), + ).toThrow() + expect(() => + Frontend.decodeRequest({ + ...request, + params: { ...params, optionalCapabilities: [""] }, + }), + ).toThrow() + }) + + test("selects the protocol and advertises only installed capabilities", async () => { + await expect(Effect.runPromise(Handshake.dispatch(ui, params))).resolves.toEqual({ + protocolVersion: 1, + role: "ui", + server: { name: "opencode", version: "test" }, + capabilities: [...Frontend.Capabilities], + }) + }) + + test("rejects a role mismatch", async () => { + await expect( + Effect.runPromise(Handshake.dispatch(ui, { ...params, expectedRole: "backend" })), + ).rejects.toMatchObject({ _tag: "SimulationHandshake.RoleMismatchError", expected: "backend", actual: "ui" }) + }) + + test("rejects unsupported protocol versions", async () => { + await expect(Effect.runPromise(Handshake.dispatch(ui, { ...params, offeredVersions: [2] }))).rejects.toMatchObject({ + _tag: "SimulationHandshake.UnsupportedProtocolError", + offered: [2], + supported: [1], + }) + }) + + test("rejects a missing required capability but ignores missing optional capabilities", async () => { + await expect( + Effect.runPromise( + Handshake.dispatch(ui, { + ...params, + requiredCapabilities: ["ui.state", "ui.future"], + }), + ), + ).rejects.toMatchObject({ _tag: "SimulationHandshake.MissingCapabilityError", missing: ["ui.future"] }) + + await expect( + Effect.runPromise( + Handshake.dispatch(ui, { + ...params, + requiredCapabilities: [], + optionalCapabilities: ["ui.future"], + }), + ), + ).resolves.toMatchObject({ capabilities: Frontend.Capabilities }) + }) +}) diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index 23da2d9d0d..650fd2d172 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -7,6 +7,30 @@ import { availableEndpoint, connect } from "./fixture/websocket" test("streams a Drive-controlled provider response and removes the finished invocation", async () => { await runProvider((provider, socket, messages) => Effect.gen(function* () { + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "simulation.handshake", + params: { + client: { name: "test", version: "test" }, + expectedRole: "backend", + offeredVersions: [1], + requiredCapabilities: ["llm.attach", "llm.request"], + optionalCapabilities: [], + }, + }), + ) + expect(yield* Queue.take(messages)).toMatchObject({ + id: 0, + result: { + protocolVersion: 1, + role: "backend", + server: { name: "opencode", version: expect.any(String) }, + capabilities: expect.arrayContaining(["llm.attach", "llm.request"]), + }, + }) + socket.send("{") expect(yield* Queue.take(messages)).toMatchObject({ id: null, error: { code: -32000 } }) yield* attach(socket, messages) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b8c1f32063..d15f0d8cf3 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -46,6 +46,8 @@ import { EditorContextProvider } from "./context/editor" import { useEvent } from "./context/event" import { ClientProvider, useClient } from "./context/client" import { StartupLoading } from "./component/startup-loading" +import { DevToolsSidebar } from "./component/devtools-sidebar" +import { DevTools } from "./devtools" import { Reconnecting } from "./component/reconnecting" import { DataProvider, useData } from "./context/data" import { LocationProvider, useLocation } from "./context/location" @@ -86,6 +88,8 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" +const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" }) + registerOpencodeSpinner() const appGlobalBindingCommands = [ @@ -252,9 +256,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const pluginRuntime = createPluginRuntime() yield* Effect.tryPromise(async () => { + const appStarted = performance.now() // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. void renderer.getPalette({ size: 16 }).catch(() => undefined) + const modeStarted = performance.now() const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark" + themePerformance.set("Detect light/dark mode", `${(performance.now() - modeStarted).toFixed(2)} ms`) if (renderer.isDestroyed) return await render(() => { @@ -342,6 +349,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { config.data.debug?.devtools ?? false) const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -421,6 +430,11 @@ function App(props: { pair?: DialogPairCredentials }) { const plugins = usePlugin() const clipboard = useClipboard() + createEffect(() => { + if (!themeState.ready) return + themePerformance.set("Total", `${(performance.now() - props.started).toFixed(2)} ms`) + }) + // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, // without having to open the status panel. Tracking the last alerted status avoids re-toasting // the same problem on every refresh while still re-alerting if the state changes. @@ -737,6 +751,7 @@ function App(props: { pair?: DialogPairCredentials }) { { name: "opencode.settings", title: "Open settings", + suggested: true, slash: { name: "settings" }, run: () => { dialog.replace(() => ) @@ -1085,31 +1100,38 @@ function App(props: { pair?: DialogPairCredentials }) { - - - - - - - - - {(_) => } - - - - ( - route.navigate({ type: "home" })} /> - )} - /> - - + + + + + + + + + + + {(_) => } + + + + ( + route.navigate({ type: "home" })} /> + )} + /> + + + + + + + + - - - - - + + + + diff --git a/packages/tui/src/component/devtools-sidebar.tsx b/packages/tui/src/component/devtools-sidebar.tsx new file mode 100644 index 0000000000..e47edb9a36 --- /dev/null +++ b/packages/tui/src/component/devtools-sidebar.tsx @@ -0,0 +1,41 @@ +import { TextAttributes } from "@opentui/core" +import { For } from "solid-js" +import { useTheme } from "../context/theme" +import { DevTools } from "../devtools" + +export function DevToolsSidebar() { + const { themeV2 } = useTheme().contextual("elevated") + + return ( + + + {(group) => ( + + + + {group.title} + + + + {(entry) => ( + + {entry.key} + + {String(entry.value)} + + )} + + + )} + + + ) +} diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index c63e6e5d87..01cdb950dc 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -206,6 +206,14 @@ const settings: Setting[] = [ values: [false, true], labels: ["off", "on"], }, + { + title: "DevTools", + category: "Debug", + path: ["debug", "devtools"], + default: false, + values: [false, true], + labels: ["off", "on"], + }, ] export function DialogConfig() { diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 218e2cd220..2cc9c58584 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -1,8 +1,9 @@ import { TextAttributes } from "@opentui/core" import type { ConnectionInfo, - IntegrationConnectOauthOutput, + IntegrationCommandConnectOutput, IntegrationInfo, + IntegrationOauthConnectOutput, IntegrationOAuthMethod, } from "@opencode-ai/client" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" @@ -27,7 +28,8 @@ const INTEGRATION_PRIORITY: Record = { } type ConnectMethod = Exclude -type IntegrationAttempt = IntegrationConnectOauthOutput["data"] +type IntegrationAttempt = IntegrationOauthConnectOutput["data"] +type CommandAttempt = IntegrationCommandConnectOutput["data"] type OnIntegrationConnected = (providerID?: string) => void export function integrationOptions(list: IntegrationInfo[]) { @@ -167,9 +169,148 @@ function openMethod( dialog.replace(() => ) return } + if (method.type === "command") { + dialog.replace(() => ) + return + } void beginOAuth(integration, method, dialog, onConnected) } +function CommandStarting(props: { + integration: IntegrationInfo + method: Extract + onConnected?: OnIntegrationConnected +}) { + const data = useData() + const dialog = useDialog() + const client = useClient() + const toast = useToast() + let closed = false + let handedOff = false + + onMount(() => { + void client.api.integration.command + .connect({ + integrationID: props.integration.id, + methodID: props.method.id, + location: location(data), + }) + .then((result) => { + if (closed) { + void client.api.integration.command.cancel({ + integrationID: props.integration.id, + attemptID: result.data.attemptID, + location: location(data), + }) + return + } + handedOff = true + dialog.replace(() => ( + + )) + }) + .catch((cause) => { + if (closed) return + toast.show({ variant: "error", message: message(cause) }) + dialog.clear() + }) + }) + onCleanup(() => { + if (!handedOff) closed = true + }) + + return +} + +function CommandPending(props: { + integration: IntegrationInfo + title: string + attempt: CommandAttempt + onConnected?: OnIntegrationConnected +}) { + const data = useData() + const dialog = useDialog() + const client = useClient() + const toast = useToast() + const [output, setOutput] = createSignal("") + let timer: ReturnType | undefined + let settled = false + + const poll = () => { + void client.api.integration.command + .status({ + integrationID: props.integration.id, + attemptID: props.attempt.attemptID, + location: location(data), + }) + .then((result) => { + const status = result.data + if (status.status === "pending") { + setOutput(status.message ?? "") + timer = setTimeout(poll, 500) + return + } + settled = true + if (status.status === "complete") { + void connected(props.integration, data, dialog, toast, props.onConnected) + return + } + toast.show({ + variant: "error", + message: status.status === "failed" ? status.message : "Authentication expired", + }) + dialog.clear() + }) + .catch((cause) => { + settled = true + toast.show({ variant: "error", message: message(cause) }) + dialog.clear() + }) + } + + onMount(poll) + onCleanup(() => { + if (timer) clearTimeout(timer) + if (settled) return + void client.api.integration.command.cancel({ + integrationID: props.integration.id, + attemptID: props.attempt.attemptID, + location: location(data), + }) + }) + + return +} + +function CommandView(props: { title: string; output: string; message: string }) { + const dialog = useDialog() + const { theme } = useTheme() + onMount(() => dialog.setSize("large")) + return ( + + + + {props.title} + + dialog.clear()}> + esc close + + + + {props.output.trim()} + + + {props.message} + + + ) +} + function KeyMethod(props: { integration: IntegrationInfo method: Extract @@ -227,8 +368,8 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void client.api.integration.connect - .oauth({ + void client.api.integration.oauth + .connect({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -297,8 +438,8 @@ function OAuthAuto(props: { })) const poll = () => { - void client.api.integration.attempt - .status({ attemptID: props.attempt.attemptID, location: location(data) }) + void client.api.integration.oauth + .status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -324,7 +465,11 @@ function OAuthAuto(props: { onCleanup(() => { if (timer) clearTimeout(timer) if (settled) return - void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void client.api.integration.oauth.cancel({ + integrationID: props.integration.id, + attemptID: props.attempt.attemptID, + location: location(data), + }) }) return ( @@ -354,7 +499,11 @@ function OAuthCode(props: { onCleanup(() => { if (settled) return - void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void client.api.integration.oauth.cancel({ + integrationID: props.integration.id, + attemptID: props.attempt.attemptID, + location: location(data), + }) }) return ( @@ -363,8 +512,13 @@ function OAuthCode(props: { placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void client.api.integration.attempt - .complete({ attemptID: props.attempt.attemptID, location: location(data), code }) + void client.api.integration.oauth + .complete({ + integrationID: props.integration.id, + attemptID: props.attempt.attemptID, + location: location(data), + code, + }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected) diff --git a/packages/tui/src/component/logo.tsx b/packages/tui/src/component/logo.tsx index bc24b3c553..0acaa44882 100644 --- a/packages/tui/src/component/logo.tsx +++ b/packages/tui/src/component/logo.tsx @@ -5,10 +5,10 @@ import { tint } from "../theme/color" import { logo } from "../logo" export function Logo() { - const { theme } = useTheme() + const { themeV2 } = useTheme() const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => { - const shadow = tint(theme.background, fg, 0.25) + const shadow = tint(themeV2.background(), fg, 0.25) const attrs = bold ? TextAttributes.BOLD : undefined return Array.from(line).map((char) => { if (char === "_") { @@ -52,8 +52,8 @@ export function Logo() { {(line, index) => ( - {renderLine(line, theme.textMuted, false)} - {renderLine(logo.right[index()], theme.text, true)} + {renderLine(line, themeV2.text.subdued(), false)} + {renderLine(logo.right[index()], themeV2.text(), true)} )} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index fb0a6e2fca..3688ef3ef7 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -190,7 +190,7 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const exit = useExit() const dimensions = useTerminalDimensions() - const { theme, syntax } = useTheme() + const { themeV2, syntax } = useTheme() const animationsEnabled = createMemo(() => config.animations ?? true) const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) @@ -297,8 +297,8 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - if (props.disabled) input.cursorColor = theme.backgroundElement - if (!props.disabled) input.cursorColor = theme.text + if (props.disabled) input.cursorColor = themeV2.background.surface.offset() + if (!props.disabled) input.cursorColor = themeV2.text() }) const usage = createMemo(() => { @@ -1306,10 +1306,10 @@ export function Prompt(props: PromptProps) { } const highlight = createMemo(() => { - if (leader()) return theme.border - if (store.mode === "shell") return theme.primary + if (leader()) return themeV2.border() + if (store.mode === "shell") return themeV2.background.action.primary() const agent = local.agent.current() - if (!agent) return theme.border + if (!agent) return themeV2.border() return local.agent.color(agent.id) }) @@ -1326,7 +1326,7 @@ export function Prompt(props: PromptProps) { () => !!local.agent.current() && store.mode === "normal" && showVariant(), animationsEnabled, ) - const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha())) + const borderHighlight = createMemo(() => tint(themeV2.border(), highlight(), agentMetaAlpha())) const placeholderText = createMemo(() => { if (props.showPlaceholder === false) return undefined @@ -1346,7 +1346,7 @@ export function Prompt(props: PromptProps) { const spinnerDef = createMemo(() => { const agent = status() === "running" ? local.agent.current() : local.agent.current() - const color = agent ? local.agent.color(agent.id) : theme.border + const color = agent ? local.agent.color(agent.id) : themeV2.border() return { frames: createFrames({ color, @@ -1383,16 +1383,16 @@ export function Prompt(props: PromptProps) { paddingRight={2} paddingTop={1} flexShrink={0} - backgroundColor={theme.backgroundElement} + backgroundColor={themeV2.background.action.secondary("focused")} flexGrow={1} width="100%" >