chore: update merge branch with latest v2
This commit is contained in:
commit
143568a0ef
199 changed files with 8926 additions and 2482 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
bun.lock
71
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=="],
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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<typeof GoogleVertexAnthropicBody>
|
||||
|
||||
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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
81
packages/ai/src/providers/google-vertex-chat.ts
Normal file
81
packages/ai/src/providers/google-vertex-chat.ts
Normal file
|
|
@ -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<Settings>["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)
|
||||
}
|
||||
111
packages/ai/src/providers/google-vertex-messages.ts
Normal file
111
packages/ai/src/providers/google-vertex-messages.ts
Normal file
|
|
@ -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<Settings>["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)
|
||||
}
|
||||
|
|
@ -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<Settings>["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,
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
export { model } from "../google-vertex-anthropic"
|
||||
export type { Settings } from "../google-vertex-anthropic"
|
||||
2
packages/ai/src/providers/google-vertex/chat.ts
Normal file
2
packages/ai/src/providers/google-vertex/chat.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { model } from "../google-vertex-chat"
|
||||
export type { Settings } from "../google-vertex-chat"
|
||||
2
packages/ai/src/providers/google-vertex/gemini.ts
Normal file
2
packages/ai/src/providers/google-vertex/gemini.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { model } from "../google-vertex"
|
||||
export type { Settings } from "../google-vertex"
|
||||
2
packages/ai/src/providers/google-vertex/messages.ts
Normal file
2
packages/ai/src/providers/google-vertex/messages.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { model } from "../google-vertex-messages"
|
||||
export type { Settings } from "../google-vertex-messages"
|
||||
2
packages/ai/src/providers/google-vertex/responses.ts
Normal file
2
packages/ai/src/providers/google-vertex/responses.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { model } from "../google-vertex-responses"
|
||||
export type { Settings } from "../google-vertex-responses"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
52
packages/cli/src/commands/handlers/auth/connect.ts
Normal file
52
packages/cli/src/commands/handlers/auth/connect.ts
Normal file
|
|
@ -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<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, 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<A>(task: () => Promise<A>) {
|
||||
return Effect.tryPromise({ try: task, catch: (cause) => cause })
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
|
||||
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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -407,75 +407,124 @@ export type Endpoint10_1Input = {
|
|||
export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.get"]>>
|
||||
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
|
||||
|
||||
type Endpoint10_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||
type Endpoint10_2Request = Parameters<RawClient["server.integration"]["integration.wellknown.add"]>[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<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
||||
export type IntegrationConnectKeyOperation<E = never> = (
|
||||
export type Endpoint10_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.wellknown.add"]>>
|
||||
export type IntegrationWellknownAddOperation<E = never> = (
|
||||
input: Endpoint10_2Input,
|
||||
) => Effect.Effect<Endpoint10_2Output, E>
|
||||
|
||||
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[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<ReturnType<RawClient["server.integration"]["integration.connect.oauth"]>>
|
||||
export type IntegrationConnectOauthOperation<E = never> = (
|
||||
export type Endpoint10_3Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
||||
export type IntegrationConnectKeyOperation<E = never> = (
|
||||
input: Endpoint10_3Input,
|
||||
) => Effect.Effect<Endpoint10_3Output, E>
|
||||
|
||||
type Endpoint10_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||
type Endpoint10_4Request = Parameters<RawClient["server.integration"]["integration.oauth.connect"]>[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<ReturnType<RawClient["server.integration"]["integration.attempt.status"]>>
|
||||
export type IntegrationAttemptStatusOperation<E = never> = (
|
||||
export type Endpoint10_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.connect"]>>
|
||||
export type IntegrationOauthConnectOperation<E = never> = (
|
||||
input: Endpoint10_4Input,
|
||||
) => Effect.Effect<Endpoint10_4Output, E>
|
||||
|
||||
type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||
type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.oauth.status"]>[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<RawClient["server.integration"]["integration.attempt.complete"]>
|
||||
>
|
||||
export type IntegrationAttemptCompleteOperation<E = never> = (
|
||||
export type Endpoint10_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.status"]>>
|
||||
export type IntegrationOauthStatusOperation<E = never> = (
|
||||
input: Endpoint10_5Input,
|
||||
) => Effect.Effect<Endpoint10_5Output, E>
|
||||
|
||||
type Endpoint10_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||
type Endpoint10_6Request = Parameters<RawClient["server.integration"]["integration.oauth.complete"]>[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<ReturnType<RawClient["server.integration"]["integration.attempt.cancel"]>>
|
||||
export type IntegrationAttemptCancelOperation<E = never> = (
|
||||
export type Endpoint10_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.complete"]>>
|
||||
export type IntegrationOauthCompleteOperation<E = never> = (
|
||||
input: Endpoint10_6Input,
|
||||
) => Effect.Effect<Endpoint10_6Output, E>
|
||||
|
||||
type Endpoint10_7Request = Parameters<RawClient["server.integration"]["integration.oauth.cancel"]>[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<ReturnType<RawClient["server.integration"]["integration.oauth.cancel"]>>
|
||||
export type IntegrationOauthCancelOperation<E = never> = (
|
||||
input: Endpoint10_7Input,
|
||||
) => Effect.Effect<Endpoint10_7Output, E>
|
||||
|
||||
type Endpoint10_8Request = Parameters<RawClient["server.integration"]["integration.command.connect"]>[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<ReturnType<RawClient["server.integration"]["integration.command.connect"]>>
|
||||
export type IntegrationCommandConnectOperation<E = never> = (
|
||||
input: Endpoint10_8Input,
|
||||
) => Effect.Effect<Endpoint10_8Output, E>
|
||||
|
||||
type Endpoint10_9Request = Parameters<RawClient["server.integration"]["integration.command.status"]>[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<ReturnType<RawClient["server.integration"]["integration.command.status"]>>
|
||||
export type IntegrationCommandStatusOperation<E = never> = (
|
||||
input: Endpoint10_9Input,
|
||||
) => Effect.Effect<Endpoint10_9Output, E>
|
||||
|
||||
type Endpoint10_10Request = Parameters<RawClient["server.integration"]["integration.command.cancel"]>[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<ReturnType<RawClient["server.integration"]["integration.command.cancel"]>>
|
||||
export type IntegrationCommandCancelOperation<E = never> = (
|
||||
input: Endpoint10_10Input,
|
||||
) => Effect.Effect<Endpoint10_10Output, E>
|
||||
|
||||
export interface IntegrationApi<E = never> {
|
||||
readonly list: IntegrationListOperation<E>
|
||||
readonly get: IntegrationGetOperation<E>
|
||||
readonly connect: {
|
||||
readonly key: IntegrationConnectKeyOperation<E>
|
||||
readonly oauth: IntegrationConnectOauthOperation<E>
|
||||
readonly wellknown: { readonly add: IntegrationWellknownAddOperation<E> }
|
||||
readonly connect: { readonly key: IntegrationConnectKeyOperation<E> }
|
||||
readonly oauth: {
|
||||
readonly connect: IntegrationOauthConnectOperation<E>
|
||||
readonly status: IntegrationOauthStatusOperation<E>
|
||||
readonly complete: IntegrationOauthCompleteOperation<E>
|
||||
readonly cancel: IntegrationOauthCancelOperation<E>
|
||||
}
|
||||
readonly attempt: {
|
||||
readonly status: IntegrationAttemptStatusOperation<E>
|
||||
readonly complete: IntegrationAttemptCompleteOperation<E>
|
||||
readonly cancel: IntegrationAttemptCancelOperation<E>
|
||||
readonly command: {
|
||||
readonly connect: IntegrationCommandConnectOperation<E>
|
||||
readonly status: IntegrationCommandStatusOperation<E>
|
||||
readonly cancel: IntegrationCommandCancelOperation<E>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||
type Endpoint10_2Request = Parameters<RawClient["server.integration"]["integration.wellknown.add"]>[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<RawClient["server.integration"]["integration.connect.key"]>[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<RawClient["server.integration"]["integration.connect.oauth"]>[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<RawClient["server.integration"]["integration.oauth.connect"]>[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<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||
type Endpoint10_4Input = {
|
||||
readonly attemptID: Endpoint10_4Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint10_4Request["query"]["location"]
|
||||
type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.oauth.status"]>[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<RawClient["server.integration"]["integration.attempt.complete"]>[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<RawClient["server.integration"]["integration.oauth.complete"]>[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<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||
type Endpoint10_6Input = {
|
||||
readonly attemptID: Endpoint10_6Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint10_6Request["query"]["location"]
|
||||
type Endpoint10_7Request = Parameters<RawClient["server.integration"]["integration.oauth.cancel"]>[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<RawClient["server.integration"]["integration.command.connect"]>[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<RawClient["server.integration"]["integration.command.status"]>[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<RawClient["server.integration"]["integration.command.cancel"]>[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<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||
|
|
|
|||
|
|
@ -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<IntegrationWellknownAddOutput>(
|
||||
{
|
||||
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<IntegrationConnectKeyOutput>(
|
||||
|
|
@ -901,8 +924,10 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
),
|
||||
oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationConnectOauthOutput>(
|
||||
},
|
||||
oauth: {
|
||||
connect: (input: IntegrationOauthConnectInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationOauthConnectOutput>(
|
||||
{
|
||||
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<IntegrationAttemptStatusOutput>(
|
||||
status: (input: IntegrationOauthStatusInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationOauthStatusOutput>(
|
||||
{
|
||||
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<IntegrationAttemptCompleteOutput>(
|
||||
complete: (input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationOauthCompleteOutput>(
|
||||
{
|
||||
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<IntegrationAttemptCancelOutput>(
|
||||
cancel: (input: IntegrationOauthCancelInput, requestOptions?: RequestOptions) =>
|
||||
request<IntegrationOauthCancelOutput>(
|
||||
{
|
||||
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<IntegrationCommandConnectOutput>(
|
||||
{
|
||||
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<IntegrationCommandStatusOutput>(
|
||||
{
|
||||
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<IntegrationCommandCancelOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [401, 400],
|
||||
|
|
|
|||
|
|
@ -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<string> }
|
||||
|
||||
export type IntegrationKeyMethod = { type: "key"; label?: string }
|
||||
|
||||
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
|
||||
|
|
@ -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<FormField>]
|
||||
|
||||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -34,30 +34,51 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
|||
value instanceof ErrorConstructorReference ||
|
||||
isCodeModeValue(value)
|
||||
|
||||
export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): 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<unknown> {
|
||||
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<Iterator<unknown>> = [[value].values()]
|
||||
const seen = new Set<object>()
|
||||
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<object>()): 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<Iterator<unknown>> = [[value].values()]
|
||||
const seen = new Set<object>()
|
||||
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<object>(),
|
||||
): 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<Iterator<unknown>> = [[value].values()]
|
||||
const seen = new Set<object>()
|
||||
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 => {
|
||||
|
|
|
|||
|
|
@ -777,9 +777,9 @@ export class Interpreter<R> {
|
|||
}
|
||||
|
||||
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<R> {
|
|||
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<unknown>, 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<R> {
|
|||
}
|
||||
|
||||
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<unknown>
|
||||
const consumed = new Set<string>()
|
||||
for (const propertyValue of getArray(pattern, "properties")) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
|
|
@ -878,36 +875,29 @@ export class Interpreter<R> {
|
|||
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<R> {
|
|||
})
|
||||
}
|
||||
|
||||
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
|
||||
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<unknown>, 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<string, unknown> & Array<unknown>)[key]
|
||||
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
switch (node.type) {
|
||||
case "Literal": {
|
||||
|
|
@ -1279,6 +1289,7 @@ export class Interpreter<R> {
|
|||
private evaluateUnaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
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<R> {
|
|||
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<R> {
|
|||
|
||||
private getMemberReference(
|
||||
node: AstNode,
|
||||
operation: "read" | "delete" = "read",
|
||||
): Effect.Effect<
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
|
|
@ -1875,6 +1888,7 @@ export class Interpreter<R> {
|
|||
}
|
||||
|
||||
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<R> {
|
|||
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
|
||||
}
|
||||
|
||||
private evaluateDeleteExpression(argument: AstNode): Effect.Effect<boolean, unknown, R> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<unknown>, 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)) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
112
packages/core/script/wellknown-server.ts
Normal file
112
packages/core/script/wellknown-server.ts
Normal file
|
|
@ -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}`)
|
||||
|
|
@ -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<Info>("Config.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
|
|
@ -106,6 +111,7 @@ export class Info extends Schema.Class<Info>("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<Document>("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],
|
||||
})
|
||||
|
|
|
|||
14
packages/core/src/config/experimental.ts
Normal file
14
packages/core/src/config/experimental.ts
Normal file
|
|
@ -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<Info>("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",
|
||||
}),
|
||||
}) {}
|
||||
35
packages/core/src/config/plugin/policy.ts
Normal file
35
packages/core/src/config/plugin/policy.ts
Normal file
|
|
@ -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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
13
packages/core/src/config/policy.ts
Normal file
13
packages/core/src/config/policy.ts
Normal file
|
|
@ -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
|
||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -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[]
|
||||
|
|
|
|||
18
packages/core/src/database/migration/20260716020354_kv.ts
Normal file
18
packages/core/src/database/migration/20260716020354_kv.ts
Normal file
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<CodeRequiredError>()("Integration.CodeRequired", {
|
||||
attemptID: AttemptID,
|
||||
}) {}
|
||||
|
|
@ -159,17 +177,6 @@ export interface Interface extends State.Transformable<Draft> {
|
|||
/** User-facing label for the stored credential. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<void, AuthorizationError>
|
||||
/** 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<Attempt, AuthorizationError>
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
|
|
@ -178,18 +185,39 @@ export interface Interface extends State.Transformable<Draft> {
|
|||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
}
|
||||
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<Attempt, AuthorizationError>
|
||||
/** Returns the current state of an OAuth attempt. */
|
||||
readonly status: (attemptID: AttemptID) => Effect.Effect<AttemptStatus>
|
||||
readonly status: (input: {
|
||||
readonly integrationID: ID
|
||||
readonly attemptID: AttemptID
|
||||
}) => Effect.Effect<AttemptStatus>
|
||||
/** 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<void, CodeRequiredError | AuthorizationError>
|
||||
/** Cancels an attempt and releases its resources. */
|
||||
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
|
||||
readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
|
||||
}
|
||||
readonly command: {
|
||||
readonly connect: (input: {
|
||||
readonly integrationID: ID
|
||||
readonly methodID: MethodID
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<CommandAttempt, AuthorizationError>
|
||||
readonly status: (input: {
|
||||
readonly integrationID: ID
|
||||
readonly attemptID: AttemptID
|
||||
}) => Effect.Effect<CommandAttemptStatus>
|
||||
readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<AttemptID, AttemptEntry>())
|
||||
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "integration",
|
||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||
|
|
@ -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<Method>)
|
||||
else current.methods[index] = implementation.method as Types.DeepMutable<Method>
|
||||
|
|
@ -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<string, unknown>) {
|
||||
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],
|
||||
})
|
||||
|
|
|
|||
47
packages/core/src/kv.ts
Normal file
47
packages/core/src/kv.ts
Normal file
|
|
@ -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<Value | undefined>
|
||||
readonly set: (key: string, value: Value) => Effect.Effect<void>
|
||||
readonly remove: (key: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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] })
|
||||
9
packages/core/src/kv/sql.ts
Normal file
9
packages/core/src/kv/sql.ts
Normal file
|
|
@ -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<KV.Value>().notNull(),
|
||||
...Timestamps,
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Instructions.Instructions>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpGuidance") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<Summary> | Instructions.Removed) =>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Effect.Success<ReturnType<typeof serv
|
|||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
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* () {
|
||||
|
|
|
|||
|
|
@ -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) })),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Op
|
|||
if (!entrypoint) return
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
operation.mtime === undefined
|
||||
? entrypoint
|
||||
: `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
||||
operation.mtime === undefined ? entrypoint : `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => 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,
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
47
packages/core/src/plugin/system-prompt.ts
Normal file
47
packages/core/src/plugin/system-prompt.ts
Normal file
|
|
@ -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 }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
76
packages/core/src/plugin/system-prompt/meta.txt
Normal file
76
packages/core/src/plugin/system-prompt/meta.txt
Normal file
|
|
@ -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:
|
||||
<example>
|
||||
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...
|
||||
..
|
||||
..
|
||||
</example>
|
||||
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
|
||||
<example>
|
||||
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]
|
||||
</example>
|
||||
# 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 <system-reminder> tags. <system-reminder> 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.
|
||||
<example>
|
||||
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]
|
||||
</example>
|
||||
<example>
|
||||
user: What is the codebase structure?
|
||||
assistant: [Uses the subagent tool]
|
||||
</example>
|
||||
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.
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
|
|
@ -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<Instructions.Instructions>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ReferenceGuidance") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@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) => ({
|
||||
|
|
@ -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<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
|
||||
/** Generates text from current Session context without admitting input or mutating history. */
|
||||
readonly generate: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: string
|
||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||
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<Image.Interface>,
|
||||
) {
|
||||
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<Image.Interface>,
|
||||
) {
|
||||
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<Image.Interface>,
|
||||
) {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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<previous-summary>\n${input.previousSummary}\n</previous-summary>`
|
||||
: "Create a new anchored summary from the conversation history.",
|
||||
SUMMARY_TEMPLATE,
|
||||
"The following is the conversation history:",
|
||||
...input.context,
|
||||
].join("\n\n")
|
||||
|
||||
|
|
|
|||
124
packages/core/src/session/context.ts
Normal file
124
packages/core/src/session/context.ts
Normal file
|
|
@ -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<SessionMessage.Info>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Selection, AgentNotFoundError>
|
||||
/** Resolves the model and active history for that selection. */
|
||||
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
|
||||
}
|
||||
|
||||
/** Location-scoped model-context loader for durable Session Steps. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@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,
|
||||
],
|
||||
})
|
||||
69
packages/core/src/session/generate-node.ts
Normal file
69
packages/core/src/session/generate-node.ts
Normal file
|
|
@ -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],
|
||||
})
|
||||
21
packages/core/src/session/generate.ts
Normal file
21
packages/core/src/session/generate.ts
Normal file
|
|
@ -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<string, Error>
|
||||
}
|
||||
|
||||
/** Location-scoped transient generation from Session context. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionGenerate") {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Observation, Instructions.InitializationBlocked> {
|
||||
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<Instructions.Hash, Schema.Json>(
|
||||
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<Record<string, Schema.Json>>) {
|
||||
|
|
@ -338,3 +414,13 @@ function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
|||
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
function foldedState(sessionID: SessionSchema.ID, folded: NonNullable<ReturnType<typeof fold>>) {
|
||||
return {
|
||||
session_id: sessionID,
|
||||
epoch_start: folded.epochStart,
|
||||
through_seq: folded.throughSeq,
|
||||
initial_values: folded.initial,
|
||||
current_values: folded.current,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
packages/core/src/session/model-request.ts
Normal file
119
packages/core/src/session/model-request.ts
Normal file
|
|
@ -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<Prepared>
|
||||
}
|
||||
|
||||
/** Location-scoped outbound model-request preparation. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@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],
|
||||
})
|
||||
|
|
@ -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<void, ToolOutputStore.Error>()
|
||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
|
||||
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 = <A, E, R>(effect: Effect.Effect<A, E, R>) => 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,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
<examples>
|
||||
"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."
|
||||
</examples>
|
||||
|
||||
- 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.
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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<Instructions.Instructions>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillGuidance") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@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)
|
||||
|
|
@ -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<DraftApi> = (draft: DraftApi) => Effect.Effect<void> | void
|
||||
type TransformCallback<DraftApi> = (draft: DraftApi) => void
|
||||
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi
|
||||
|
||||
export interface Registration {
|
||||
|
|
@ -34,6 +34,7 @@ type Batch = {
|
|||
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
const reloadDebounce = 500
|
||||
|
||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -73,6 +74,10 @@ export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
|||
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
|
||||
let state = options.initial()
|
||||
let transforms: { run: TransformCallback<DraftApi> }[] = []
|
||||
let generation = 0
|
||||
let requestedAt = 0
|
||||
let running = false
|
||||
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
|
||||
const semaphore = Semaphore.makeUnsafe(1)
|
||||
|
||||
const commit = Effect.fn("State.commit")(function* (next: State) {
|
||||
|
|
@ -82,9 +87,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||
})
|
||||
|
||||
const apply = (transform: TransformCallback<DraftApi>, 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<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||
yield* commit(next)
|
||||
})
|
||||
|
||||
const reload = () => semaphore.withPermit(materialize())
|
||||
const materializeReload = () => semaphore.withPermit(materialize())
|
||||
|
||||
const rebuild = (): Effect.Effect<void> =>
|
||||
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<void>()
|
||||
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<State, DraftApi> = {
|
||||
get: () => state,
|
||||
|
|
@ -117,7 +153,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): 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<State, DraftApi>(options: Options<State, DraftApi>): 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 }
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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<NormalizedItem> => {
|
||||
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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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<Record<string, boolean>>) {
|
||||
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
|
||||
tools ?? {},
|
||||
|
|
|
|||
194
packages/core/src/wellknown.ts
Normal file
194
packages/core/src/wellknown.ts
Normal file
|
|
@ -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<typeof Auth> {}
|
||||
export const Auth = Schema.Struct({
|
||||
command: Schema.Array(Schema.String),
|
||||
env: Schema.String,
|
||||
}).annotate({ identifier: "WellKnown.Auth" })
|
||||
|
||||
export interface RemoteConfig extends Schema.Schema.Type<typeof RemoteConfig> {}
|
||||
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<typeof Config> {}
|
||||
export const Config = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: "WellKnown.Config" })
|
||||
|
||||
export interface Manifest extends Schema.Schema.Type<typeof Manifest> {}
|
||||
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<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly origin: string
|
||||
readonly integrationID: Integration.ID
|
||||
readonly manifest: Manifest
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly entries: () => Effect.Effect<readonly Entry[], Error>
|
||||
readonly snapshot: () => readonly Entry[]
|
||||
readonly refresh: () => Effect.Effect<boolean, Error>
|
||||
readonly add: (origin: string) => Effect.Effect<Entry, Error>
|
||||
readonly remove: (origin: string) => Effect.Effect<void>
|
||||
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<Record<string, string>>) {
|
||||
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<string, Entry>())
|
||||
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] })
|
||||
36
packages/core/src/wellknown/plugin.ts
Normal file
36
packages/core/src/wellknown/plugin.ts
Normal file
|
|
@ -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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<Watcher.Service>,
|
||||
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)))
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
95
packages/core/test/config/policy.test.ts
Normal file
95
packages/core/test/config/policy.test.ts
Normal file
|
|
@ -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<boolean>) {
|
||||
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")
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
425
packages/core/test/instruction-state.test.ts
Normal file
425
packages/core/test/instruction-state.test.ts
Normal file
|
|
@ -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<string | Instructions.Unavailable | Instructions.Removed>) =>
|
||||
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: {},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -29,6 +29,20 @@ const failingIt = testEffect(
|
|||
AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
|
||||
)
|
||||
|
||||
function eventually<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<A, E | Error, R> {
|
||||
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,
|
||||
})
|
||||
|
|
|
|||
25
packages/core/test/kv.test.ts
Normal file
25
packages/core/test/kv.test.ts
Normal file
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
7
packages/core/test/lib/image.ts
Normal file
7
packages/core/test/lib/image.ts
Normal file
|
|
@ -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),
|
||||
})
|
||||
|
|
@ -44,7 +44,7 @@ export const registerToolPlugin = <R>(plugin: {
|
|||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const context = host({
|
||||
ai: {
|
||||
session: {
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
},
|
||||
tool: {
|
||||
|
|
|
|||
150
packages/core/test/mcp-instructions.test.ts
Normal file
150
packages/core/test/mcp-instructions.test.ts
Normal file
|
|
@ -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(
|
||||
[
|
||||
"<mcp_instructions>",
|
||||
' <server name="alpha">',
|
||||
' Use tools from this server through `execute` under `tools["alpha"]`.',
|
||||
" Alpha line one",
|
||||
" Alpha line two",
|
||||
" </server>",
|
||||
' <server name="beta">',
|
||||
' Use tools from this server through `execute` under `tools["beta"]`.',
|
||||
" Beta instructions",
|
||||
" </server>",
|
||||
"</mcp_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:",
|
||||
' <server name="beta">',
|
||||
' Use tools from this server through `execute` under `tools["beta"]`.',
|
||||
" Beta instructions",
|
||||
" </server>",
|
||||
].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.",
|
||||
"<mcp_instructions>",
|
||||
' <server name="alpha">',
|
||||
' Use tools from this server through `execute` under `tools["alpha"]`.',
|
||||
" Updated alpha",
|
||||
" </server>",
|
||||
' <server name="beta">',
|
||||
' Use tools from this server through `execute` under `tools["beta"]`.',
|
||||
" Beta instructions",
|
||||
" </server>",
|
||||
"</mcp_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,
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue