mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9de001d92f | |||
| 3cd894d40a | |||
| 0e705e2a05 | |||
| 421d649019 | |||
| a7855aa169 |
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
"@opencode-ai/plugin": minor
|
|
||||||
"@opencode-ai/sdk": minor
|
|
||||||
"@opencode-ai/client": minor
|
|
||||||
"@opencode-ai/protocol": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
|
|
||||||
@@ -61,7 +61,6 @@
|
|||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -125,7 +124,7 @@
|
|||||||
"opencode2": "./bin/opencode2.cjs",
|
"opencode2": "./bin/opencode2.cjs",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "1.2.1",
|
"@agentclientprotocol/sdk": "0.21.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -351,7 +350,7 @@
|
|||||||
"@ai-sdk/google": "3.0.73",
|
"@ai-sdk/google": "3.0.73",
|
||||||
"@ai-sdk/google-vertex": "4.0.128",
|
"@ai-sdk/google-vertex": "4.0.128",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.51",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.84",
|
"@ai-sdk/openai": "3.0.84",
|
||||||
"@ai-sdk/openai-compatible": "2.0.41",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
@@ -363,7 +362,7 @@
|
|||||||
"@aws-sdk/credential-providers": "3.1057.0",
|
"@aws-sdk/credential-providers": "3.1057.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@effect/sql-sqlite-bun": "catalog:",
|
"@effect/sql-sqlite-bun": "catalog:",
|
||||||
"@ff-labs/fff-bun": "0.10.1",
|
"@ff-labs/fff-bun": "0.9.4",
|
||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/ai": "workspace:*",
|
"@opencode-ai/ai": "workspace:*",
|
||||||
@@ -471,9 +470,7 @@
|
|||||||
"packages/docs": {
|
"packages/docs": {
|
||||||
"name": "@opencode-ai/docs",
|
"name": "@opencode-ai/docs",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"effect": "catalog:",
|
|
||||||
"mint": "4.2.666",
|
"mint": "4.2.666",
|
||||||
"prettier": "3.6.2",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
@@ -709,7 +706,6 @@
|
|||||||
"version": "1.18.4",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -1083,7 +1079,6 @@
|
|||||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
@@ -1178,7 +1173,7 @@
|
|||||||
|
|
||||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||||
|
|
||||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="],
|
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
|
||||||
|
|
||||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||||
|
|
||||||
@@ -1210,7 +1205,7 @@
|
|||||||
|
|
||||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||||
|
|
||||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
|
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
||||||
|
|
||||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
||||||
|
|
||||||
@@ -1646,25 +1641,23 @@
|
|||||||
|
|
||||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bin-android-arm64": ["@ff-labs/fff-bin-android-arm64@0.10.1", "", { "os": "android", "cpu": "arm64" }, "sha512-6Bsaa6yKEd2HV1M2WtqSYhoZucKYffIUms6GYoPN48QHP8hZgO8GXJ85/JDxKlkCJsN2hw1ROiwQK0+xUHYhFQ=="],
|
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7yUP+56sG3UTrLg7eepOD16yM2dgiD7g6Ase2XWQB7oXwLV7mBMylPKIli2pP3kHzfw9K+BS3WAwHKHJ2QhxYw=="],
|
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-9zb+P1xtyqu/jVklm5RKF2zm9doRRsBNbkF/a8S4aSqSJoNlQR8ZF7C129fzOLfffVAjjgcO2l8oJgxOzHYiwQ=="],
|
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-KTwr9CUfCJv0vtG2xG+nMxDae/2NJY2/oVmtgSvTnH9baI6JprqsFGphbukx7mdW/QMPwBiYtoO8ZGoC7i92jA=="],
|
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-oznmSpV+zjiAPiwqbA4y+SAsMaYvDhGllHiT9L4ebdQnSOfcQzlUwvo45OhBPwHBt47tIois4bkF/MITlDk54A=="],
|
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KDpl8lwSEOauP/6FSJIvnARzE+ILm2rVIRQAio9dc5nn56EvlsryBWry0c5V0Tbw1PV0lNrZ+bzcIiPuTacvzw=="],
|
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-mZCpojVtGNDr/wdCUEAHdfrl6qORvKHv3Pw35TaOdshnG4wocoA5bBKTGj/zmAU11o0GMK4V+wUhdNrgqoE10w=="],
|
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-SbT76ETXC5AgV9J8sVDozKG8wzrrxsOn8lbBprlOtx+O5V5EYmS1W7BlsatTHy6ddQ3uU5oOYO04PWZBuP6wXg=="],
|
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dcpHUCBEZoXKQCdKa3bADfgcNyeyfM8tatXrILqIFYi9GL8E4GnzKx1rGkgP5ukVtuj0WuoJyyqSvGjpJPIT8w=="],
|
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="],
|
||||||
|
|
||||||
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="],
|
|
||||||
|
|
||||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||||
|
|
||||||
@@ -6156,9 +6149,7 @@
|
|||||||
|
|
||||||
"@ai-sdk/groq/@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/groq/@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/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
"@ai-sdk/mistral/@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/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
|
||||||
|
|
||||||
"@ai-sdk/openai/@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/openai/@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=="],
|
||||||
|
|
||||||
@@ -6588,8 +6579,6 @@
|
|||||||
|
|
||||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||||
|
|
||||||
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
|
|
||||||
|
|
||||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
|
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
|
||||||
@@ -6608,8 +6597,6 @@
|
|||||||
|
|
||||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||||
|
|
||||||
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
|
|
||||||
|
|
||||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||||
|
|
||||||
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
|
"@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
|
||||||
@@ -6938,8 +6925,6 @@
|
|||||||
|
|
||||||
"aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
|
"aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||||
|
|
||||||
"ansi-align/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=="],
|
"ansi-align/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=="],
|
||||||
@@ -8060,8 +8045,6 @@
|
|||||||
|
|
||||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/mistral/@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-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
"x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
|
||||||
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
"aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
|
||||||
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
"aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
|
||||||
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
"x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -155,12 +155,12 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
|
|||||||
@@ -78,10 +78,7 @@ const streamText = LLM.stream(request).pipe(
|
|||||||
Stream.tap((event) =>
|
Stream.tap((event) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
|
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
|
||||||
if (event.type === "finish")
|
if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`)
|
||||||
process.stdout.write(
|
|
||||||
`\nfinish: ${event.reason.normalized}${event.reason.raw ? ` (${event.reason.raw})` : ""}\n`,
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Stream.runDrain,
|
Stream.runDrain,
|
||||||
@@ -197,7 +194,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
|||||||
event: Schema.String,
|
event: Schema.String,
|
||||||
initial: () => undefined,
|
initial: () => undefined,
|
||||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
||||||
onHalt: () => [{ type: "finish", reason: { normalized: "stop" } }],
|
onHalt: () => [{ type: "finish", reason: "stop" }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
|
|||||||
|
|
||||||
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
|
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
|
||||||
|
|
||||||
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice">
|
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice" | "responseFormat">
|
||||||
|
|
||||||
export class GenerateObjectResponse<T> {
|
export class GenerateObjectResponse<T> {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ const AnthropicTool = Schema.Struct({
|
|||||||
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
||||||
|
|
||||||
const AnthropicToolChoice = Schema.Union([
|
const AnthropicToolChoice = Schema.Union([
|
||||||
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
|
Schema.Struct({ type: Schema.Literals(["auto", "any"]) }),
|
||||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
|
|||||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||||
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
|
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
|
||||||
auto: () => ({ type: "auto" as const }),
|
auto: () => ({ type: "auto" as const }),
|
||||||
none: () => ({ type: "none" as const }),
|
none: () => undefined,
|
||||||
required: () => ({ type: "any" as const }),
|
required: () => ({ type: "any" as const }),
|
||||||
tool: (name) => ({ type: "tool" as const, name }),
|
tool: (name) => ({ type: "tool" as const, name }),
|
||||||
})
|
})
|
||||||
@@ -330,10 +330,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
|||||||
const wireType = serverToolResultType(part.name)
|
const wireType = serverToolResultType(part.name)
|
||||||
if (!wireType)
|
if (!wireType)
|
||||||
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
||||||
// Prefer the provider-owned replay payload; fall back to the result value for
|
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
||||||
// histories constructed directly from provider events.
|
|
||||||
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
|
||||||
return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||||
@@ -545,6 +542,7 @@ const outputConfig = (request: LLMRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||||
|
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||||
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
||||||
@@ -553,7 +551,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||||
const tools =
|
const tools =
|
||||||
request.tools.length === 0
|
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||||
? undefined
|
? undefined
|
||||||
: request.tools.map((tool) =>
|
: request.tools.map((tool) =>
|
||||||
lowerTool(
|
lowerTool(
|
||||||
@@ -562,9 +560,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
|
|
||||||
const toolChoice =
|
|
||||||
tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
|
||||||
const system =
|
const system =
|
||||||
request.system.length === 0
|
request.system.length === 0
|
||||||
? undefined
|
? undefined
|
||||||
@@ -601,7 +596,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||||
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
|
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
|
||||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
if (reason === "max_tokens") return "length"
|
||||||
if (reason === "tool_use") return "tool-calls"
|
if (reason === "tool_use") return "tool-calls"
|
||||||
if (reason === "refusal") return "content-filter"
|
if (reason === "refusal") return "content-filter"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -685,9 +680,7 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
|
|||||||
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
||||||
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
// The complete payload is irreducible provider replay state: subsequent
|
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
||||||
// stateless requests must round-trip the typed result block verbatim.
|
|
||||||
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,10 +829,7 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
|||||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||||
reason: {
|
reason: mapFinishReason(event.delta?.stop_reason),
|
||||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
|
||||||
raw: event.delta?.stop_reason ?? undefined,
|
|
||||||
},
|
|
||||||
usage,
|
usage,
|
||||||
providerMetadata: event.delta?.stop_sequence
|
providerMetadata: event.delta?.stop_sequence
|
||||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Usage,
|
Usage,
|
||||||
type CacheHint,
|
type CacheHint,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type FinishReasonDetails,
|
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ModelToolSchemaCompatibility,
|
type ModelToolSchemaCompatibility,
|
||||||
@@ -393,13 +392,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
|||||||
// tools → system → messages order to favour the highest-impact prefixes.
|
// tools → system → messages order to favour the highest-impact prefixes.
|
||||||
const breakpoints = BedrockCache.breakpoints()
|
const breakpoints = BedrockCache.breakpoints()
|
||||||
const toolConfig =
|
const toolConfig =
|
||||||
request.tools.length > 0
|
request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||||
? {
|
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
|
||||||
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
|
|
||||||
// Converse has no native "none". Keep definitions stable for prompt
|
|
||||||
// caching and omit only the unsupported choice.
|
|
||||||
toolChoice,
|
|
||||||
}
|
|
||||||
: undefined
|
: undefined
|
||||||
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||||
const messages = yield* lowerMessages(request, breakpoints)
|
const messages = yield* lowerMessages(request, breakpoints)
|
||||||
@@ -436,10 +430,9 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
const mapFinishReason = (reason: string): FinishReason => {
|
const mapFinishReason = (reason: string): FinishReason => {
|
||||||
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
|
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
|
||||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
if (reason === "max_tokens") return "length"
|
||||||
if (reason === "tool_use") return "tool-calls"
|
if (reason === "tool_use") return "tool-calls"
|
||||||
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter"
|
||||||
if (reason === "malformed_model_output" || reason === "malformed_tool_use") return "error"
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +461,7 @@ interface ParserState {
|
|||||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||||
// can emit exactly one finish after both chunks have had a chance to arrive.
|
// can emit exactly one finish after both chunks have had a chance to arrive.
|
||||||
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
|
readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
|
||||||
readonly hasToolCalls: boolean
|
readonly hasToolCalls: boolean
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||||
@@ -585,13 +578,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
pendingFinish: {
|
pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage },
|
||||||
reason: {
|
|
||||||
normalized: mapFinishReason(event.messageStop.stopReason),
|
|
||||||
raw: event.messageStop.stopReason,
|
|
||||||
},
|
|
||||||
usage: state.pendingFinish?.usage,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
] as const
|
] as const
|
||||||
@@ -599,16 +586,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
|
|
||||||
if (event.metadata) {
|
if (event.metadata) {
|
||||||
const usage = mapUsage(event.metadata.usage)
|
const usage = mapUsage(event.metadata.usage)
|
||||||
return [
|
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
||||||
{
|
|
||||||
...state,
|
|
||||||
pendingFinish: {
|
|
||||||
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
|
|
||||||
usage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
] as const
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const exception = (
|
const exception = (
|
||||||
@@ -641,13 +619,8 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
|||||||
? (() => {
|
? (() => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
Lifecycle.finish(state.lifecycle, events, {
|
Lifecycle.finish(state.lifecycle, events, {
|
||||||
reason: {
|
reason:
|
||||||
...state.pendingFinish.reason,
|
state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
|
||||||
normalized:
|
|
||||||
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
|
|
||||||
? "tool-calls"
|
|
||||||
: state.pendingFinish.reason.normalized,
|
|
||||||
},
|
|
||||||
usage: state.pendingFinish.usage,
|
usage: state.pendingFinish.usage,
|
||||||
})
|
})
|
||||||
return events
|
return events
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ const thinkingConfig = (request: LLMRequest) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||||
const hasTools = request.tools.length > 0
|
const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||||
const generationConfig = {
|
const generationConfig = {
|
||||||
@@ -329,7 +329,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
|||||||
contents: yield* lowerMessages(request),
|
contents: yield* lowerMessages(request),
|
||||||
systemInstruction:
|
systemInstruction:
|
||||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||||
tools: hasTools
|
tools: toolsEnabled
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
functionDeclarations: request.tools.map((tool) =>
|
functionDeclarations: request.tools.map((tool) =>
|
||||||
@@ -338,7 +338,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: undefined,
|
: undefined,
|
||||||
toolConfig: hasTools && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
|
toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
|
||||||
generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
|
generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
|
||||||
? generationConfig
|
? generationConfig
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -382,22 +382,10 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
|||||||
finishReason === "SAFETY" ||
|
finishReason === "SAFETY" ||
|
||||||
finishReason === "BLOCKLIST" ||
|
finishReason === "BLOCKLIST" ||
|
||||||
finishReason === "PROHIBITED_CONTENT" ||
|
finishReason === "PROHIBITED_CONTENT" ||
|
||||||
finishReason === "SPII" ||
|
finishReason === "SPII"
|
||||||
finishReason === "MODEL_ARMOR" ||
|
|
||||||
finishReason === "IMAGE_PROHIBITED_CONTENT" ||
|
|
||||||
finishReason === "IMAGE_RECITATION" ||
|
|
||||||
finishReason === "LANGUAGE"
|
|
||||||
)
|
)
|
||||||
return "content-filter"
|
return "content-filter"
|
||||||
if (
|
if (finishReason === "MALFORMED_FUNCTION_CALL") return "error"
|
||||||
finishReason === "MALFORMED_FUNCTION_CALL" ||
|
|
||||||
finishReason === "UNEXPECTED_TOOL_CALL" ||
|
|
||||||
finishReason === "NO_IMAGE" ||
|
|
||||||
finishReason === "TOO_MANY_TOOL_CALLS" ||
|
|
||||||
finishReason === "MISSING_THOUGHT_SIGNATURE" ||
|
|
||||||
finishReason === "MALFORMED_RESPONSE"
|
|
||||||
)
|
|
||||||
return "error"
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,10 +402,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
|||||||
)
|
)
|
||||||
: state.lifecycle
|
: state.lifecycle
|
||||||
Lifecycle.finish(lifecycle, events, {
|
Lifecycle.finish(lifecycle, events, {
|
||||||
reason: {
|
reason: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||||
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
|
|
||||||
raw: state.finishReason,
|
|
||||||
},
|
|
||||||
usage: state.usage,
|
usage: state.usage,
|
||||||
})
|
})
|
||||||
return events
|
return events
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import { Endpoint } from "../route/endpoint"
|
|||||||
import { HttpTransport } from "../route/transport"
|
import { HttpTransport } from "../route/transport"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
LLMError,
|
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type FinishReasonDetails,
|
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type MediaPart,
|
type MediaPart,
|
||||||
@@ -19,7 +17,6 @@ import {
|
|||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
type ToolContent,
|
type ToolContent,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { classifyProviderFailure } from "../provider-error"
|
|
||||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
import { OpenAIOptions } from "./utils/openai-options"
|
import { OpenAIOptions } from "./utils/openai-options"
|
||||||
import { Lifecycle } from "./utils/lifecycle"
|
import { Lifecycle } from "./utils/lifecycle"
|
||||||
@@ -167,18 +164,11 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
|||||||
const OpenAIChatChoice = Schema.Struct({
|
const OpenAIChatChoice = Schema.Struct({
|
||||||
delta: optionalNull(OpenAIChatDelta),
|
delta: optionalNull(OpenAIChatDelta),
|
||||||
finish_reason: optionalNull(Schema.String),
|
finish_reason: optionalNull(Schema.String),
|
||||||
native_finish_reason: optionalNull(Schema.String),
|
|
||||||
})
|
|
||||||
|
|
||||||
const OpenAIChatError = Schema.Struct({
|
|
||||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
|
||||||
message: Schema.String,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const OpenAIChatEvent = Schema.Struct({
|
export const OpenAIChatEvent = Schema.Struct({
|
||||||
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
|
choices: Schema.Array(OpenAIChatChoice),
|
||||||
usage: optionalNull(OpenAIChatUsage),
|
usage: optionalNull(OpenAIChatUsage),
|
||||||
error: optionalNull(OpenAIChatError),
|
|
||||||
})
|
})
|
||||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||||
@@ -194,7 +184,7 @@ export interface ParserState {
|
|||||||
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly finishReason?: FinishReasonDetails
|
readonly finishReason?: FinishReason
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
readonly reasoningField?: string
|
readonly reasoningField?: string
|
||||||
readonly reasoningDetails: Array<unknown>
|
readonly reasoningDetails: Array<unknown>
|
||||||
@@ -449,7 +439,6 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
|||||||
if (reason === "length") return "length"
|
if (reason === "length") return "length"
|
||||||
if (reason === "content_filter") return "content-filter"
|
if (reason === "content_filter") return "content-filter"
|
||||||
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
|
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
|
||||||
if (reason === "error") return "error"
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,22 +532,10 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
|
|||||||
|
|
||||||
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.error)
|
|
||||||
return yield* new LLMError({
|
|
||||||
module: ADAPTER,
|
|
||||||
method: "stream",
|
|
||||||
reason: classifyProviderFailure({
|
|
||||||
message: event.error.message,
|
|
||||||
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
|
|
||||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const usage = mapUsage(event.usage) ?? state.usage
|
const usage = mapUsage(event.usage) ?? state.usage
|
||||||
const choice = event.choices?.[0]
|
const choice = event.choices[0]
|
||||||
const finishReason = choice?.finish_reason
|
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
|
||||||
? { normalized: mapFinishReason(choice.finish_reason), raw: choice.native_finish_reason ?? choice.finish_reason }
|
|
||||||
: state.finishReason
|
|
||||||
const delta = choice?.delta
|
const delta = choice?.delta
|
||||||
const toolDeltas = delta?.tool_calls ?? []
|
const toolDeltas = delta?.tool_calls ?? []
|
||||||
let tools = state.tools
|
let tools = state.tools
|
||||||
@@ -650,13 +627,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const hasToolCalls = state.toolCallEvents.length > 0
|
const hasToolCalls = state.toolCallEvents.length > 0
|
||||||
const reason = state.finishReason
|
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||||
? {
|
|
||||||
...state.finishReason,
|
|
||||||
normalized:
|
|
||||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
const metadata = reasoningMetadata(
|
const metadata = reasoningMetadata(
|
||||||
state.reasoningField,
|
state.reasoningField,
|
||||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||||
|
|||||||
@@ -979,10 +979,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
|||||||
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||||
reason: {
|
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||||
normalized: mapFinishReason(event, state.hasFunctionCall),
|
|
||||||
raw: event.response?.incomplete_details?.reason,
|
|
||||||
},
|
|
||||||
usage: mapUsage(event.response?.usage),
|
usage: mapUsage(event.response?.usage),
|
||||||
providerMetadata:
|
providerMetadata:
|
||||||
event.response?.id || event.response?.service_tier
|
event.response?.id || event.response?.service_tier
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
|
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
readonly stepStarted: boolean
|
readonly stepStarted: boolean
|
||||||
@@ -81,7 +81,7 @@ export const finish = (
|
|||||||
state: State,
|
state: State,
|
||||||
events: LLMEvent[],
|
events: LLMEvent[],
|
||||||
input: {
|
input: {
|
||||||
readonly reason: FinishReasonDetails
|
readonly reason: FinishReason
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly providerMetadata?: ProviderMetadata
|
readonly providerMetadata?: ProviderMetadata
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -191,16 +191,10 @@ export const ToolError = Schema.Struct({
|
|||||||
}).annotate({ identifier: "LLM.Event.ToolError" })
|
}).annotate({ identifier: "LLM.Event.ToolError" })
|
||||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||||
|
|
||||||
export const FinishReasonDetails = Schema.Struct({
|
|
||||||
normalized: FinishReason,
|
|
||||||
raw: Schema.optional(Schema.String),
|
|
||||||
}).annotate({ identifier: "LLM.FinishReasonDetails" })
|
|
||||||
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
|
|
||||||
|
|
||||||
export const StepFinish = Schema.Struct({
|
export const StepFinish = Schema.Struct({
|
||||||
type: Schema.tag("step-finish"),
|
type: Schema.tag("step-finish"),
|
||||||
index: Schema.Number,
|
index: Schema.Number,
|
||||||
reason: FinishReasonDetails,
|
reason: FinishReason,
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.StepFinish" })
|
}).annotate({ identifier: "LLM.Event.StepFinish" })
|
||||||
@@ -208,7 +202,7 @@ export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
|||||||
|
|
||||||
export const Finish = Schema.Struct({
|
export const Finish = Schema.Struct({
|
||||||
type: Schema.tag("finish"),
|
type: Schema.tag("finish"),
|
||||||
reason: FinishReasonDetails,
|
reason: FinishReason,
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||||
@@ -371,7 +365,7 @@ interface ResponseState {
|
|||||||
readonly events: ReadonlyArray<LLMEvent>
|
readonly events: ReadonlyArray<LLMEvent>
|
||||||
readonly message: Message
|
readonly message: Message
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly finishReason?: FinishReasonDetails
|
readonly finishReason?: FinishReason
|
||||||
readonly textParts: Readonly<Record<string, ContentAssembly>>
|
readonly textParts: Readonly<Record<string, ContentAssembly>>
|
||||||
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
|
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
|
||||||
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
|
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
|
||||||
@@ -399,7 +393,7 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
events,
|
events,
|
||||||
finishReason: state.finishReason ?? { normalized: "error" },
|
finishReason: state.finishReason ?? "error",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -586,7 +580,7 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
|
|||||||
message: Message,
|
message: Message,
|
||||||
events: Schema.Array(LLMEvent),
|
events: Schema.Array(LLMEvent),
|
||||||
usage: Schema.optional(Usage),
|
usage: Schema.optional(Usage),
|
||||||
finishReason: FinishReasonDetails,
|
finishReason: FinishReason,
|
||||||
}) {
|
}) {
|
||||||
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
||||||
get text() {
|
get text() {
|
||||||
|
|||||||
@@ -261,6 +261,13 @@ export namespace ToolChoice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ResponseFormat = Schema.Union([
|
||||||
|
Schema.Struct({ type: Schema.Literal("text") }),
|
||||||
|
Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }),
|
||||||
|
Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }),
|
||||||
|
]).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
export type ResponseFormat = Schema.Schema.Type<typeof ResponseFormat>
|
||||||
|
|
||||||
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||||
id: Schema.optional(Schema.String),
|
id: Schema.optional(Schema.String),
|
||||||
model: ModelSchema,
|
model: ModelSchema,
|
||||||
@@ -271,6 +278,7 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
|||||||
generation: Schema.optional(GenerationOptions),
|
generation: Schema.optional(GenerationOptions),
|
||||||
providerOptions: Schema.optional(ProviderOptions),
|
providerOptions: Schema.optional(ProviderOptions),
|
||||||
http: Schema.optional(HttpOptions),
|
http: Schema.optional(HttpOptions),
|
||||||
|
responseFormat: Schema.optional(ResponseFormat),
|
||||||
cache: Schema.optional(CachePolicy),
|
cache: Schema.optional(CachePolicy),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
}) {}
|
}) {}
|
||||||
@@ -288,6 +296,7 @@ export namespace LLMRequest {
|
|||||||
generation: request.generation,
|
generation: request.generation,
|
||||||
providerOptions: request.providerOptions,
|
providerOptions: request.providerOptions,
|
||||||
http: request.http,
|
http: request.http,
|
||||||
|
responseFormat: request.responseFormat,
|
||||||
cache: request.cache,
|
cache: request.cache,
|
||||||
metadata: request.metadata,
|
metadata: request.metadata,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const fakeFraming: FramingDef<FakeEvent> = {
|
|||||||
|
|
||||||
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||||
event.type === "finish"
|
event.type === "finish"
|
||||||
? { type: "finish", reason: { normalized: event.reason } }
|
? { type: "finish", reason: event.reason }
|
||||||
: { type: "text-delta", id: "text-0", text: event.text }
|
: { type: "text-delta", id: "text-0", text: event.text }
|
||||||
|
|
||||||
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
|
|||||||
const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||||
const assistantContent: ContentPart[] = []
|
const assistantContent: ContentPart[] = []
|
||||||
const toolCalls: ToolCallPart[] = []
|
const toolCalls: ToolCallPart[] = []
|
||||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = { normalized: "unknown" }
|
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = "unknown"
|
||||||
let usage: Usage | undefined
|
let usage: Usage | undefined
|
||||||
let providerMetadata: ProviderMetadata | undefined
|
let providerMetadata: ProviderMetadata | undefined
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ describe("llm constructors", () => {
|
|||||||
LLMResponse.text({
|
LLMResponse.text({
|
||||||
events: [
|
events: [
|
||||||
{ type: "text-delta", id: "text-0", text: "hi" },
|
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||||
{ type: "finish", reason: { normalized: "stop" } },
|
{ type: "finish", reason: "stop" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toBe("hi")
|
).toBe("hi")
|
||||||
|
|||||||
@@ -235,34 +235,6 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("keeps tools and sends tool_choice none", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
|
||||||
LLM.request({
|
|
||||||
id: "req_tool_choice_none",
|
|
||||||
model,
|
|
||||||
tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object", properties: {} } }],
|
|
||||||
messages: [
|
|
||||||
Message.user("What is the weather?"),
|
|
||||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
|
||||||
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
|
|
||||||
],
|
|
||||||
toolChoice: "none",
|
|
||||||
cache: "none",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(prepared.body.tools).toEqual([
|
|
||||||
{
|
|
||||||
name: "lookup",
|
|
||||||
description: "Look things up",
|
|
||||||
input_schema: { type: "object", properties: {} },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(prepared.body.tool_choice).toEqual({ type: "none" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Regression: read tool results must stay structured so base64 media data is
|
// Regression: read tool results must stay structured so base64 media data is
|
||||||
// not JSON-stringified into `tool_result.content`.
|
// not JSON-stringified into `tool_result.content`.
|
||||||
it.effect("lowers media tool-result content as structured blocks", () =>
|
it.effect("lowers media tool-result content as structured blocks", () =>
|
||||||
@@ -448,50 +420,12 @@ describe("Anthropic Messages route", () => {
|
|||||||
])
|
])
|
||||||
expect(response.events.at(-1)).toMatchObject({
|
expect(response.events.at(-1)).toMatchObject({
|
||||||
type: "finish",
|
type: "finish",
|
||||||
reason: { normalized: "stop", raw: "end_turn" },
|
reason: "stop",
|
||||||
providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
|
providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("maps context-window truncation to length", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
fixedResponse(
|
|
||||||
sseEvents(
|
|
||||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
|
||||||
{
|
|
||||||
type: "message_delta",
|
|
||||||
delta: { stop_reason: "model_context_window_exceeded" },
|
|
||||||
usage: { output_tokens: 1 },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.finishReason).toEqual({ normalized: "length", raw: "model_context_window_exceeded" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("preserves pause_turn while normalizing it to stop", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
fixedResponse(
|
|
||||||
sseEvents(
|
|
||||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
|
||||||
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "pause_turn" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("assembles streamed tool call input", () =>
|
it.effect("assembles streamed tool call input", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
@@ -541,16 +475,10 @@ describe("Anthropic Messages route", () => {
|
|||||||
providerExecuted: undefined,
|
providerExecuted: undefined,
|
||||||
providerMetadata: undefined,
|
providerMetadata: undefined,
|
||||||
},
|
},
|
||||||
{
|
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||||
type: "step-finish",
|
|
||||||
index: 0,
|
|
||||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
|
||||||
usage,
|
|
||||||
providerMetadata: undefined,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "finish",
|
type: "finish",
|
||||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
reason: "tool-calls",
|
||||||
providerMetadata: undefined,
|
providerMetadata: undefined,
|
||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
@@ -708,20 +636,10 @@ describe("Anthropic Messages route", () => {
|
|||||||
name: "web_search",
|
name: "web_search",
|
||||||
result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
|
result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
// The complete payload rides in provider metadata as irreducible replay
|
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
|
||||||
// state for later stateless requests.
|
|
||||||
providerMetadata: {
|
|
||||||
anthropic: {
|
|
||||||
blockType: "web_search_tool_result",
|
|
||||||
result: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
expect(response.text).toBe("Found it.")
|
expect(response.text).toBe("Found it.")
|
||||||
expect(response.events.at(-1)).toMatchObject({
|
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||||
type: "finish",
|
|
||||||
reason: { normalized: "stop", raw: "end_turn" },
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -154,36 +154,6 @@ describe("Bedrock Converse route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const prepared = yield* LLMClient.prepare(
|
|
||||||
LLM.updateRequest(baseRequest, {
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
name: "lookup",
|
|
||||||
description: "Lookup data",
|
|
||||||
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
toolChoice: ToolChoice.make({ type: "none" }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(prepared.body.toolConfig).toMatchObject({
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
toolSpec: {
|
|
||||||
name: "lookup",
|
|
||||||
description: "Lookup data",
|
|
||||||
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(prepared.body.toolConfig?.toolChoice).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("lowers assistant tool-call + tool-result message history", () =>
|
it.effect("lowers assistant tool-call + tool-result message history", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare(
|
const prepared = yield* LLMClient.prepare(
|
||||||
@@ -290,10 +260,7 @@ describe("Bedrock Converse route", () => {
|
|||||||
// `metadata` (carries usage). We consolidate them into a single
|
// `metadata` (carries usage). We consolidate them into a single
|
||||||
// terminal `finish` event with both.
|
// terminal `finish` event with both.
|
||||||
expect(finishes).toHaveLength(1)
|
expect(finishes).toHaveLength(1)
|
||||||
expect(finishes[0]).toMatchObject({
|
expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
|
||||||
type: "finish",
|
|
||||||
reason: { normalized: "stop", raw: "end_turn" },
|
|
||||||
})
|
|
||||||
expect(response.usage).toMatchObject({
|
expect(response.usage).toMatchObject({
|
||||||
inputTokens: 5,
|
inputTokens: 5,
|
||||||
outputTokens: 2,
|
outputTokens: 2,
|
||||||
@@ -302,23 +269,6 @@ describe("Bedrock Converse route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("maps truncation and malformed output stop reasons", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const reasons = [
|
|
||||||
["model_context_window_exceeded", "length"],
|
|
||||||
["malformed_model_output", "error"],
|
|
||||||
["malformed_tool_use", "error"],
|
|
||||||
] as const
|
|
||||||
|
|
||||||
for (const [raw, normalized] of reasons) {
|
|
||||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
|
||||||
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
|
|
||||||
)
|
|
||||||
expect(response.finishReason).toEqual({ normalized, raw })
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("adds cache reads and writes to Bedrock input usage", () =>
|
it.effect("adds cache reads and writes to Bedrock input usage", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = eventStreamBody(
|
const body = eventStreamBody(
|
||||||
@@ -382,10 +332,7 @@ describe("Bedrock Converse route", () => {
|
|||||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
|
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
|
||||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
|
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
|
||||||
])
|
])
|
||||||
expect(response.events.at(-1)).toMatchObject({
|
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||||
type: "finish",
|
|
||||||
reason: { normalized: "tool-calls", raw: "tool_use" },
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -411,7 +358,7 @@ describe("Bedrock Converse route", () => {
|
|||||||
name: "lookup",
|
name: "lookup",
|
||||||
raw: '{"query":"partial',
|
raw: '{"query":"partial',
|
||||||
})
|
})
|
||||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
|
expect(response.finishReason).toBe("tool-calls")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -233,11 +233,11 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("keeps tools and sends function calling mode NONE", () =>
|
it.effect("omits tools when tool choice is none", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare(
|
const prepared = yield* LLMClient.prepare(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
id: "req_tool_choice_none",
|
id: "req_no_tools",
|
||||||
model,
|
model,
|
||||||
prompt: "Say hello.",
|
prompt: "Say hello.",
|
||||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||||
@@ -245,10 +245,8 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(prepared.body).toMatchObject({
|
expect(prepared.body).toEqual({
|
||||||
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
|
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
|
||||||
tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }],
|
|
||||||
toolConfig: { functionCallingConfig: { mode: "NONE" } },
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -373,16 +371,10 @@ describe("Gemini route", () => {
|
|||||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||||
{ type: "text-delta", id: "text-0", text: "!" },
|
{ type: "text-delta", id: "text-0", text: "!" },
|
||||||
{ type: "text-end", id: "text-0" },
|
{ type: "text-end", id: "text-0" },
|
||||||
{
|
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||||
type: "step-finish",
|
|
||||||
index: 0,
|
|
||||||
reason: { normalized: "stop", raw: "STOP" },
|
|
||||||
usage,
|
|
||||||
providerMetadata: undefined,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "finish",
|
type: "finish",
|
||||||
reason: { normalized: "stop", raw: "STOP" },
|
reason: "stop",
|
||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -535,16 +527,10 @@ describe("Gemini route", () => {
|
|||||||
providerExecuted: undefined,
|
providerExecuted: undefined,
|
||||||
providerMetadata: undefined,
|
providerMetadata: undefined,
|
||||||
},
|
},
|
||||||
{
|
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||||
type: "step-finish",
|
|
||||||
index: 0,
|
|
||||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
|
||||||
usage,
|
|
||||||
providerMetadata: undefined,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "finish",
|
type: "finish",
|
||||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
reason: "tool-calls",
|
||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -583,10 +569,7 @@ describe("Gemini route", () => {
|
|||||||
},
|
},
|
||||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
||||||
])
|
])
|
||||||
expect(response.events.at(-1)).toMatchObject({
|
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||||
type: "finish",
|
|
||||||
reason: { normalized: "tool-calls", raw: "STOP" },
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -606,41 +589,9 @@ describe("Gemini route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||||
expect(length.events.at(-1)).toMatchObject({
|
expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
|
||||||
type: "finish",
|
|
||||||
reason: { normalized: "length", raw: "MAX_TOKENS" },
|
|
||||||
})
|
|
||||||
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||||
expect(filtered.events.at(-1)).toMatchObject({
|
expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
|
||||||
type: "finish",
|
|
||||||
reason: { normalized: "content-filter", raw: "SAFETY" },
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const reasons = [
|
|
||||||
["MODEL_ARMOR", "content-filter"],
|
|
||||||
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
|
|
||||||
["IMAGE_RECITATION", "content-filter"],
|
|
||||||
["LANGUAGE", "content-filter"],
|
|
||||||
["UNEXPECTED_TOOL_CALL", "error"],
|
|
||||||
["NO_IMAGE", "error"],
|
|
||||||
["IMAGE_OTHER", "unknown"],
|
|
||||||
["TOO_MANY_TOOL_CALLS", "error"],
|
|
||||||
["MISSING_THOUGHT_SIGNATURE", "error"],
|
|
||||||
["MALFORMED_RESPONSE", "error"],
|
|
||||||
] as const
|
|
||||||
|
|
||||||
for (const [raw, normalized] of reasons) {
|
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: raw }] })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
expect(response.finishReason).toEqual({ normalized, raw })
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -569,16 +569,10 @@ describe("OpenAI Chat route", () => {
|
|||||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||||
{ type: "text-delta", id: "text-0", text: "!" },
|
{ type: "text-delta", id: "text-0", text: "!" },
|
||||||
{ type: "text-end", id: "text-0" },
|
{ type: "text-end", id: "text-0" },
|
||||||
{
|
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||||
type: "step-finish",
|
|
||||||
index: 0,
|
|
||||||
reason: { normalized: "stop", raw: "stop" },
|
|
||||||
usage,
|
|
||||||
providerMetadata: undefined,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "finish",
|
type: "finish",
|
||||||
reason: { normalized: "stop", raw: "stop" },
|
reason: "stop",
|
||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -1043,14 +1037,8 @@ describe("OpenAI Chat route", () => {
|
|||||||
providerExecuted: undefined,
|
providerExecuted: undefined,
|
||||||
providerMetadata: undefined,
|
providerMetadata: undefined,
|
||||||
},
|
},
|
||||||
{
|
{ type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
|
||||||
type: "step-finish",
|
{ type: "finish", reason: "tool-calls", usage: undefined },
|
||||||
index: 0,
|
|
||||||
reason: { normalized: "tool-calls", raw: "tool_calls" },
|
|
||||||
usage: undefined,
|
|
||||||
providerMetadata: undefined,
|
|
||||||
},
|
|
||||||
{ type: "finish", reason: { normalized: "tool-calls", raw: "tool_calls" }, usage: undefined },
|
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -232,10 +232,7 @@ describe("OpenAI-compatible Chat route", () => {
|
|||||||
|
|
||||||
expect(response.text).toBe("Hello!")
|
expect(response.text).toBe("Hello!")
|
||||||
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
|
||||||
expect(response.events.at(-1)).toMatchObject({
|
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||||
type: "finish",
|
|
||||||
reason: { normalized: "stop", raw: "stop" },
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -856,13 +856,13 @@ describe("OpenAI Responses route", () => {
|
|||||||
{
|
{
|
||||||
type: "step-finish",
|
type: "step-finish",
|
||||||
index: 0,
|
index: 0,
|
||||||
reason: { normalized: "stop", raw: undefined },
|
reason: "stop",
|
||||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "finish",
|
type: "finish",
|
||||||
reason: { normalized: "stop", raw: undefined },
|
reason: "stop",
|
||||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
@@ -887,18 +887,11 @@ describe("OpenAI Responses route", () => {
|
|||||||
const length = yield* generate({ reason: "max_output_tokens" })
|
const length = yield* generate({ reason: "max_output_tokens" })
|
||||||
const contentFilter = yield* generate({ reason: "content_filter" })
|
const contentFilter = yield* generate({ reason: "content_filter" })
|
||||||
const unknown = yield* generate({})
|
const unknown = yield* generate({})
|
||||||
const custom = yield* generate({ reason: "provider_limit" })
|
|
||||||
|
|
||||||
expect([
|
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
|
||||||
length.finishReason,
|
"length",
|
||||||
contentFilter.finishReason,
|
"content-filter",
|
||||||
unknown.finishReason,
|
"unknown",
|
||||||
custom.finishReason,
|
|
||||||
]).toEqual([
|
|
||||||
{ normalized: "length", raw: "max_output_tokens" },
|
|
||||||
{ normalized: "content-filter", raw: "content_filter" },
|
|
||||||
{ normalized: "unknown", raw: undefined },
|
|
||||||
{ normalized: "unknown", raw: "provider_limit" },
|
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -953,8 +946,8 @@ describe("OpenAI Responses route", () => {
|
|||||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||||
{ type: "reasoning-end", id: "rs_1" },
|
{ type: "reasoning-end", id: "rs_1" },
|
||||||
{ type: "text-end", id: "msg_1" },
|
{ type: "text-end", id: "msg_1" },
|
||||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
{ type: "step-finish", index: 0, reason: "stop" },
|
||||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
{ type: "finish", reason: "stop" },
|
||||||
])
|
])
|
||||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||||
expect(response.message.content).toEqual([
|
expect(response.message.content).toEqual([
|
||||||
@@ -1045,8 +1038,8 @@ describe("OpenAI Responses route", () => {
|
|||||||
id: "rs_1:1",
|
id: "rs_1:1",
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
},
|
},
|
||||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
{ type: "step-finish", index: 0, reason: "stop" },
|
||||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
{ type: "finish", reason: "stop" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1429,16 +1422,10 @@ describe("OpenAI Responses route", () => {
|
|||||||
providerExecuted: undefined,
|
providerExecuted: undefined,
|
||||||
providerMetadata: { openai: { itemId: "item_1" } },
|
providerMetadata: { openai: { itemId: "item_1" } },
|
||||||
},
|
},
|
||||||
{
|
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||||
type: "step-finish",
|
|
||||||
index: 0,
|
|
||||||
reason: { normalized: "tool-calls", raw: undefined },
|
|
||||||
usage,
|
|
||||||
providerMetadata: undefined,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "finish",
|
type: "finish",
|
||||||
reason: { normalized: "tool-calls", raw: undefined },
|
reason: "tool-calls",
|
||||||
providerMetadata: undefined,
|
providerMetadata: undefined,
|
||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
@@ -1478,7 +1465,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
name: "lookup",
|
name: "lookup",
|
||||||
raw: '{"query":"partial',
|
raw: '{"query":"partial',
|
||||||
})
|
})
|
||||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
expect(response.finishReason).toBe("tool-calls")
|
||||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1505,7 +1492,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
name: "lookup",
|
name: "lookup",
|
||||||
raw: '{"query":"partial',
|
raw: '{"query":"partial',
|
||||||
})
|
})
|
||||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
expect(response.finishReason).toBe("tool-calls")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { LLM, Message } from "../../src"
|
|||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import * as OpenRouter from "../../src/providers/openrouter"
|
import * as OpenRouter from "../../src/providers/openrouter"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
import { fixedResponse } from "../lib/http"
|
|
||||||
import { sseEvents } from "../lib/sse"
|
|
||||||
|
|
||||||
describe("OpenRouter", () => {
|
describe("OpenRouter", () => {
|
||||||
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
|
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
|
||||||
@@ -56,42 +54,6 @@ describe("OpenRouter", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves the upstream provider finish reason", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
|
|
||||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
fixedResponse(
|
|
||||||
sseEvents({
|
|
||||||
choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("fails on a mid-stream provider error", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
|
||||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
fixedResponse(
|
|
||||||
sseEvents({
|
|
||||||
error: { code: 502, message: "Provider disconnected" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.flip,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
|
||||||
expect(error.message).toContain("Provider disconnected")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("preserves manually supplied reasoning details", () =>
|
it.effect("preserves manually supplied reasoning details", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const details = [
|
const details = [
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ const readPdfRuntime = Tool.make({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const expectCode = (response: LLMResponse) => {
|
const expectCode = (response: LLMResponse) => {
|
||||||
expect(response.finishReason.normalized).toBe("stop")
|
expect(response.finishReason).toBe("stop")
|
||||||
expect(response.text.toUpperCase()).toContain(CODE)
|
expect(response.text.toUpperCase()).toContain(CODE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ describe("PDF recorded", () => {
|
|||||||
tools: { read_pdf: readPdfRuntime },
|
tools: { read_pdf: readPdfRuntime },
|
||||||
}).pipe(Stream.runCollect),
|
}).pipe(Stream.runCollect),
|
||||||
)
|
)
|
||||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "stop" } })
|
expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||||
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
|
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
|
|||||||
|
|
||||||
export const expectFinish = (
|
export const expectFinish = (
|
||||||
events: ReadonlyArray<LLMEvent>,
|
events: ReadonlyArray<LLMEvent>,
|
||||||
reason: FinishReason,
|
reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
|
||||||
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
|
) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
|
||||||
|
|
||||||
export const expectWeatherToolCall = (response: LLMResponse) =>
|
export const expectWeatherToolCall = (response: LLMResponse) =>
|
||||||
expect(response.toolCalls).toMatchObject([
|
expect(response.toolCalls).toMatchObject([
|
||||||
@@ -136,10 +136,10 @@ export const expectWeatherToolCall = (response: LLMResponse) =>
|
|||||||
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
|
export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
|
||||||
const finishes = events.filter(LLMEvent.is.finish)
|
const finishes = events.filter(LLMEvent.is.finish)
|
||||||
expect(finishes).toHaveLength(1)
|
expect(finishes).toHaveLength(1)
|
||||||
expect(finishes[0]?.reason.normalized).toBe("stop")
|
expect(finishes[0]?.reason).toBe("stop")
|
||||||
|
|
||||||
const stepFinishes = events.filter(LLMEvent.is.stepFinish)
|
const stepFinishes = events.filter(LLMEvent.is.stepFinish)
|
||||||
expect(stepFinishes.map((event) => event.reason.normalized)).toEqual(["tool-calls", "stop"])
|
expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
|
||||||
|
|
||||||
const toolCalls = events.filter(LLMEvent.is.toolCall)
|
const toolCalls = events.filter(LLMEvent.is.toolCall)
|
||||||
expect(toolCalls).toHaveLength(1)
|
expect(toolCalls).toHaveLength(1)
|
||||||
@@ -503,7 +503,7 @@ export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "finish") {
|
if (event.type === "finish") {
|
||||||
summary.push({ type: "finish", reason: event.reason.normalized, usage: usageSummary(event.usage) })
|
summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
|
return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ describe("LLMResponse reducer", () => {
|
|||||||
LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }),
|
LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }),
|
||||||
LLMEvent.textDelta({ id: "t1", text: "Answer" }),
|
LLMEvent.textDelta({ id: "t1", text: "Answer" }),
|
||||||
LLMEvent.textEnd({ id: "t1" }),
|
LLMEvent.textEnd({ id: "t1" }),
|
||||||
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 5 } }),
|
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }),
|
||||||
]
|
]
|
||||||
const response = LLMResponse.fromEvents(events)
|
const response = LLMResponse.fromEvents(events)
|
||||||
|
|
||||||
expect(response?.finishReason).toEqual({ normalized: "stop" })
|
expect(response?.finishReason).toBe("stop")
|
||||||
expect(response?.usage).toMatchObject({ outputTokens: 5 })
|
expect(response?.usage).toMatchObject({ outputTokens: 5 })
|
||||||
expect(response?.events).toEqual(events)
|
expect(response?.events).toEqual(events)
|
||||||
expect(response?.events.map((event) => event.type)).toEqual([
|
expect(response?.events.map((event) => event.type)).toEqual([
|
||||||
@@ -62,26 +62,18 @@ describe("LLMResponse reducer", () => {
|
|||||||
|
|
||||||
test("uses terminal usage when present and keeps prior usage when finish omits it", () => {
|
test("uses terminal usage when present and keeps prior usage when finish omits it", () => {
|
||||||
const withFinishUsage = LLMResponse.fromEvents([
|
const withFinishUsage = LLMResponse.fromEvents([
|
||||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
|
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
|
||||||
LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }),
|
LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }),
|
||||||
])
|
])
|
||||||
const withoutFinishUsage = LLMResponse.fromEvents([
|
const withoutFinishUsage = LLMResponse.fromEvents([
|
||||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 3 } }),
|
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }),
|
||||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
LLMEvent.finish({ reason: "stop" }),
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 })
|
expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 })
|
||||||
expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 })
|
expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves the raw finish reason", () => {
|
|
||||||
const response = LLMResponse.fromEvents([
|
|
||||||
LLMEvent.finish({ reason: { normalized: "unknown", raw: "provider_limit" } }),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(response?.finishReason).toEqual({ normalized: "unknown", raw: "provider_limit" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("assembles tool-call content only after the completed tool call event", () => {
|
test("assembles tool-call content only after the completed tool call event", () => {
|
||||||
const pending = reduce([
|
const pending = reduce([
|
||||||
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||||
@@ -96,7 +88,7 @@ describe("LLMResponse reducer", () => {
|
|||||||
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }),
|
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }),
|
||||||
LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }),
|
LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }),
|
||||||
LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||||
LLMEvent.finish({ reason: { normalized: "tool-calls" } }),
|
LLMEvent.finish({ reason: "tool-calls" }),
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(response?.message.content).toEqual([
|
expect(response?.message.content).toEqual([
|
||||||
|
|||||||
@@ -48,12 +48,8 @@ describe("llm schema", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("finish constructors accept usage input", () => {
|
test("finish constructors accept usage input", () => {
|
||||||
expect(
|
expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage)
|
||||||
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage,
|
expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
|
||||||
).toBeInstanceOf(Usage)
|
|
||||||
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(
|
|
||||||
Usage,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("content part tagged union exposes guards", () => {
|
test("content part tagged union exposes guards", () => {
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export async function setupTimeline(
|
|||||||
locale?: string
|
locale?: string
|
||||||
deviceScaleFactor?: number
|
deviceScaleFactor?: number
|
||||||
seedHistory?: boolean
|
seedHistory?: boolean
|
||||||
protocol?: "v1" | "v2"
|
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const sessions = input.sessions ?? [session()]
|
const sessions = input.sessions ?? [session()]
|
||||||
@@ -115,7 +114,6 @@ export async function setupTimeline(
|
|||||||
retry: input.eventRetry ?? 20,
|
retry: input.eventRetry ?? 20,
|
||||||
})
|
})
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: input.protocol,
|
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
provider: provider(),
|
provider: provider(),
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
|
|||||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||||
id: "timeline-event-7",
|
id: "timeline-event-7",
|
||||||
})
|
})
|
||||||
@@ -100,7 +100,7 @@ test("does not request replay when reconnecting the volatile V2 event stream", a
|
|||||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||||
|
|
||||||
expect(first.eventID).toBe("timeline-event-7")
|
expect(first.eventID).toBe("timeline-event-7")
|
||||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes through non-event fetches", async ({ page }) => {
|
test("passes through non-event fetches", async ({ page }) => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
|
|||||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
protocol?: "v1" | "v2"
|
|
||||||
provider: unknown
|
provider: unknown
|
||||||
directory: string
|
directory: string
|
||||||
project: unknown
|
project: unknown
|
||||||
@@ -54,20 +53,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||||
|
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||||
const events = config.events?.()
|
if (path === "/global/health") return json(route, { healthy: true })
|
||||||
return sse(
|
|
||||||
route,
|
|
||||||
path === "/api/event"
|
|
||||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
|
||||||
: events,
|
|
||||||
config.eventRetry,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (path === "/global/health")
|
|
||||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
|
||||||
if (path === "/api/health" && config.protocol === "v2")
|
|
||||||
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||||
if (path === "/permission")
|
if (path === "/permission")
|
||||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
@@ -96,129 +83,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
},
|
},
|
||||||
data: [],
|
data: [],
|
||||||
})
|
})
|
||||||
if (path === "/api/agent")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: "build",
|
|
||||||
name: "Build",
|
|
||||||
mode: "primary",
|
|
||||||
hidden: false,
|
|
||||||
request: { settings: {}, headers: {}, body: {} },
|
|
||||||
permissions: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
|
||||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
|
||||||
if (path === "/api/mcp/resource")
|
|
||||||
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
|
||||||
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
|
|
||||||
if (integration && route.request().method() === "GET")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
|
||||||
})
|
|
||||||
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
if (path === "/api/project") return json(route, [config.project])
|
|
||||||
if (path === "/api/project/current")
|
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
|
||||||
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
|
|
||||||
if (path === "/api/path")
|
|
||||||
return json(route, {
|
|
||||||
state: config.directory,
|
|
||||||
config: config.directory,
|
|
||||||
worktree: config.directory,
|
|
||||||
directory: config.directory,
|
|
||||||
home: "C:/OpenCode",
|
|
||||||
})
|
|
||||||
if (path === "/api/permission/request")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
|
||||||
currentPermission,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
if (path === "/api/question/request")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
|
||||||
})
|
|
||||||
if (path === "/api/vcs")
|
|
||||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
|
||||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
|
||||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
|
||||||
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
|
||||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
|
||||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
|
||||||
if (emptyObject.has(path)) return json(route, {})
|
if (emptyObject.has(path)) return json(route, {})
|
||||||
if (emptyList.has(path)) return json(route, [])
|
if (emptyList.has(path)) return json(route, [])
|
||||||
if (path === "/api/session") {
|
|
||||||
const directory = url.searchParams.get("directory")
|
|
||||||
const parentID = url.searchParams.get("parentID")
|
|
||||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
|
||||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
|
||||||
const sessions = config.sessions
|
|
||||||
.filter((session) => !directory || session.directory === directory)
|
|
||||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
|
||||||
.filter((session) => {
|
|
||||||
const search = url.searchParams.get("search")?.toLowerCase()
|
|
||||||
return (
|
|
||||||
!search ||
|
|
||||||
String(session.title ?? "")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(search)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
|
||||||
const data = ordered.slice(offset, offset + limit)
|
|
||||||
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
|
||||||
return json(route, {
|
|
||||||
data: data.map((session) => currentSession(session, config.directory)),
|
|
||||||
cursor: { next },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (path === "/api/session/active") {
|
|
||||||
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
|
|
||||||
return json(route, {
|
|
||||||
data: Object.fromEntries(
|
|
||||||
Object.entries(statuses).flatMap(([id, status]) =>
|
|
||||||
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
|
||||||
route.request().method() === "POST"
|
|
||||||
) {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||||
|
|
||||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
|
||||||
if (currentSessionMatch) {
|
|
||||||
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
|
||||||
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
|
|
||||||
return json(route, {
|
|
||||||
data: currentSession(session, config.directory),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||||
if (sessionMatch) {
|
if (sessionMatch) {
|
||||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||||
@@ -239,24 +107,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
|
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||||
|
|
||||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
|
||||||
if (currentMessagesMatch) {
|
|
||||||
const token = url.searchParams.get("cursor") ?? undefined
|
|
||||||
const before = token ? cursors.get(token) : undefined
|
|
||||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
|
||||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
|
|
||||||
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
|
|
||||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
|
||||||
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
|
||||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
|
|
||||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
|
||||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
|
||||||
return json(route, {
|
|
||||||
data: pageData.items.map(currentMessage).reverse(),
|
|
||||||
cursor: { next: cursor },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
if (messagesMatch) {
|
if (messagesMatch) {
|
||||||
const token = url.searchParams.get("before") ?? undefined
|
const token = url.searchParams.get("before") ?? undefined
|
||||||
@@ -279,115 +129,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function location(config: MockServerConfig) {
|
|
||||||
return {
|
|
||||||
directory: config.directory,
|
|
||||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentPermission(value: unknown) {
|
|
||||||
const permission = value as Record<string, unknown>
|
|
||||||
if (permission.action) return permission
|
|
||||||
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
|
|
||||||
return {
|
|
||||||
id: permission.id,
|
|
||||||
sessionID: permission.sessionID,
|
|
||||||
action: permission.permission,
|
|
||||||
resources: permission.patterns ?? [],
|
|
||||||
save: permission.always,
|
|
||||||
metadata: permission.metadata,
|
|
||||||
source:
|
|
||||||
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
|
||||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
|
||||||
return {
|
|
||||||
id: session.id,
|
|
||||||
parentID: session.parentID,
|
|
||||||
projectID: session.projectID ?? "project",
|
|
||||||
agent: session.agent ?? "build",
|
|
||||||
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
|
||||||
cost: session.cost ?? 0,
|
|
||||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: {
|
|
||||||
created: "created" in time && typeof time.created === "number" ? time.created : 0,
|
|
||||||
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
|
|
||||||
...(session.time && typeof session.time === "object" && "archived" in session.time
|
|
||||||
? { archived: session.time.archived }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
title: session.title ?? session.id,
|
|
||||||
location: {
|
|
||||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
|
||||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
|
||||||
},
|
|
||||||
subpath: session.path,
|
|
||||||
revert: session.revert,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentMessage(value: unknown) {
|
|
||||||
const item = value as {
|
|
||||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
|
|
||||||
parts: Array<Record<string, unknown> & { type: string }>
|
|
||||||
}
|
|
||||||
if (item.info.role === "user") {
|
|
||||||
return {
|
|
||||||
id: item.info.id,
|
|
||||||
type: "user",
|
|
||||||
time: item.info.time,
|
|
||||||
text: item.parts
|
|
||||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
|
||||||
.join("\n"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: item.info.id,
|
|
||||||
type: "assistant",
|
|
||||||
time: item.info.time,
|
|
||||||
agent: item.info.agent ?? "build",
|
|
||||||
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
|
|
||||||
cost: item.info.cost,
|
|
||||||
tokens: item.info.tokens,
|
|
||||||
error: item.info.error,
|
|
||||||
content: item.parts.flatMap<unknown>((part) => {
|
|
||||||
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
|
|
||||||
if (part.type !== "tool") return []
|
|
||||||
const state = part.state as Record<string, unknown>
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: part.id,
|
|
||||||
name: part.tool,
|
|
||||||
time: state.time ?? { created: item.info.time.created },
|
|
||||||
state:
|
|
||||||
state.status === "pending"
|
|
||||||
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
|
|
||||||
: state.status === "completed"
|
|
||||||
? {
|
|
||||||
status: "completed",
|
|
||||||
input: state.input ?? {},
|
|
||||||
structured: state.metadata ?? {},
|
|
||||||
content: [{ type: "text", text: state.output ?? "" }],
|
|
||||||
}
|
|
||||||
: state.status === "error"
|
|
||||||
? {
|
|
||||||
status: "error",
|
|
||||||
input: state.input ?? {},
|
|
||||||
structured: state.metadata ?? {},
|
|
||||||
content: [],
|
|
||||||
error: { type: "ToolError", message: state.error ?? "Tool failed" },
|
|
||||||
}
|
|
||||||
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status,
|
status,
|
||||||
@@ -408,18 +149,3 @@ function sse(route: Route, events?: unknown[], retry?: number) {
|
|||||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentEvent(input: unknown) {
|
|
||||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
|
||||||
const envelope = input as { directory?: string; payload?: unknown }
|
|
||||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
|
||||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
|
||||||
if (!payload.type) return input
|
|
||||||
return {
|
|
||||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
|
||||||
created: Date.now(),
|
|
||||||
type: payload.type,
|
|
||||||
data: payload.properties ?? {},
|
|
||||||
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
|||||||
export type SseConnectionRecord = {
|
export type SseConnectionRecord = {
|
||||||
id: number
|
id: number
|
||||||
url: string
|
url: string
|
||||||
path: "/global/event" | "/event" | "/api/event"
|
path: "/global/event" | "/event"
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
openedAt: number
|
openedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
@@ -93,21 +93,6 @@ export async function installSseTransport<T>(
|
|||||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||||
`data: ${JSON.stringify(payload)}\n\n`,
|
`data: ${JSON.stringify(payload)}\n\n`,
|
||||||
].join("")
|
].join("")
|
||||||
const currentEvent = (input: unknown) => {
|
|
||||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
|
||||||
const envelope = input as { directory?: string; payload?: unknown }
|
|
||||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
|
||||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
|
||||||
if (!payload.type) return input
|
|
||||||
return {
|
|
||||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
|
||||||
created: Date.now(),
|
|
||||||
type: payload.type,
|
|
||||||
data: payload.properties ?? {},
|
|
||||||
location:
|
|
||||||
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const acknowledge = (
|
const acknowledge = (
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
bytes: number,
|
bytes: number,
|
||||||
@@ -155,13 +140,15 @@ export async function installSseTransport<T>(
|
|||||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||||
return acknowledge(connection, input.bytes.length, output.length)
|
return acknowledge(connection, input.bytes.length, output.length)
|
||||||
}
|
}
|
||||||
const encoded = input.deliveries.map((delivery) => {
|
const encoded = input.deliveries.map((delivery) => ({
|
||||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
delivery,
|
||||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||||
})
|
}))
|
||||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||||
if (input.burst) {
|
if (input.burst) {
|
||||||
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
const bytes = encoder.encode(
|
||||||
|
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
|
||||||
|
)
|
||||||
connection.controller.enqueue(bytes)
|
connection.controller.enqueue(bytes)
|
||||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||||
}
|
}
|
||||||
@@ -174,10 +161,7 @@ export async function installSseTransport<T>(
|
|||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (
|
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||||
url.origin !== server ||
|
|
||||||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
|
||||||
)
|
|
||||||
return originalFetch(request)
|
return originalFetch(request)
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
@@ -193,10 +177,6 @@ export async function installSseTransport<T>(
|
|||||||
record.controller = controller
|
record.controller = controller
|
||||||
connections.push(record)
|
connections.push(record)
|
||||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||||
if (url.pathname === "/api/event")
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
|
||||||
)
|
|
||||||
request.signal.addEventListener(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -53,7 +53,6 @@
|
|||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
@@ -15,23 +14,6 @@ describe("resumeStreamAfterPageShow", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("adaptServerEvent", () => {
|
|
||||||
test("preserves V2 events while adapting permission requests for existing consumers", () => {
|
|
||||||
const current = {
|
|
||||||
id: "evt_1",
|
|
||||||
created: 1,
|
|
||||||
type: "permission.v2.asked",
|
|
||||||
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
|
|
||||||
} as OpenCodeEvent
|
|
||||||
|
|
||||||
expect(adaptServerEvent(current)).toMatchObject({
|
|
||||||
type: "permission.asked",
|
|
||||||
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
|
|
||||||
current,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("coalesceServerEvents", () => {
|
describe("coalesceServerEvents", () => {
|
||||||
const delta = (value: string, field = "text", partID = "part") => ({
|
const delta = (value: string, field = "text", partID = "part") => ({
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
@@ -52,24 +34,6 @@ describe("coalesceServerEvents", () => {
|
|||||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("merges adjacent current text deltas", () => {
|
|
||||||
const current = (id: string, value: string) =>
|
|
||||||
adaptServerEvent({
|
|
||||||
id,
|
|
||||||
created: 1,
|
|
||||||
type: "session.text.delta",
|
|
||||||
location: { directory: "/repo" },
|
|
||||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
|
|
||||||
} as OpenCodeEvent)
|
|
||||||
const result = coalesceServerEvents([
|
|
||||||
{ directory: "/repo", payload: current("evt_1", "hello ") },
|
|
||||||
{ directory: "/repo", payload: current("evt_2", "world") },
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves event boundaries and distinct fields", () => {
|
test("preserves event boundaries and distinct fields", () => {
|
||||||
const status = {
|
const status = {
|
||||||
directory: "/repo",
|
directory: "/repo",
|
||||||
|
|||||||
@@ -1,60 +1,21 @@
|
|||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
|
||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
|
import { createSdkForServer } from "@/utils/server"
|
||||||
import { useLanguage } from "./language"
|
import { useLanguage } from "./language"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
|
||||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
|
||||||
|
|
||||||
const isAbortError = (error: unknown) =>
|
const isAbortError = (error: unknown) =>
|
||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
|
|
||||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||||
export type ServerEvent = Event & { current?: OpenCodeEvent }
|
type QueuedServerEvent = { directory: string; payload: Event }
|
||||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
|
||||||
type CurrentDelta = Extract<
|
|
||||||
OpenCodeEvent,
|
|
||||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
|
||||||
>
|
|
||||||
|
|
||||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
|
||||||
if (event.type === "permission.v2.asked") {
|
|
||||||
return {
|
|
||||||
id: event.id,
|
|
||||||
type: "permission.asked",
|
|
||||||
properties: {
|
|
||||||
id: event.data.id,
|
|
||||||
sessionID: event.data.sessionID,
|
|
||||||
permission: event.data.action,
|
|
||||||
patterns: event.data.resources,
|
|
||||||
always: event.data.save ?? [],
|
|
||||||
metadata: event.data.metadata ?? {},
|
|
||||||
tool:
|
|
||||||
event.data.source?.type === "tool"
|
|
||||||
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
current: event,
|
|
||||||
} as ServerEvent
|
|
||||||
}
|
|
||||||
if (event.type === "permission.v2.replied")
|
|
||||||
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
|
|
||||||
if (event.type === "question.v2.asked")
|
|
||||||
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
|
|
||||||
if (event.type === "question.v2.replied")
|
|
||||||
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
|
|
||||||
if (event.type === "question.v2.rejected")
|
|
||||||
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
|
|
||||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
const coalescedKey = (event: QueuedServerEvent) => {
|
const coalescedKey = (event: QueuedServerEvent) => {
|
||||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||||
@@ -79,34 +40,6 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
|
|||||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||||
const output: QueuedServerEvent[] = []
|
const output: QueuedServerEvent[] = []
|
||||||
events.forEach((event) => {
|
events.forEach((event) => {
|
||||||
const current = currentDelta(event.payload.current)
|
|
||||||
if (current) {
|
|
||||||
const previous = output[output.length - 1]
|
|
||||||
const prior = currentDelta(previous?.payload.current)
|
|
||||||
if (
|
|
||||||
previous &&
|
|
||||||
prior &&
|
|
||||||
previous.directory === event.directory &&
|
|
||||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
|
||||||
) {
|
|
||||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
|
||||||
const data =
|
|
||||||
current.type === "session.compaction.delta"
|
|
||||||
? { ...current.data, text: fragment }
|
|
||||||
: { ...current.data, delta: fragment }
|
|
||||||
output[output.length - 1] = {
|
|
||||||
directory: event.directory,
|
|
||||||
payload: {
|
|
||||||
...event.payload,
|
|
||||||
properties: data,
|
|
||||||
current: { ...current, data } as CurrentDelta,
|
|
||||||
} as ServerEvent,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output.push(event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.payload.type !== "message.part.delta") {
|
if (event.payload.type !== "message.part.delta") {
|
||||||
output.push(event)
|
output.push(event)
|
||||||
return
|
return
|
||||||
@@ -138,52 +71,12 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
|
||||||
if (
|
|
||||||
event?.type === "session.text.delta" ||
|
|
||||||
event?.type === "session.reasoning.delta" ||
|
|
||||||
event?.type === "session.tool.input.delta" ||
|
|
||||||
event?.type === "session.compaction.delta"
|
|
||||||
)
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentDeltaKey(event: CurrentDelta) {
|
|
||||||
if (event.type === "session.tool.input.delta")
|
|
||||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
|
|
||||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
|
||||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentDeltaFragment(event: CurrentDelta) {
|
|
||||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||||
if (!event.persisted) return
|
if (!event.persisted) return
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
||||||
type ServerSDKBase = {
|
|
||||||
server: ServerConnection.Any
|
|
||||||
scope: ServerScope
|
|
||||||
protocol: Promise<ServerProtocol>
|
|
||||||
url: string
|
|
||||||
client: ReturnType<typeof createSdkForServer>
|
|
||||||
api: CompatibleApi
|
|
||||||
currentApi: ServerApi
|
|
||||||
event: {
|
|
||||||
on: ServerEventEmitter["on"]
|
|
||||||
listen: ServerEventEmitter["listen"]
|
|
||||||
start: () => Promise<void> | undefined
|
|
||||||
}
|
|
||||||
createClient: (
|
|
||||||
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
|
|
||||||
) => ReturnType<typeof createSdkForServer>
|
|
||||||
}
|
|
||||||
|
|
||||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
|
|
||||||
@@ -198,15 +91,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
|
||||||
const eventSdk = createSdkForServer({
|
const eventSdk = createSdkForServer({
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
fetch: eventFetch,
|
fetch: eventFetch,
|
||||||
server: server.http,
|
server: server.http,
|
||||||
})
|
})
|
||||||
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key: string]: ServerEvent
|
[key: string]: Event
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type Queued = QueuedServerEvent
|
type Queued = QueuedServerEvent
|
||||||
@@ -251,6 +142,21 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
let run: Promise<void> | undefined
|
let run: Promise<void> | undefined
|
||||||
let started = false
|
let started = false
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||||
|
let lastEventAt = Date.now()
|
||||||
|
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const resetHeartbeat = () => {
|
||||||
|
lastEventAt = Date.now()
|
||||||
|
if (heartbeat) clearTimeout(heartbeat)
|
||||||
|
heartbeat = setTimeout(() => {
|
||||||
|
attempt?.abort()
|
||||||
|
}, HEARTBEAT_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
const clearHeartbeat = () => {
|
||||||
|
if (!heartbeat) return
|
||||||
|
clearTimeout(heartbeat)
|
||||||
|
heartbeat = undefined
|
||||||
|
}
|
||||||
|
|
||||||
const start = () => {
|
const start = () => {
|
||||||
if (started) return run
|
if (started) return run
|
||||||
@@ -262,24 +168,35 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||||
while (!abort.signal.aborted && started && generation === active) {
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
attempt = new AbortController()
|
attempt = new AbortController()
|
||||||
|
lastEventAt = Date.now()
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
}
|
}
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
try {
|
try {
|
||||||
const kind = await protocol
|
const events = await eventSdk.global.event({
|
||||||
const events =
|
signal: attempt.signal,
|
||||||
kind === "v1"
|
onSseError: (error) => {
|
||||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
if (isStreamClosed(error, attempt?.signal)) return
|
||||||
: eventApi.event.subscribe({ signal: attempt.signal })
|
if (streamErrorLogged) return
|
||||||
|
streamErrorLogged = true
|
||||||
|
console.error("[global-sdk] event stream error", {
|
||||||
|
url: server.http.url,
|
||||||
|
fetch: eventFetch ? "platform" : "webview",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
let yielded = Date.now()
|
let yielded = Date.now()
|
||||||
for await (const event of events) {
|
resetHeartbeat()
|
||||||
|
for await (const event of events.stream) {
|
||||||
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
const legacy = "payload" in event
|
if (event.payload.type !== "sync") {
|
||||||
if (legacy && event.payload.type === "sync") continue
|
const directory = event.directory ?? "global"
|
||||||
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
|
const payload = event.payload as Event
|
||||||
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
|
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
}
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
yielded = Date.now()
|
yielded = Date.now()
|
||||||
@@ -297,6 +214,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
} finally {
|
} finally {
|
||||||
abort.signal.removeEventListener("abort", onAbort)
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
attempt = undefined
|
attempt = undefined
|
||||||
|
clearHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abort.signal.aborted || !started || generation !== active) return
|
if (abort.signal.aborted || !started || generation !== active) return
|
||||||
@@ -315,11 +233,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
started = false
|
started = false
|
||||||
generation++
|
generation++
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
|
clearHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
makeEventListener(window, "pagehide", stop)
|
makeEventListener(window, "pagehide", stop)
|
||||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||||
|
makeEventListener(document, "visibilitychange", () => {
|
||||||
|
if (document.visibilityState !== "visible") return
|
||||||
|
if (!started) return
|
||||||
|
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||||
|
attempt?.abort()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -333,24 +258,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
fetch: platform.fetch,
|
fetch: platform.fetch,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
|
|
||||||
const legacy = (directory?: string) =>
|
|
||||||
createSdkForServer({
|
|
||||||
server: server.http,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
throwOnError: true,
|
|
||||||
directory,
|
|
||||||
})
|
|
||||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
scope,
|
scope,
|
||||||
protocol,
|
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
client: sdk,
|
client: sdk,
|
||||||
api,
|
|
||||||
currentApi,
|
|
||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
listen: emitter.listen.bind(emitter),
|
listen: emitter.listen.bind(emitter),
|
||||||
@@ -366,6 +279,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||||
export type ServerSDK = ServerSDKBase & {
|
export type ServerSDK = ServerSDKBase & {
|
||||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||||
}
|
}
|
||||||
@@ -395,7 +309,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
|||||||
})
|
})
|
||||||
|
|
||||||
type SDKEventMap = {
|
type SDKEventMap = {
|
||||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||||
@@ -415,12 +329,6 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
|||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
directory,
|
directory,
|
||||||
client,
|
client,
|
||||||
api: createCompatibleApi({
|
|
||||||
protocol: serverSDK.protocol,
|
|
||||||
current: serverSDK.currentApi,
|
|
||||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
|
||||||
directory,
|
|
||||||
}),
|
|
||||||
event: emitter,
|
event: emitter,
|
||||||
get url() {
|
get url() {
|
||||||
return serverSDK.url
|
return serverSDK.url
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { createApiForServer, createSdkForServer } from "./server"
|
|
||||||
import { createCompatibleApi } from "./server-compat"
|
|
||||||
|
|
||||||
function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) {
|
|
||||||
const requests: Request[] = []
|
|
||||||
const fetcher = Object.assign(
|
|
||||||
async (input: string | URL | Request, init?: RequestInit) => {
|
|
||||||
const request = new Request(input, init)
|
|
||||||
requests.push(request)
|
|
||||||
if (request.method === "PATCH") {
|
|
||||||
return Response.json({
|
|
||||||
id: "ses_1",
|
|
||||||
slug: "ses_1",
|
|
||||||
projectID: "project",
|
|
||||||
directory: "/repo",
|
|
||||||
title: "Session",
|
|
||||||
version: "1",
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
|
|
||||||
return new Response(undefined, { status: 204 })
|
|
||||||
if (request.method === "POST" && request.url.endsWith("/prompt")) {
|
|
||||||
return Response.json({
|
|
||||||
admittedSeq: 1,
|
|
||||||
id: "msg_1",
|
|
||||||
sessionID: "ses_1",
|
|
||||||
timeCreated: 1,
|
|
||||||
type: "user",
|
|
||||||
data: { text: "hello" },
|
|
||||||
delivery: "steer",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (request.method === "GET") return Response.json([])
|
|
||||||
return new Response(undefined, { status: 204 })
|
|
||||||
},
|
|
||||||
{ preconnect: globalThis.fetch.preconnect },
|
|
||||||
)
|
|
||||||
const server = { url: "http://localhost:4096" }
|
|
||||||
const api = createCompatibleApi({
|
|
||||||
protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
|
|
||||||
current: createApiForServer({ server, fetch: fetcher }),
|
|
||||||
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
|
|
||||||
directory: "/repo",
|
|
||||||
})
|
|
||||||
return { api, requests }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("createCompatibleApi", () => {
|
|
||||||
test("routes V1 archive through the legacy session update", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
|
|
||||||
|
|
||||||
const url = new URL(requests[0]!.url)
|
|
||||||
expect(url.pathname).toBe("/session/ses_1")
|
|
||||||
expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
|
|
||||||
expect(requests[0]!.method).toBe("PATCH")
|
|
||||||
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("converts current prompts to the V1 prompt contract", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
await api.session.prompt({
|
|
||||||
sessionID: "ses_1",
|
|
||||||
id: "msg_1",
|
|
||||||
text: "hello",
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "provider", modelID: "model" },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
|
|
||||||
expect(await requests[0]!.json()).toMatchObject({
|
|
||||||
messageID: "msg_1",
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "provider", modelID: "model" },
|
|
||||||
parts: [{ type: "text", text: "hello" }],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps V2 session actions on the current API", async () => {
|
|
||||||
const { api, requests } = setup("v2")
|
|
||||||
await api.session.archive({ sessionID: "ses_1" })
|
|
||||||
|
|
||||||
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
|
|
||||||
expect(requests[0]!.method).toBe("POST")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resolves protocol detection once across implementation methods", async () => {
|
|
||||||
let detections = 0
|
|
||||||
const resolved = Promise.resolve<"v1" | "v2">("v2")
|
|
||||||
const protocol = new Proxy(resolved, {
|
|
||||||
get(target, property) {
|
|
||||||
if (property !== "then") return Reflect.get(target, property, target)
|
|
||||||
detections++
|
|
||||||
return target.then.bind(target)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { api } = setup(protocol)
|
|
||||||
|
|
||||||
await api.session.archive({ sessionID: "ses_1" })
|
|
||||||
await api.session.list()
|
|
||||||
|
|
||||||
expect(detections).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses the global V1 session search endpoint", async () => {
|
|
||||||
const { api, requests } = setup("v1")
|
|
||||||
await api.session.list({ parentID: null, search: "session", limit: 50 })
|
|
||||||
|
|
||||||
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
import type { ServerApi } from "./server"
|
|
||||||
import type { ServerProtocol } from "./server-protocol"
|
|
||||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type {
|
|
||||||
Project,
|
|
||||||
ProjectCurrent,
|
|
||||||
SessionApi,
|
|
||||||
SessionCommandInput,
|
|
||||||
SessionCommandOutput,
|
|
||||||
SessionCompactInput,
|
|
||||||
SessionCompactOutput,
|
|
||||||
SessionInfo,
|
|
||||||
SessionPromptInput,
|
|
||||||
SessionPromptOutput,
|
|
||||||
SessionShellInput,
|
|
||||||
SessionShellOutput,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
|
|
||||||
type LegacyClient = OpencodeClient
|
|
||||||
type LegacyFor = (directory?: string) => LegacyClient
|
|
||||||
type CompatibleSessionApi = Omit<
|
|
||||||
SessionApi,
|
|
||||||
"prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
|
|
||||||
> & {
|
|
||||||
prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
|
|
||||||
command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
|
|
||||||
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
|
||||||
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
|
||||||
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
|
||||||
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
|
||||||
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
|
||||||
}
|
|
||||||
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
|
|
||||||
type LegacyPrompt = {
|
|
||||||
agent?: string
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
variant?: string
|
|
||||||
}
|
|
||||||
type LegacyLocation = { directory?: string }
|
|
||||||
type CompatibleInput = {
|
|
||||||
protocol: Promise<ServerProtocol>
|
|
||||||
current: ServerApi
|
|
||||||
legacy: LegacyFor
|
|
||||||
directory?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function mime(uri: string) {
|
|
||||||
const match = /^data:([^;,]+)/.exec(uri)
|
|
||||||
return match?.[1] ?? "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionInfo(session: Session): SessionInfo {
|
|
||||||
return {
|
|
||||||
id: session.id,
|
|
||||||
parentID: session.parentID,
|
|
||||||
projectID: session.projectID,
|
|
||||||
agent: session.agent,
|
|
||||||
model: session.model && {
|
|
||||||
id: session.model.id,
|
|
||||||
providerID: session.model.providerID,
|
|
||||||
variant: session.model.variant,
|
|
||||||
},
|
|
||||||
cost: session.cost ?? 0,
|
|
||||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: session.time,
|
|
||||||
title: session.title,
|
|
||||||
location: { directory: session.directory, workspaceID: session.workspaceID },
|
|
||||||
subpath: session.path,
|
|
||||||
revert: session.revert && {
|
|
||||||
messageID: session.revert.messageID,
|
|
||||||
partID: session.revert.partID,
|
|
||||||
snapshot: session.revert.snapshot,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
|
|
||||||
const v1 = createV1Api(input)
|
|
||||||
return lazyApi(
|
|
||||||
input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
|
|
||||||
input.current,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
|
|
||||||
const cache = new Map<PropertyKey, unknown>()
|
|
||||||
return new Proxy(shape, {
|
|
||||||
get(target, property, receiver) {
|
|
||||||
const sample = Reflect.get(target, property, receiver)
|
|
||||||
if (typeof sample === "function") {
|
|
||||||
return (...args: unknown[]) =>
|
|
||||||
implementation.then((value) => {
|
|
||||||
const method = Reflect.get(value, property)
|
|
||||||
if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
|
|
||||||
return Reflect.apply(method, value, args)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (sample === null || typeof sample !== "object") return sample
|
|
||||||
if (cache.has(property)) return cache.get(property)
|
|
||||||
const nested = lazyApi(
|
|
||||||
implementation.then((value) => {
|
|
||||||
const result = Reflect.get(value, property)
|
|
||||||
if (result === null || typeof result !== "object") {
|
|
||||||
throw new Error(`API namespace unavailable: ${String(property)}`)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}),
|
|
||||||
sample,
|
|
||||||
)
|
|
||||||
cache.set(property, nested)
|
|
||||||
return nested
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createV1Api(input: CompatibleInput): CompatibleApi {
|
|
||||||
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
|
|
||||||
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
|
|
||||||
const located = <T>(data: T, value?: { directory?: string }) => ({
|
|
||||||
location: {
|
|
||||||
directory: directory(value) ?? "",
|
|
||||||
project: { id: "", directory: directory(value) ?? "" },
|
|
||||||
},
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...input.current,
|
|
||||||
session: {
|
|
||||||
...input.current.session,
|
|
||||||
async list(
|
|
||||||
value?: Parameters<ServerApi["session"]["list"]>[0],
|
|
||||||
options?: Parameters<ServerApi["session"]["list"]>[1],
|
|
||||||
) {
|
|
||||||
if (!value?.directory && value?.search !== undefined) {
|
|
||||||
const result = await legacy().experimental.session.list(
|
|
||||||
{
|
|
||||||
roots: value.parentID === null ? true : undefined,
|
|
||||||
search: value.search,
|
|
||||||
limit: value.limit,
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
)
|
|
||||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
|
||||||
}
|
|
||||||
const result = await legacy({ directory: value?.directory }).session.list({
|
|
||||||
directory: value?.directory,
|
|
||||||
roots: value?.parentID === null ? true : undefined,
|
|
||||||
search: value?.search,
|
|
||||||
limit: value?.limit,
|
|
||||||
})
|
|
||||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
|
||||||
},
|
|
||||||
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
|
|
||||||
const result = await legacy(value?.location ?? undefined).session.create({
|
|
||||||
directory: directory(value?.location ?? undefined),
|
|
||||||
})
|
|
||||||
if (!result.data) throw new Error("Failed to create session")
|
|
||||||
return sessionInfo(result.data)
|
|
||||||
},
|
|
||||||
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
|
|
||||||
const result = await legacy().session.get(value)
|
|
||||||
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
|
|
||||||
return sessionInfo(result.data)
|
|
||||||
},
|
|
||||||
async active() {
|
|
||||||
const result = await legacy().session.status()
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
|
|
||||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
|
||||||
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
|
||||||
},
|
|
||||||
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
|
||||||
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
|
||||||
},
|
|
||||||
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
|
||||||
await legacy(value).session.delete(value)
|
|
||||||
},
|
|
||||||
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
|
|
||||||
const result = await legacy().session.fork(value)
|
|
||||||
if (!result.data) throw new Error("Failed to fork session")
|
|
||||||
return sessionInfo(result.data)
|
|
||||||
},
|
|
||||||
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
|
|
||||||
await legacy().session.abort(value)
|
|
||||||
},
|
|
||||||
async prompt(value: SessionPromptInput & LegacyPrompt) {
|
|
||||||
await legacy().session.promptAsync({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
messageID: value.id ?? undefined,
|
|
||||||
agent: value.agent,
|
|
||||||
model: value.model,
|
|
||||||
variant: value.variant,
|
|
||||||
parts: [
|
|
||||||
{ type: "text", text: value.text },
|
|
||||||
...(value.files ?? []).map((file) => ({
|
|
||||||
type: "file" as const,
|
|
||||||
mime: mime(file.uri),
|
|
||||||
url: file.uri,
|
|
||||||
filename: file.name,
|
|
||||||
})),
|
|
||||||
...(value.agents ?? []).map((agent) => ({
|
|
||||||
type: "agent" as const,
|
|
||||||
name: agent.name,
|
|
||||||
source: agent.mention
|
|
||||||
? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
admittedSeq: 0,
|
|
||||||
id: value.id ?? "",
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
timeCreated: Date.now(),
|
|
||||||
type: "user",
|
|
||||||
data: { text: value.text },
|
|
||||||
delivery: value.delivery ?? "steer",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async command(value: SessionCommandInput) {
|
|
||||||
await legacy().session.command({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
messageID: value.id ?? undefined,
|
|
||||||
command: value.command,
|
|
||||||
arguments: value.arguments ?? "",
|
|
||||||
agent: value.agent ?? undefined,
|
|
||||||
model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
|
|
||||||
variant: value.model?.variant,
|
|
||||||
parts: value.files?.map((file) => ({
|
|
||||||
type: "file" as const,
|
|
||||||
mime: mime(file.uri),
|
|
||||||
url: file.uri,
|
|
||||||
filename: file.name,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
admittedSeq: 0,
|
|
||||||
id: value.id ?? "",
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
timeCreated: Date.now(),
|
|
||||||
type: "user",
|
|
||||||
data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
|
|
||||||
delivery: value.delivery ?? "steer",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async shell(value: SessionShellInput & LegacyPrompt) {
|
|
||||||
await legacy().session.shell({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
command: value.command,
|
|
||||||
agent: value.agent,
|
|
||||||
model: value.model,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
|
|
||||||
if (!value.model) throw new Error("A model is required to compact a V1 session")
|
|
||||||
await legacy().session.summarize({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
providerID: value.model.providerID,
|
|
||||||
modelID: value.model.modelID,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
admittedSeq: 0,
|
|
||||||
id: value.id ?? "",
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
timeCreated: Date.now(),
|
|
||||||
type: "compaction",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
revert: {
|
|
||||||
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
|
|
||||||
await legacy().session.revert(value)
|
|
||||||
return { messageID: value.messageID }
|
|
||||||
},
|
|
||||||
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
|
|
||||||
await legacy().session.unrevert(value)
|
|
||||||
},
|
|
||||||
commit: input.current.session.revert.commit,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
project: {
|
|
||||||
...input.current.project,
|
|
||||||
async list() {
|
|
||||||
return ((await legacy().project.list()).data ?? []) as Project[]
|
|
||||||
},
|
|
||||||
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).project.current()
|
|
||||||
if (!result.data) throw new Error("Project not found")
|
|
||||||
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
|
||||||
},
|
|
||||||
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
|
||||||
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
|
||||||
const result = await legacy({ directory: project?.worktree }).project.update({
|
|
||||||
...value,
|
|
||||||
directory: project?.worktree,
|
|
||||||
})
|
|
||||||
if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
|
|
||||||
return result.data as Project
|
|
||||||
},
|
|
||||||
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
|
||||||
const result = await legacy(value.location).worktree.list()
|
|
||||||
return (result.data ?? []).map((item) => ({ directory: item }))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
path: {
|
|
||||||
...input.current.path,
|
|
||||||
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).path.get()
|
|
||||||
if (!result.data) throw new Error("Path unavailable")
|
|
||||||
return result.data
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vcs: {
|
|
||||||
...input.current.vcs,
|
|
||||||
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).vcs.get()
|
|
||||||
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
|
|
||||||
},
|
|
||||||
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).vcs.status()
|
|
||||||
return located(result.data ?? [], value?.location)
|
|
||||||
},
|
|
||||||
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
|
|
||||||
const result = await legacy(value.location).vcs.diff({
|
|
||||||
mode: value.mode === "working" ? "git" : value.mode,
|
|
||||||
context: value.context,
|
|
||||||
})
|
|
||||||
return located(
|
|
||||||
(result.data ?? []).map((file) => ({
|
|
||||||
file: file.file,
|
|
||||||
patch: file.patch ?? "",
|
|
||||||
additions: file.additions,
|
|
||||||
deletions: file.deletions,
|
|
||||||
status: file.status ?? "modified",
|
|
||||||
})),
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
file: {
|
|
||||||
...input.current.file,
|
|
||||||
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
|
|
||||||
return located(result.data ?? [], value?.location)
|
|
||||||
},
|
|
||||||
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
|
|
||||||
const result = await legacy(value.location).find.files({
|
|
||||||
query: value.query,
|
|
||||||
type: value.type,
|
|
||||||
limit: value.limit,
|
|
||||||
})
|
|
||||||
return located(
|
|
||||||
(result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
integration: {
|
|
||||||
...input.current.integration,
|
|
||||||
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
|
|
||||||
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
|
|
||||||
(method, index) =>
|
|
||||||
method.type === "api"
|
|
||||||
? { type: "key" as const, label: method.label }
|
|
||||||
: { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
|
|
||||||
)
|
|
||||||
return located(
|
|
||||||
{
|
|
||||||
id: value.integrationID,
|
|
||||||
name: value.integrationID,
|
|
||||||
methods,
|
|
||||||
connections: [],
|
|
||||||
},
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
connect: {
|
|
||||||
...input.current.integration.connect,
|
|
||||||
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
|
|
||||||
await legacy(value.location).auth.set({
|
|
||||||
providerID: value.integrationID,
|
|
||||||
auth: { type: "api", key: value.key },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
...input.current.integration.oauth,
|
|
||||||
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
|
|
||||||
const method = Number(value.methodID)
|
|
||||||
const result = await legacy(value.location).provider.oauth.authorize(
|
|
||||||
{ providerID: value.integrationID, method, inputs: value.inputs },
|
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
if (!result.data) throw new Error("Failed to start OAuth authorization")
|
|
||||||
return located(
|
|
||||||
{
|
|
||||||
attemptID: `${value.integrationID}:${method}`,
|
|
||||||
url: result.data.url,
|
|
||||||
instructions: result.data.instructions,
|
|
||||||
mode: result.data.method,
|
|
||||||
time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
|
|
||||||
},
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
|
|
||||||
const method = Number(value.attemptID.split(":").at(-1))
|
|
||||||
await legacy(value.location).provider.oauth.callback(
|
|
||||||
{ providerID: value.integrationID, method, code: value.code },
|
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
|
|
||||||
const method = Number(value.attemptID.split(":").at(-1))
|
|
||||||
await legacy(value.location).provider.oauth.callback(
|
|
||||||
{ providerID: value.integrationID, method },
|
|
||||||
{ throwOnError: true },
|
|
||||||
)
|
|
||||||
return located(
|
|
||||||
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
|
|
||||||
value.location,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pty: {
|
|
||||||
...input.current.pty,
|
|
||||||
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
|
|
||||||
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
|
|
||||||
},
|
|
||||||
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
|
|
||||||
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
|
|
||||||
},
|
|
||||||
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
|
|
||||||
const result = await legacy(value?.location).pty.create({
|
|
||||||
command: value?.command,
|
|
||||||
args: value?.args ? [...value.args] : undefined,
|
|
||||||
cwd: value?.cwd,
|
|
||||||
title: value?.title,
|
|
||||||
env: value?.env,
|
|
||||||
})
|
|
||||||
if (!result.data) throw new Error("Failed to create terminal")
|
|
||||||
return located(result.data, value?.location)
|
|
||||||
},
|
|
||||||
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
|
|
||||||
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
|
|
||||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
|
||||||
return located(result.data, value.location)
|
|
||||||
},
|
|
||||||
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
|
|
||||||
const result = await legacy(value.location).pty.update({
|
|
||||||
ptyID: value.ptyID,
|
|
||||||
title: value.title,
|
|
||||||
size: value.size,
|
|
||||||
})
|
|
||||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
|
||||||
return located(result.data, value.location)
|
|
||||||
},
|
|
||||||
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
|
|
||||||
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
|
||||||
},
|
|
||||||
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
|
|
||||||
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
|
|
||||||
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
|
|
||||||
return located(result.data, value.location)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
permission: {
|
|
||||||
...input.current.permission,
|
|
||||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
|
|
||||||
await legacy().permission.respond({
|
|
||||||
sessionID: value.sessionID,
|
|
||||||
permissionID: value.requestID,
|
|
||||||
response: value.reply,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
question: {
|
|
||||||
...input.current.question,
|
|
||||||
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
|
||||||
await legacy().question.reply({
|
|
||||||
requestID: value.requestID,
|
|
||||||
answers: value.answers.map((answer) => [...answer]),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
|
|
||||||
await legacy().question.reject({ requestID: value.requestID })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,45 +14,15 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
|
|||||||
|
|
||||||
describe("checkServerHealth", () => {
|
describe("checkServerHealth", () => {
|
||||||
test("returns healthy response with version", async () => {
|
test("returns healthy response with version", async () => {
|
||||||
let request: URL | undefined
|
const fetch = (async () =>
|
||||||
const fetch = (async (input: RequestInfo | URL) => {
|
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||||
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
|
||||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
})
|
})) as unknown as typeof globalThis.fetch
|
||||||
}) as unknown as typeof globalThis.fetch
|
|
||||||
|
|
||||||
const result = await checkServerHealth(server, fetch)
|
const result = await checkServerHealth(server, fetch)
|
||||||
|
|
||||||
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
||||||
expect(request?.pathname).toBe("/api/health")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back to the V1 health endpoint", async () => {
|
|
||||||
const paths: string[] = []
|
|
||||||
const fetch = (async (input: RequestInfo | URL) => {
|
|
||||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
|
||||||
paths.push(url.pathname)
|
|
||||||
if (url.pathname === "/api/health") return new Response(undefined, { status: 404 })
|
|
||||||
return Response.json({ healthy: true, version: "1.18.4" })
|
|
||||||
}) as unknown as typeof globalThis.fetch
|
|
||||||
|
|
||||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
|
||||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back when the current health response is malformed", async () => {
|
|
||||||
const paths: string[] = []
|
|
||||||
const fetch = (async (input: RequestInfo | URL) => {
|
|
||||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
|
||||||
paths.push(url.pathname)
|
|
||||||
if (url.pathname === "/api/health") return Response.json({})
|
|
||||||
return Response.json({ healthy: true, version: "1.18.4" })
|
|
||||||
}) as unknown as typeof globalThis.fetch
|
|
||||||
|
|
||||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
|
||||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("allows slow servers thirty seconds by default", async () => {
|
test("allows slow servers thirty seconds by default", async () => {
|
||||||
@@ -172,7 +142,7 @@ describe("checkServerHealth", () => {
|
|||||||
retryDelayMs: 1,
|
retryDelayMs: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(count).toBe(6)
|
expect(count).toBe(3)
|
||||||
expect(result).toEqual({ healthy: false })
|
expect(result).toEqual({ healthy: false })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { authTokenFromCredentials, createSdkForServer } from "./server"
|
import { createSdkForServer } from "./server"
|
||||||
import { ClientError, OpenCode } from "@opencode-ai/client"
|
|
||||||
import { Accessor, createEffect, onCleanup } from "solid-js"
|
import { Accessor, createEffect, onCleanup } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
|
||||||
@@ -62,7 +61,6 @@ function wait(ms: number, signal?: AbortSignal) {
|
|||||||
|
|
||||||
function retryable(error: unknown, signal?: AbortSignal) {
|
function retryable(error: unknown, signal?: AbortSignal) {
|
||||||
if (signal?.aborted) return false
|
if (signal?.aborted) return false
|
||||||
if (error instanceof ClientError) return error.reason === "Transport"
|
|
||||||
if (!(error instanceof Error)) return false
|
if (!(error instanceof Error)) return false
|
||||||
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
||||||
if (error instanceof TypeError) return true
|
if (error instanceof TypeError) return true
|
||||||
@@ -84,31 +82,15 @@ export async function checkServerHealth(
|
|||||||
.then(() => attempt(count + 1))
|
.then(() => attempt(count + 1))
|
||||||
.catch(() => ({ healthy: false }))
|
.catch(() => ({ healthy: false }))
|
||||||
}
|
}
|
||||||
const attempt = async (count: number): Promise<ServerHealth> => {
|
const attempt = (count: number): Promise<ServerHealth> =>
|
||||||
const current = await OpenCode.make({
|
createSdkForServer({
|
||||||
baseUrl: server.url,
|
server,
|
||||||
fetch,
|
fetch,
|
||||||
headers: server.password
|
signal,
|
||||||
? {
|
|
||||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
})
|
||||||
.health.get({ signal })
|
|
||||||
.then((x) =>
|
|
||||||
typeof x.healthy === "boolean"
|
|
||||||
? { data: { healthy: x.healthy, version: x.version } }
|
|
||||||
: { error: new Error("Invalid health response") },
|
|
||||||
)
|
|
||||||
.catch((error) => ({ error }))
|
|
||||||
if ("data" in current && current.data) return current.data
|
|
||||||
if (signal?.aborted) return { healthy: false }
|
|
||||||
|
|
||||||
return createSdkForServer({ server, fetch, signal })
|
|
||||||
.global.health()
|
.global.health()
|
||||||
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||||
.catch((error) => next(count, error))
|
.catch((error) => next(count, error))
|
||||||
}
|
|
||||||
return attempt(0).finally(() => timeout?.clear?.())
|
return attempt(0).finally(() => timeout?.clear?.())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { detectServerProtocol } from "./server-protocol"
|
|
||||||
|
|
||||||
const server = { url: "http://localhost:4096" }
|
|
||||||
const json = (value: unknown, status = 200) =>
|
|
||||||
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
|
|
||||||
const mockFetch = (run: (input: string | URL | Request) => Promise<Response>) =>
|
|
||||||
Object.assign(run, { preconnect: globalThis.fetch.preconnect })
|
|
||||||
|
|
||||||
describe("detectServerProtocol", () => {
|
|
||||||
test("prefers the legacy health endpoint when both API generations exist", async () => {
|
|
||||||
const fetcher = mockFetch((input) => {
|
|
||||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
|
||||||
if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" }))
|
|
||||||
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("recognizes V2 health by its process identifier", async () => {
|
|
||||||
const fetcher = mockFetch((input) => {
|
|
||||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
|
||||||
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
|
||||||
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await detectServerProtocol(server, fetcher)).toBe("v2")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("recognizes the transitional V1 API health response", async () => {
|
|
||||||
const fetcher = mockFetch((input) => {
|
|
||||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
|
||||||
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
|
||||||
return Promise.resolve(json({ healthy: true }))
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { ServerConnection } from "@/context/server"
|
|
||||||
import { authTokenFromCredentials } from "./server"
|
|
||||||
|
|
||||||
export type ServerProtocol = "v1" | "v2"
|
|
||||||
|
|
||||||
function headers(server: ServerConnection.HttpBase) {
|
|
||||||
if (!server.password) return
|
|
||||||
return {
|
|
||||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) {
|
|
||||||
const response = await fetch(new URL(path, server.url), {
|
|
||||||
headers: headers(server),
|
|
||||||
signal: AbortSignal.timeout(5_000),
|
|
||||||
})
|
|
||||||
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return
|
|
||||||
const value: unknown = await response.json()
|
|
||||||
if (!value || typeof value !== "object") return
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function detectServerProtocol(
|
|
||||||
server: ServerConnection.HttpBase,
|
|
||||||
fetch: typeof globalThis.fetch,
|
|
||||||
): Promise<ServerProtocol> {
|
|
||||||
const legacy = await probe(server, fetch, "/global/health").catch(() => undefined)
|
|
||||||
if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1"
|
|
||||||
|
|
||||||
const current = await probe(server, fetch, "/api/health").catch(() => undefined)
|
|
||||||
if (current && "pid" in current && typeof current.pid === "number") return "v2"
|
|
||||||
if (current && "healthy" in current && current.healthy === true) return "v1"
|
|
||||||
return "v2"
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
@@ -40,23 +39,3 @@ export function createSdkForServer({
|
|||||||
baseUrl: server.url,
|
baseUrl: server.url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApiForServer(input: {
|
|
||||||
server: ServerConnection.HttpBase
|
|
||||||
fetch?: typeof globalThis.fetch
|
|
||||||
}): OpenCodeClient {
|
|
||||||
return OpenCode.make({
|
|
||||||
baseUrl: input.server.url,
|
|
||||||
fetch: input.fetch,
|
|
||||||
headers: input.server.password
|
|
||||||
? {
|
|
||||||
Authorization: `Basic ${authTokenFromCredentials({
|
|
||||||
username: input.server.username,
|
|
||||||
password: input.server.password,
|
|
||||||
})}`,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerApi = OpenCodeClient
|
|
||||||
|
|||||||
Binary file not shown.
@@ -22,7 +22,7 @@
|
|||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "1.2.1",
|
"@agentclientprotocol/sdk": "0.21.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
type AuthenticateRequest,
|
type AuthenticateRequest,
|
||||||
type CancelNotification,
|
type CancelNotification,
|
||||||
type CloseSessionRequest,
|
type CloseSessionRequest,
|
||||||
type DeleteSessionRequest,
|
|
||||||
type ForkSessionRequest,
|
type ForkSessionRequest,
|
||||||
type InitializeRequest,
|
type InitializeRequest,
|
||||||
type ListSessionsRequest,
|
type ListSessionsRequest,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
type PromptRequest,
|
type PromptRequest,
|
||||||
type ResumeSessionRequest,
|
type ResumeSessionRequest,
|
||||||
type SetSessionConfigOptionRequest,
|
type SetSessionConfigOptionRequest,
|
||||||
|
type SetSessionModelRequest,
|
||||||
type SetSessionModeRequest,
|
type SetSessionModeRequest,
|
||||||
} from "@agentclientprotocol/sdk"
|
} from "@agentclientprotocol/sdk"
|
||||||
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
@@ -28,12 +28,12 @@ export function create(client: OpenCodeClient, connection: AgentSideConnection)
|
|||||||
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
|
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
|
||||||
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
|
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
|
||||||
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
|
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
|
||||||
deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)),
|
|
||||||
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
|
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
|
||||||
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
|
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
|
||||||
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
|
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
|
||||||
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
|
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
|
||||||
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
|
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
|
||||||
|
unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)),
|
||||||
prompt: (params: PromptRequest) => run(service.prompt(params)),
|
prompt: (params: PromptRequest) => run(service.prompt(params)),
|
||||||
cancel: (params: CancelNotification) => run(service.cancel(params)),
|
cancel: (params: CancelNotification) => run(service.cancel(params)),
|
||||||
} satisfies Agent
|
} satisfies Agent
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export type TurnControl = {
|
|||||||
type ToolState = {
|
type ToolState = {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
input: ToolInput
|
input: ToolInput
|
||||||
metadata: Record<string, unknown>
|
structured: Record<string, unknown>
|
||||||
content: ToolContent
|
content: ToolContent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export type TurnStart =
|
|||||||
| { readonly type: "compaction"; readonly id: string }
|
| { readonly type: "compaction"; readonly id: string }
|
||||||
|
|
||||||
function emptyToolState(): ToolState {
|
function emptyToolState(): ToolState {
|
||||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
return { name: "tool", input: {}, structured: {}, content: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function streamTurn(input: {
|
export async function streamTurn(input: {
|
||||||
@@ -47,7 +47,7 @@ export async function streamTurn(input: {
|
|||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly start: TurnStart
|
readonly start: TurnStart
|
||||||
readonly writeTextFile: boolean
|
readonly userMessageID?: string | null
|
||||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||||
readonly control: TurnControl
|
readonly control: TurnControl
|
||||||
}): Promise<PromptResponse> {
|
}): Promise<PromptResponse> {
|
||||||
@@ -120,7 +120,7 @@ export async function streamTurn(input: {
|
|||||||
}
|
}
|
||||||
if (event.type === "session.tool.input.started") {
|
if (event.type === "session.tool.input.started") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
assistantMessageID = event.data.assistantMessageID
|
||||||
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] })
|
||||||
await update({
|
await update({
|
||||||
sessionUpdate: "tool_call",
|
sessionUpdate: "tool_call",
|
||||||
...pendingToolCall({
|
...pendingToolCall({
|
||||||
@@ -151,13 +151,15 @@ export async function streamTurn(input: {
|
|||||||
if (event.type === "session.tool.progress") {
|
if (event.type === "session.tool.progress") {
|
||||||
const current = tools.get(event.data.callID)
|
const current = tools.get(event.data.callID)
|
||||||
if (!current) continue
|
if (!current) continue
|
||||||
current.metadata = event.data.metadata
|
current.structured = event.data.structured
|
||||||
|
current.content = event.data.content
|
||||||
await update({
|
await update({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
...runningToolUpdate({
|
...runningToolUpdate({
|
||||||
toolCallId: event.data.callID,
|
toolCallId: event.data.callID,
|
||||||
toolName: current.name,
|
toolName: current.name,
|
||||||
state: { input: current.input },
|
state: { input: current.input },
|
||||||
|
content: current.content,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -168,12 +170,11 @@ export async function streamTurn(input: {
|
|||||||
tools.delete(event.data.callID)
|
tools.delete(event.data.callID)
|
||||||
await syncEditedFiles({
|
await syncEditedFiles({
|
||||||
connection: input.connection,
|
connection: input.connection,
|
||||||
writeTextFile: input.writeTextFile,
|
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
toolName: current.name,
|
toolName: current.name,
|
||||||
toolInput: current.input,
|
toolInput: current.input,
|
||||||
metadata: event.data.metadata ?? {},
|
structured: event.data.structured,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
await update({
|
await update({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
@@ -181,8 +182,9 @@ export async function streamTurn(input: {
|
|||||||
toolCallId: event.data.callID,
|
toolCallId: event.data.callID,
|
||||||
toolName: current.name,
|
toolName: current.name,
|
||||||
input: current.input,
|
input: current.input,
|
||||||
metadata: event.data.metadata,
|
structured: event.data.structured,
|
||||||
content: event.data.content,
|
content: event.data.content,
|
||||||
|
result: event.data.result,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
@@ -196,7 +198,7 @@ export async function streamTurn(input: {
|
|||||||
toolCallId: event.data.callID,
|
toolCallId: event.data.callID,
|
||||||
toolName: current.name,
|
toolName: current.name,
|
||||||
input: current.input,
|
input: current.input,
|
||||||
metadata: event.data.metadata ?? current.metadata,
|
structured: event.data.metadata ?? current.structured,
|
||||||
content: event.data.content ?? current.content,
|
content: event.data.content ?? current.content,
|
||||||
error: event.data.error.message,
|
error: event.data.error.message,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
@@ -229,7 +231,7 @@ export async function streamTurn(input: {
|
|||||||
if (!started) {
|
if (!started) {
|
||||||
streamController.abort()
|
streamController.abort()
|
||||||
await completed.catch(() => {})
|
await completed.catch(() => {})
|
||||||
return response(undefined, undefined, "interrupted", true, undefined)
|
return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const terminal = await completed
|
const terminal = await completed
|
||||||
@@ -244,6 +246,7 @@ export async function streamTurn(input: {
|
|||||||
terminal,
|
terminal,
|
||||||
control.cancelled,
|
control.cancelled,
|
||||||
finish,
|
finish,
|
||||||
|
input.userMessageID,
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
streamController.abort()
|
streamController.abort()
|
||||||
@@ -339,8 +342,9 @@ async function replayMessage(
|
|||||||
toolCallId: part.id,
|
toolCallId: part.id,
|
||||||
toolName: part.name,
|
toolName: part.name,
|
||||||
input: part.state.input,
|
input: part.state.input,
|
||||||
metadata: part.state.metadata,
|
structured: part.state.structured,
|
||||||
content: part.state.content,
|
content: part.state.content,
|
||||||
|
result: part.state.result,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -354,6 +358,7 @@ async function replayMessage(
|
|||||||
toolCallId: part.id,
|
toolCallId: part.id,
|
||||||
toolName: part.name,
|
toolName: part.name,
|
||||||
state: { input: part.state.input },
|
state: { input: part.state.input },
|
||||||
|
content: part.state.content,
|
||||||
cwd,
|
cwd,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -368,7 +373,7 @@ async function replayMessage(
|
|||||||
toolCallId: part.id,
|
toolCallId: part.id,
|
||||||
toolName: part.name,
|
toolName: part.name,
|
||||||
input: part.state.input,
|
input: part.state.input,
|
||||||
metadata: part.state.metadata,
|
structured: part.state.structured,
|
||||||
content: part.state.content,
|
content: part.state.content,
|
||||||
error: part.state.error.message,
|
error: part.state.error.message,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -395,6 +400,7 @@ function response(
|
|||||||
terminal: "succeeded" | "failed" | "interrupted",
|
terminal: "succeeded" | "failed" | "interrupted",
|
||||||
cancelled: boolean,
|
cancelled: boolean,
|
||||||
finish: SessionMessageAssistant["finish"],
|
finish: SessionMessageAssistant["finish"],
|
||||||
|
messageID: string | null | undefined,
|
||||||
): PromptResponse {
|
): PromptResponse {
|
||||||
const error = assistant?.error ?? executionError
|
const error = assistant?.error ?? executionError
|
||||||
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
|
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
|
||||||
@@ -417,7 +423,7 @@ function response(
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
|
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
|
||||||
return { stopReason, ...(usage ? { usage } : {}), _meta: {} }
|
return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStopReason(input: {
|
function resolveStopReason(input: {
|
||||||
|
|||||||
@@ -53,16 +53,15 @@ export async function replyPermission(input: {
|
|||||||
|
|
||||||
export async function syncEditedFiles(input: {
|
export async function syncEditedFiles(input: {
|
||||||
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||||
readonly writeTextFile: boolean
|
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly toolName: string
|
readonly toolName: string
|
||||||
readonly toolInput: ToolInput
|
readonly toolInput: ToolInput
|
||||||
readonly metadata: Readonly<Record<string, unknown>>
|
readonly structured: Readonly<Record<string, unknown>>
|
||||||
}) {
|
}) {
|
||||||
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
|
if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
|
||||||
const files = Array.isArray(input.metadata.files)
|
const files = Array.isArray(input.structured.files)
|
||||||
? input.metadata.files.flatMap((file): string[] => {
|
? input.structured.files.flatMap((file): string[] => {
|
||||||
if (!file || typeof file !== "object") return []
|
if (!file || typeof file !== "object") return []
|
||||||
const path = Reflect.get(file, "file")
|
const path = Reflect.get(file, "file")
|
||||||
return typeof path === "string" ? [path] : []
|
return typeof path === "string" ? [path] : []
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import type {
|
|||||||
CancelNotification,
|
CancelNotification,
|
||||||
CloseSessionRequest,
|
CloseSessionRequest,
|
||||||
CloseSessionResponse,
|
CloseSessionResponse,
|
||||||
DeleteSessionRequest,
|
|
||||||
DeleteSessionResponse,
|
|
||||||
ForkSessionRequest,
|
ForkSessionRequest,
|
||||||
ForkSessionResponse,
|
ForkSessionResponse,
|
||||||
InitializeRequest,
|
InitializeRequest,
|
||||||
@@ -35,6 +33,8 @@ import type {
|
|||||||
ResumeSessionResponse,
|
ResumeSessionResponse,
|
||||||
SetSessionConfigOptionRequest,
|
SetSessionConfigOptionRequest,
|
||||||
SetSessionConfigOptionResponse,
|
SetSessionConfigOptionResponse,
|
||||||
|
SetSessionModelRequest,
|
||||||
|
SetSessionModelResponse,
|
||||||
SetSessionModeRequest,
|
SetSessionModeRequest,
|
||||||
SetSessionModeResponse,
|
SetSessionModeResponse,
|
||||||
} from "@agentclientprotocol/sdk"
|
} from "@agentclientprotocol/sdk"
|
||||||
@@ -47,8 +47,7 @@ import { ACPError } from "./error"
|
|||||||
|
|
||||||
export const AuthMethodID = "opencode-login"
|
export const AuthMethodID = "opencode-login"
|
||||||
|
|
||||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
|
||||||
Partial<Pick<AgentSideConnection, "writeTextFile">>
|
|
||||||
|
|
||||||
type Catalog = {
|
type Catalog = {
|
||||||
readonly providers: ConfigOptionProvider[]
|
readonly providers: ConfigOptionProvider[]
|
||||||
@@ -84,12 +83,12 @@ export interface Interface {
|
|||||||
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
|
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
|
||||||
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
|
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
|
||||||
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
|
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
|
||||||
deleteSession(input: DeleteSessionRequest): Promise<DeleteSessionResponse>
|
|
||||||
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
|
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
|
||||||
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
|
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
|
||||||
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
|
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
|
||||||
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
|
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
|
||||||
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
|
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
|
||||||
|
setSessionModel(input: SetSessionModelRequest): Promise<SetSessionModelResponse>
|
||||||
prompt(input: PromptRequest): Promise<PromptResponse>
|
prompt(input: PromptRequest): Promise<PromptResponse>
|
||||||
cancel(input: CancelNotification): Promise<void>
|
cancel(input: CancelNotification): Promise<void>
|
||||||
}
|
}
|
||||||
@@ -99,7 +98,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
const catalogs = new Map<string, Promise<Catalog>>()
|
const catalogs = new Map<string, Promise<Catalog>>()
|
||||||
const registeredMcp = new Map<string, Set<string>>()
|
const registeredMcp = new Map<string, Set<string>>()
|
||||||
const active = new Map<string, TurnControl>()
|
const active = new Map<string, TurnControl>()
|
||||||
const capabilities = { writeTextFile: false }
|
|
||||||
|
|
||||||
const catalog = (cwd: string) => {
|
const catalog = (cwd: string) => {
|
||||||
const cached = catalogs.get(cwd)
|
const cached = catalogs.get(cwd)
|
||||||
@@ -159,7 +157,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
initialize: async (params) => {
|
initialize: async (params) => {
|
||||||
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
|
||||||
const authMethod: AuthMethod = {
|
const authMethod: AuthMethod = {
|
||||||
description: "Run `opencode auth login` in the terminal",
|
description: "Run `opencode auth login` in the terminal",
|
||||||
name: "Login with opencode",
|
name: "Login with opencode",
|
||||||
@@ -176,7 +173,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
loadSession: true,
|
loadSession: true,
|
||||||
mcpCapabilities: { http: true, sse: false },
|
mcpCapabilities: { http: true, sse: false },
|
||||||
promptCapabilities: { embeddedContext: true, image: true },
|
promptCapabilities: { embeddedContext: true, image: true },
|
||||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
|
||||||
},
|
},
|
||||||
authMethods: [authMethod],
|
authMethods: [authMethod],
|
||||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||||
@@ -219,14 +216,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteSession: async (params) => {
|
|
||||||
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
|
||||||
if (!isSessionNotFoundError(error)) throw error
|
|
||||||
})
|
|
||||||
sessions.delete(params.sessionId)
|
|
||||||
registeredMcp.delete(params.sessionId)
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
resumeSession: async (params) => {
|
resumeSession: async (params) => {
|
||||||
const session = await getSession(input.client, params.sessionId)
|
const session = await getSession(input.client, params.sessionId)
|
||||||
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
|
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
|
||||||
@@ -281,6 +270,13 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
|
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
|
setSessionModel: async (params) => {
|
||||||
|
const state = await requireSession(params.sessionId)
|
||||||
|
const selected = requireModel(state.catalog, params.modelId)
|
||||||
|
state.model = selected
|
||||||
|
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||||
|
return {}
|
||||||
|
},
|
||||||
prompt: async (params) => {
|
prompt: async (params) => {
|
||||||
const state = await requireSession(params.sessionId)
|
const state = await requireSession(params.sessionId)
|
||||||
if (active.has(state.id)) {
|
if (active.has(state.id)) {
|
||||||
@@ -299,7 +295,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
sessionID: state.id,
|
sessionID: state.id,
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
start: prepared.start,
|
start: prepared.start,
|
||||||
writeTextFile: capabilities.writeTextFile,
|
userMessageID: params.messageId,
|
||||||
control,
|
control,
|
||||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@@ -483,7 +479,6 @@ async function registerMcpServers(
|
|||||||
|
|
||||||
function mcpConfig(server: McpServer) {
|
function mcpConfig(server: McpServer) {
|
||||||
if ("type" in server) {
|
if ("type" in server) {
|
||||||
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
|
|
||||||
return {
|
return {
|
||||||
type: "remote" as const,
|
type: "remote" as const,
|
||||||
url: server.url,
|
url: server.url,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { isAbsolute, resolve } from "node:path"
|
import { isAbsolute, resolve } from "node:path"
|
||||||
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
|
||||||
import { readDisplayText } from "@opencode-ai/tui/mini/tool"
|
|
||||||
|
|
||||||
export type ToolInput = Record<string, unknown>
|
export type ToolInput = Record<string, unknown>
|
||||||
export type ToolContent = ReadonlyArray<
|
export type ToolContent = ReadonlyArray<
|
||||||
@@ -101,12 +100,11 @@ export function completedToolUpdate(input: {
|
|||||||
readonly toolName: string
|
readonly toolName: string
|
||||||
readonly input: ToolInput
|
readonly input: ToolInput
|
||||||
readonly content: ToolContent
|
readonly content: ToolContent
|
||||||
readonly metadata?: Readonly<Record<string, unknown>>
|
readonly structured: Readonly<Record<string, unknown>>
|
||||||
|
readonly result?: unknown
|
||||||
}): ToolCallUpdate {
|
}): ToolCallUpdate {
|
||||||
const normalized = toolContent(input.content)
|
const normalized = toolContent(input.content)
|
||||||
// Read's model content is a JSON page envelope; show the clean text instead.
|
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
|
||||||
const firstText = input.content.find((part) => part.type === "text")
|
|
||||||
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
|
|
||||||
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
|
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
|
||||||
const primary =
|
const primary =
|
||||||
read === undefined
|
read === undefined
|
||||||
@@ -130,7 +128,8 @@ export function completedToolUpdate(input: {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
content: [...primary, ...diff, ...images],
|
content: [...primary, ...diff, ...images],
|
||||||
rawOutput: {
|
rawOutput: {
|
||||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
structured: input.structured,
|
||||||
|
...(input.result === undefined ? {} : { result: input.result }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,8 +138,8 @@ export function errorToolUpdate(input: {
|
|||||||
readonly toolCallId: string
|
readonly toolCallId: string
|
||||||
readonly toolName: string
|
readonly toolName: string
|
||||||
readonly input: ToolInput
|
readonly input: ToolInput
|
||||||
readonly content?: ToolContent
|
readonly content: ToolContent
|
||||||
readonly metadata?: Readonly<Record<string, unknown>>
|
readonly structured: Readonly<Record<string, unknown>>
|
||||||
readonly error: string
|
readonly error: string
|
||||||
readonly cwd?: string
|
readonly cwd?: string
|
||||||
}): ToolCallUpdate {
|
}): ToolCallUpdate {
|
||||||
@@ -151,11 +150,8 @@ export function errorToolUpdate(input: {
|
|||||||
title: toolTitle(input.toolName, input.input, undefined),
|
title: toolTitle(input.toolName, input.input, undefined),
|
||||||
locations: toLocations(input.toolName, input.input, input.cwd),
|
locations: toLocations(input.toolName, input.input, input.cwd),
|
||||||
rawInput: rawInput(input.toolName, input.input, input.cwd),
|
rawInput: rawInput(input.toolName, input.input, input.cwd),
|
||||||
content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }],
|
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
|
||||||
rawOutput: {
|
rawOutput: { structured: input.structured, error: input.error },
|
||||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
|
||||||
error: input.error,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +164,21 @@ function toolContent(content: ToolContent): ToolCallContent[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
|
||||||
|
if (typeof structured.content === "string") {
|
||||||
|
if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
|
||||||
|
}
|
||||||
|
if (!Array.isArray(structured.entries)) return undefined
|
||||||
|
return structured.entries
|
||||||
|
.flatMap((entry): string[] => {
|
||||||
|
if (typeof entry === "string") return [entry]
|
||||||
|
if (!entry || typeof entry !== "object") return []
|
||||||
|
const path = Reflect.get(entry, "path")
|
||||||
|
return typeof path === "string" ? [path] : []
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
|
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
|
||||||
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
|
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
|
||||||
return fallback || toolName
|
return fallback || toolName
|
||||||
|
|||||||
@@ -140,11 +140,11 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
Flag.withDefault(false),
|
Flag.withDefault(false),
|
||||||
),
|
),
|
||||||
replay: Flag.boolean("replay").pipe(
|
replay: Flag.boolean("replay").pipe(
|
||||||
Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"),
|
Flag.withDescription("Replay session history on resume and after resize"),
|
||||||
Flag.optional,
|
Flag.withDefault(true),
|
||||||
),
|
),
|
||||||
replayLimit: Flag.integer("replay-limit").pipe(
|
replayLimit: Flag.integer("replay-limit").pipe(
|
||||||
Flag.withDescription("Limit replay to the newest N messages (default: 200)"),
|
Flag.withDescription("Cap visible replay to the newest N messages"),
|
||||||
Flag.optional,
|
Flag.optional,
|
||||||
),
|
),
|
||||||
model: Flag.string("model").pipe(
|
model: Flag.string("model").pipe(
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
|||||||
model: Option.getOrUndefined(input.model),
|
model: Option.getOrUndefined(input.model),
|
||||||
agent: Option.getOrUndefined(input.agent),
|
agent: Option.getOrUndefined(input.agent),
|
||||||
prompt: Option.getOrUndefined(input.prompt),
|
prompt: Option.getOrUndefined(input.prompt),
|
||||||
replay: Option.getOrUndefined(input.replay) ?? resolved.mini?.replay ?? true,
|
replay: input.replay,
|
||||||
replayLimit: Option.getOrUndefined(input.replayLimit) ?? resolved.mini?.replay_limit,
|
replayLimit: Option.getOrUndefined(input.replayLimit),
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
tuiConfig: resolved,
|
tuiConfig: resolved,
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Reference,
|
Reference,
|
||||||
Skill,
|
Skill,
|
||||||
} from "@opencode-ai/plugin/v2"
|
} from "@opencode-ai/plugin/v2"
|
||||||
import { Tool } from "@opencode-ai/plugin/v2/tool"
|
|
||||||
|
|
||||||
const key = Symbol.for("opencode.plugin.v2.promise")
|
const key = Symbol.for("opencode.plugin.v2.promise")
|
||||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||||
@@ -24,5 +23,4 @@ const key = Symbol.for("opencode.plugin.v2.promise")
|
|||||||
Provider,
|
Provider,
|
||||||
Reference,
|
Reference,
|
||||||
Skill,
|
Skill,
|
||||||
Tool,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import { nonEmptyToolContent, toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
|
import { toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
|
||||||
import { UI } from "./ui"
|
import { UI } from "./ui"
|
||||||
|
|
||||||
type Model = {
|
type Model = {
|
||||||
@@ -55,7 +55,7 @@ type ToolState = StartedPart & {
|
|||||||
raw?: string
|
raw?: string
|
||||||
provider?: unknown
|
provider?: unknown
|
||||||
providerState?: SessionMessageAssistantTool["providerState"]
|
providerState?: SessionMessageAssistantTool["providerState"]
|
||||||
metadata: Record<string, JsonValue>
|
structured: Record<string, JsonValue>
|
||||||
content: LLMToolContent[]
|
content: LLMToolContent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +306,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
assistantMessageID: event.data.assistantMessageID,
|
assistantMessageID: event.data.assistantMessageID,
|
||||||
tool: event.data.name,
|
tool: event.data.name,
|
||||||
input: {},
|
input: {},
|
||||||
metadata: {},
|
structured: {},
|
||||||
content: [],
|
content: [],
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
@@ -334,7 +334,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
raw: current?.raw,
|
raw: current?.raw,
|
||||||
provider: { executed: event.data.executed, state: event.data.state },
|
provider: { executed: event.data.executed, state: event.data.state },
|
||||||
providerState: event.data.state,
|
providerState: event.data.state,
|
||||||
metadata: {},
|
structured: {},
|
||||||
content: [],
|
content: [],
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
@@ -342,7 +342,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
if (event.type === "session.tool.progress") {
|
if (event.type === "session.tool.progress") {
|
||||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||||
if (current) {
|
if (current) {
|
||||||
current.metadata = event.data.metadata
|
current.structured = event.data.structured
|
||||||
|
current.content = event.data.content
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -359,8 +360,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: current.input,
|
input: current.input,
|
||||||
metadata: event.data.metadata,
|
structured: event.data.structured,
|
||||||
content: event.data.content,
|
content: event.data.content,
|
||||||
|
result: event.data.result,
|
||||||
},
|
},
|
||||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||||
}
|
}
|
||||||
@@ -377,8 +379,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
output: toolOutputText(current.tool, event.data.content),
|
output: toolOutputText(current.tool, event.data.content),
|
||||||
title: current.tool,
|
title: current.tool,
|
||||||
metadata: {
|
metadata: {
|
||||||
metadata: event.data.metadata,
|
structured: event.data.structured,
|
||||||
content: event.data.content,
|
content: event.data.content,
|
||||||
|
result: event.data.result,
|
||||||
providerCall: current.provider,
|
providerCall: current.provider,
|
||||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||||
rawInput: current.raw,
|
rawInput: current.raw,
|
||||||
@@ -395,8 +398,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||||
const current = tools.get(key) ?? fallbackTool(event)
|
const current = tools.get(key) ?? fallbackTool(event)
|
||||||
const error = event.data.error.message
|
const error = event.data.error.message
|
||||||
const metadata = event.data.metadata ?? current.metadata
|
const structured = event.data.metadata ?? current.structured
|
||||||
const content = event.data.content ?? nonEmptyToolContent(current.content)
|
const content = event.data.content ?? current.content
|
||||||
const tool: SessionMessageAssistantTool = {
|
const tool: SessionMessageAssistantTool = {
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: event.data.callID,
|
id: event.data.callID,
|
||||||
@@ -407,9 +410,10 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
state: {
|
state: {
|
||||||
status: "error",
|
status: "error",
|
||||||
input: current.input,
|
input: current.input,
|
||||||
metadata,
|
structured,
|
||||||
content,
|
content,
|
||||||
error: event.data.error,
|
error: event.data.error,
|
||||||
|
result: event.data.result,
|
||||||
},
|
},
|
||||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||||
}
|
}
|
||||||
@@ -425,6 +429,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
input: current.input,
|
input: current.input,
|
||||||
error,
|
error,
|
||||||
metadata: {
|
metadata: {
|
||||||
|
result: event.data.result,
|
||||||
providerCall: current.provider,
|
providerCall: current.provider,
|
||||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||||
rawInput: current.raw,
|
rawInput: current.raw,
|
||||||
@@ -436,14 +441,15 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
renderedTools.add(key)
|
renderedTools.add(key)
|
||||||
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
||||||
if (!emit("tool_use", time, { part })) {
|
if (!emit("tool_use", time, { part })) {
|
||||||
if (content && toolOutputText(current.tool, content).trim())
|
if (toolOutputText(current.tool, content).trim())
|
||||||
await input.renderTool({
|
await input.renderTool({
|
||||||
...tool,
|
...tool,
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: current.input,
|
input: current.input,
|
||||||
metadata,
|
structured,
|
||||||
content,
|
content,
|
||||||
|
result: event.data.result,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await input.renderToolError(tool)
|
await input.renderToolError(tool)
|
||||||
@@ -591,14 +597,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
input: item.state.input,
|
input: item.state.input,
|
||||||
output: toolOutputText(item.name, item.state.content),
|
output: toolOutputText(item.name, item.state.content),
|
||||||
title: item.name,
|
title: item.name,
|
||||||
metadata: { metadata: item.state.metadata, content: item.state.content },
|
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
|
||||||
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
status: "error",
|
status: "error",
|
||||||
input: item.state.input,
|
input: item.state.input,
|
||||||
error: item.state.error.message,
|
error: item.state.error.message,
|
||||||
metadata: { metadata: item.state.metadata, content: item.state.content },
|
metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result },
|
||||||
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -608,16 +614,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
await input.renderTool(item)
|
await input.renderTool(item)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (item.state.content && toolOutputText(item.name, item.state.content).trim()) {
|
if (toolOutputText(item.name, item.state.content).trim()) {
|
||||||
await input.renderTool({
|
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
|
||||||
...item,
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: item.state.input,
|
|
||||||
metadata: item.state.metadata,
|
|
||||||
content: item.state.content,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
await input.renderToolError(item)
|
await input.renderToolError(item)
|
||||||
UI.error(item.state.error.message)
|
UI.error(item.state.error.message)
|
||||||
@@ -794,7 +792,7 @@ function fallbackTool(event: {
|
|||||||
assistantMessageID: event.data.assistantMessageID,
|
assistantMessageID: event.data.assistantMessageID,
|
||||||
tool: "tool",
|
tool: "tool",
|
||||||
input: {},
|
input: {},
|
||||||
metadata: {},
|
structured: {},
|
||||||
content: [],
|
content: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,7 +218,8 @@ describe("acp event behavior", () => {
|
|||||||
sessionID: "ses_tools",
|
sessionID: "ses_tools",
|
||||||
assistantMessageID: "msg_tools",
|
assistantMessageID: "msg_tools",
|
||||||
callID: "call_ok",
|
callID: "call_ok",
|
||||||
metadata: { phase: 1 },
|
structured: { phase: 1 },
|
||||||
|
content: [{ type: "text", text: "working" }],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
send(
|
send(
|
||||||
@@ -226,8 +227,9 @@ describe("acp event behavior", () => {
|
|||||||
sessionID: "ses_tools",
|
sessionID: "ses_tools",
|
||||||
assistantMessageID: "msg_tools",
|
assistantMessageID: "msg_tools",
|
||||||
callID: "call_ok",
|
callID: "call_ok",
|
||||||
metadata: { exit: 0 },
|
structured: { exit: 0 },
|
||||||
content: [{ type: "text", text: "done" }],
|
content: [{ type: "text", text: "done" }],
|
||||||
|
result: { code: 0 },
|
||||||
executed: true,
|
executed: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -253,7 +255,8 @@ describe("acp event behavior", () => {
|
|||||||
sessionID: "ses_tools",
|
sessionID: "ses_tools",
|
||||||
assistantMessageID: "msg_tools",
|
assistantMessageID: "msg_tools",
|
||||||
callID: "call_fail",
|
callID: "call_fail",
|
||||||
metadata: { bytes: 0 },
|
structured: { bytes: 0 },
|
||||||
|
content: [{ type: "text", text: "opening" }],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
send(
|
send(
|
||||||
@@ -310,10 +313,12 @@ describe("acp event behavior", () => {
|
|||||||
locations: [{ path: resolve("/workspace", "sub") }],
|
locations: [{ path: resolve("/workspace", "sub") }],
|
||||||
rawInput: { command: "printf done", workdir: "sub" },
|
rawInput: { command: "printf done", workdir: "sub" },
|
||||||
})
|
})
|
||||||
expect(updates[2]?.update).not.toHaveProperty("content")
|
expect(updates[2]?.update).toMatchObject({
|
||||||
|
content: [{ type: "content", content: { type: "text", text: "working" } }],
|
||||||
|
})
|
||||||
expect(updates[3]?.update).toMatchObject({
|
expect(updates[3]?.update).toMatchObject({
|
||||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||||
rawOutput: { metadata: { exit: 0 } },
|
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
|
||||||
})
|
})
|
||||||
expect(updates[7]?.update).toMatchObject({
|
expect(updates[7]?.update).toMatchObject({
|
||||||
kind: "read",
|
kind: "read",
|
||||||
@@ -322,7 +327,7 @@ describe("acp event behavior", () => {
|
|||||||
{ type: "content", content: { type: "text", text: "opening" } },
|
{ type: "content", content: { type: "text", text: "opening" } },
|
||||||
{ type: "content", content: { type: "text", text: "not found" } },
|
{ type: "content", content: { type: "text", text: "not found" } },
|
||||||
],
|
],
|
||||||
rawOutput: { metadata: { bytes: 0 }, error: "not found" },
|
rawOutput: { structured: { bytes: 0 }, error: "not found" },
|
||||||
})
|
})
|
||||||
expect(response.stopReason).toBe("end_turn")
|
expect(response.stopReason).toBe("end_turn")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -374,7 +379,7 @@ describe("acp event behavior", () => {
|
|||||||
{ type: "content", content: { type: "text", text: "done" } },
|
{ type: "content", content: { type: "text", text: "done" } },
|
||||||
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
|
{ type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
|
||||||
],
|
],
|
||||||
rawOutput: { metadata: { exit: 0 } },
|
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
|
||||||
})
|
})
|
||||||
expect(updates[8]?.update).toMatchObject({
|
expect(updates[8]?.update).toMatchObject({
|
||||||
toolCallId: "call_running",
|
toolCallId: "call_running",
|
||||||
@@ -434,7 +439,6 @@ describe("acp event behavior", () => {
|
|||||||
sessionID: "ses_cancel",
|
sessionID: "ses_cancel",
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
start: { type: "input", id: "input_cancel" },
|
start: { type: "input", id: "input_cancel" },
|
||||||
writeTextFile: false,
|
|
||||||
control,
|
control,
|
||||||
submit: async (signal) => {
|
submit: async (signal) => {
|
||||||
await fixture.client.session.prompt(
|
await fixture.client.session.prompt(
|
||||||
@@ -477,7 +481,6 @@ describe("acp event behavior", () => {
|
|||||||
sessionID: "ses_cancel_admission",
|
sessionID: "ses_cancel_admission",
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
start: { type: "input", id: "input_cancel_admission" },
|
start: { type: "input", id: "input_cancel_admission" },
|
||||||
writeTextFile: false,
|
|
||||||
control,
|
control,
|
||||||
submit: (signal) =>
|
submit: (signal) =>
|
||||||
fixture.client.session.prompt(
|
fixture.client.session.prompt(
|
||||||
@@ -563,7 +566,7 @@ function turn(input: {
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
start: { type: "input", id: input.inputID },
|
start: { type: "input", id: input.inputID },
|
||||||
writeTextFile: false,
|
userMessageID: `client_${input.inputID}`,
|
||||||
control: { cancelled: false, admission: new AbortController() },
|
control: { cancelled: false, admission: new AbortController() },
|
||||||
submit: (signal) =>
|
submit: (signal) =>
|
||||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
||||||
@@ -613,11 +616,12 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
|||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: { command: "printf done" },
|
input: { command: "printf done" },
|
||||||
metadata: { exit: 0 },
|
structured: { exit: 0 },
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: "done" },
|
{ type: "text", text: "done" },
|
||||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
|
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
|
||||||
],
|
],
|
||||||
|
result: { code: 0 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -628,7 +632,8 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
|||||||
state: {
|
state: {
|
||||||
status: "running",
|
status: "running",
|
||||||
input: { command: "pwd" },
|
input: { command: "pwd" },
|
||||||
metadata: {},
|
structured: {},
|
||||||
|
content: [{ type: "text", text: "/workspace" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -639,7 +644,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
|||||||
state: {
|
state: {
|
||||||
status: "error",
|
status: "error",
|
||||||
input: { filePath: "/workspace/missing.ts" },
|
input: { filePath: "/workspace/missing.ts" },
|
||||||
metadata: { bytes: 0 },
|
structured: { bytes: 0 },
|
||||||
content: [{ type: "text", text: "partial" }],
|
content: [{ type: "text", text: "partial" }],
|
||||||
error: { type: "tool.error", message: "failed hard" },
|
error: { type: "tool.error", message: "failed hard" },
|
||||||
},
|
},
|
||||||
@@ -672,7 +677,7 @@ function replayToolMessage(id: string) {
|
|||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: { command: "printf done" },
|
input: { command: "printf done" },
|
||||||
metadata: { exit: 0 },
|
structured: { exit: 0 },
|
||||||
content: [{ type: "text", text: "done" }],
|
content: [{ type: "text", text: "done" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const id = "msg_prompt"
|
const id = "msg_prompt"
|
||||||
|
const userMessageID = "client-message"
|
||||||
const response = await streamTurn({
|
const response = await streamTurn({
|
||||||
client,
|
client,
|
||||||
connection: {
|
connection: {
|
||||||
@@ -96,7 +97,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
|||||||
sessionID: "ses_test",
|
sessionID: "ses_test",
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
start: { type: "input", id },
|
start: { type: "input", id },
|
||||||
writeTextFile: false,
|
userMessageID,
|
||||||
control: { cancelled: false, admission: new AbortController() },
|
control: { cancelled: false, admission: new AbortController() },
|
||||||
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
|
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
|
||||||
})
|
})
|
||||||
@@ -111,7 +112,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
|
expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } })
|
||||||
} finally {
|
} finally {
|
||||||
events?.close()
|
events?.close()
|
||||||
await server.stop(true)
|
await server.stop(true)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ describe("acp initialize/auth subprocess", () => {
|
|||||||
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
|
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
|
||||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||||
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
|
|
||||||
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
|
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
|
||||||
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
CloseSessionResponse,
|
CloseSessionResponse,
|
||||||
DeleteSessionResponse,
|
|
||||||
ListSessionsResponse,
|
ListSessionsResponse,
|
||||||
LoadSessionResponse,
|
LoadSessionResponse,
|
||||||
ResumeSessionResponse,
|
ResumeSessionResponse,
|
||||||
@@ -61,20 +60,6 @@ describe("acp lifecycle subprocess", () => {
|
|||||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
|
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
|
|
||||||
test("delete capability and delete request", async () => {
|
|
||||||
await using fixture = await createAcpFixture()
|
|
||||||
const acp = fixture.spawn()
|
|
||||||
const initialized = await initialize(acp)
|
|
||||||
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
|
|
||||||
const session = await newSession(acp, fixture.home)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
expectOk(await acp.request<DeleteSessionResponse>("session/delete", { sessionId: session.sessionId })),
|
|
||||||
).toEqual({})
|
|
||||||
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
|
|
||||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
|
|
||||||
}, 60_000)
|
|
||||||
|
|
||||||
test("resume capability advertisement", async () => {
|
test("resume capability advertisement", async () => {
|
||||||
await using fixture = await createAcpFixture()
|
await using fixture = await createAcpFixture()
|
||||||
const initialized = await initialize(fixture.spawn())
|
const initialized = await initialize(fixture.spawn())
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import fs from "node:fs/promises"
|
|||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { streamTurn } from "../../src/acp/event"
|
import { streamTurn } from "../../src/acp/event"
|
||||||
import { syncEditedFiles } from "../../src/acp/permission"
|
|
||||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||||
|
|
||||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||||
@@ -13,27 +12,6 @@ type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission
|
|||||||
type Fixture = ReturnType<typeof createSseFixture>
|
type Fixture = ReturnType<typeof createSseFixture>
|
||||||
|
|
||||||
describe("acp permission behavior", () => {
|
describe("acp permission behavior", () => {
|
||||||
test("does not sync edits when writeTextFile was not advertised", async () => {
|
|
||||||
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
|
|
||||||
|
|
||||||
await syncEditedFiles({
|
|
||||||
connection: {
|
|
||||||
writeTextFile: async (input) => {
|
|
||||||
writes.push(input)
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
writeTextFile: false,
|
|
||||||
sessionID: "ses_no_write",
|
|
||||||
cwd: "/workspace",
|
|
||||||
toolName: "edit",
|
|
||||||
toolInput: { filePath: "/workspace/file.ts" },
|
|
||||||
metadata: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(writes).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("forwards allow-once and allow-always selections to the generated client", async () => {
|
test("forwards allow-once and allow-always selections to the generated client", async () => {
|
||||||
const permissionRequests: RequestPermissionRequest[] = []
|
const permissionRequests: RequestPermissionRequest[] = []
|
||||||
const fixture = createSseFixture({
|
const fixture = createSseFixture({
|
||||||
@@ -193,7 +171,7 @@ describe("acp permission behavior", () => {
|
|||||||
sessionID: "ses_edit",
|
sessionID: "ses_edit",
|
||||||
assistantMessageID: "msg_edit",
|
assistantMessageID: "msg_edit",
|
||||||
callID: "call_edit",
|
callID: "call_edit",
|
||||||
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
|
structured: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||||
content: [{ type: "text", text: "edited" }],
|
content: [{ type: "text", text: "edited" }],
|
||||||
executed: true,
|
executed: true,
|
||||||
}),
|
}),
|
||||||
@@ -286,7 +264,7 @@ describe("acp permission behavior", () => {
|
|||||||
sessionID: "ses_patch",
|
sessionID: "ses_patch",
|
||||||
assistantMessageID: "msg_patch",
|
assistantMessageID: "msg_patch",
|
||||||
callID: "call_patch",
|
callID: "call_patch",
|
||||||
metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
|
structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] },
|
||||||
content: [{ type: "text", text: "patched" }],
|
content: [{ type: "text", text: "patched" }],
|
||||||
executed: true,
|
executed: true,
|
||||||
}),
|
}),
|
||||||
@@ -487,7 +465,6 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
|
|||||||
sessionID,
|
sessionID,
|
||||||
cwd,
|
cwd,
|
||||||
start: { type: "input", id: inputID },
|
start: { type: "input", id: inputID },
|
||||||
writeTextFile: true,
|
|
||||||
control: { cancelled: false, admission: new AbortController() },
|
control: { cancelled: false, admission: new AbortController() },
|
||||||
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
|
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
|
||||||
|
|
||||||
describe("acp service directory behavior", () => {
|
describe("acp service directory behavior", () => {
|
||||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||||
@@ -134,6 +134,7 @@ describe("acp service directory behavior", () => {
|
|||||||
configId: "mode",
|
configId: "mode",
|
||||||
value: "plan",
|
value: "plan",
|
||||||
})
|
})
|
||||||
|
await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" })
|
||||||
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
||||||
|
|
||||||
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
||||||
@@ -147,6 +148,7 @@ describe("acp service directory behavior", () => {
|
|||||||
).toEqual([
|
).toEqual([
|
||||||
{ model: { providerID: "test", id: secondModel.id } },
|
{ model: { providerID: "test", id: secondModel.id } },
|
||||||
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
|
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
|
||||||
|
{ model: { providerID: "test", id: testModel.id, variant: "high" } },
|
||||||
])
|
])
|
||||||
expect(
|
expect(
|
||||||
fixture.requests
|
fixture.requests
|
||||||
|
|||||||
@@ -225,33 +225,6 @@ describe("acp service lifecycle", () => {
|
|||||||
"/api/session/missing/interrupt",
|
"/api/session/missing/interrupt",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("deletes sessions from backing and local storage", async () => {
|
|
||||||
await using fixture = makeACPFixture({
|
|
||||||
fetch(request) {
|
|
||||||
if (request.method === "POST" && request.path === "/api/session") {
|
|
||||||
return Response.json({ data: makeSession("ses_delete") })
|
|
||||||
}
|
|
||||||
if (request.method === "DELETE" && request.path === "/api/session/ses_delete") {
|
|
||||||
return new Response(null, { status: 204 })
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
|
||||||
|
|
||||||
expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({})
|
|
||||||
expect(fixture.requests).toContainEqual({
|
|
||||||
method: "DELETE",
|
|
||||||
path: "/api/session/ses_delete",
|
|
||||||
query: {},
|
|
||||||
body: undefined,
|
|
||||||
})
|
|
||||||
const missing = await fixture.service
|
|
||||||
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
|
|
||||||
.catch((error: unknown) => error)
|
|
||||||
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
|
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
|
||||||
|
|||||||
@@ -45,14 +45,17 @@ describe("acp service prompt routing and usage", () => {
|
|||||||
|
|
||||||
const commandResult = await fixture.service.prompt({
|
const commandResult = await fixture.service.prompt({
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
|
messageId: "client-command",
|
||||||
prompt: [{ type: "text", text: "/review now" }],
|
prompt: [{ type: "text", text: "/review now" }],
|
||||||
})
|
})
|
||||||
const skillResult = await fixture.service.prompt({
|
const skillResult = await fixture.service.prompt({
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
|
messageId: "client-skill",
|
||||||
prompt: [{ type: "text", text: "/verify" }],
|
prompt: [{ type: "text", text: "/verify" }],
|
||||||
})
|
})
|
||||||
const compactResult = await fixture.service.prompt({
|
const compactResult = await fixture.service.prompt({
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
|
messageId: "client-compact",
|
||||||
prompt: [{ type: "text", text: "/compact" }],
|
prompt: [{ type: "text", text: "/compact" }],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -151,11 +154,13 @@ describe("acp service prompt routing and usage", () => {
|
|||||||
|
|
||||||
const response = await fixture.service.prompt({
|
const response = await fixture.service.prompt({
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
|
messageId: "client-message",
|
||||||
prompt: [{ type: "text", text: "hello" }],
|
prompt: [{ type: "text", text: "hello" }],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response).toEqual({
|
expect(response).toEqual({
|
||||||
stopReason: "end_turn",
|
stopReason: "end_turn",
|
||||||
|
userMessageId: "client-message",
|
||||||
usage: {
|
usage: {
|
||||||
inputTokens: 100,
|
inputTokens: 100,
|
||||||
outputTokens: 40,
|
outputTokens: 40,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("acp tools", () => {
|
|||||||
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
|
{ type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` },
|
||||||
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
|
{ type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
|
||||||
],
|
],
|
||||||
metadata: {},
|
structured: {},
|
||||||
}).content,
|
}).content,
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
@@ -93,7 +93,7 @@ describe("acp tools", () => {
|
|||||||
content: "created",
|
content: "created",
|
||||||
},
|
},
|
||||||
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
|
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
|
||||||
metadata: {},
|
structured: {},
|
||||||
}).content,
|
}).content,
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
@@ -103,22 +103,20 @@ describe("acp tools", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("unwraps read's JSON page envelope instead of showing model-facing formatting", () => {
|
test("uses clean structured read content instead of model-facing formatting", () => {
|
||||||
expect(
|
expect(
|
||||||
completedToolUpdate({
|
completedToolUpdate({
|
||||||
toolCallId: "tool-read",
|
toolCallId: "tool-read",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
input: { path: "/tmp/file.ts" },
|
input: { path: "/tmp/file.ts" },
|
||||||
content: [
|
content: [{ type: "text", text: "<content>1: first\n2: second</content>" }],
|
||||||
{
|
structured: {
|
||||||
type: "text",
|
type: "text-page",
|
||||||
text: JSON.stringify(
|
content: "first\nsecond",
|
||||||
{ type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false },
|
mime: "text/plain",
|
||||||
null,
|
offset: 1,
|
||||||
2,
|
truncated: false,
|
||||||
),
|
},
|
||||||
},
|
|
||||||
],
|
|
||||||
}).content,
|
}).content,
|
||||||
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
|
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
|
||||||
|
|
||||||
@@ -127,17 +125,13 @@ describe("acp tools", () => {
|
|||||||
toolCallId: "tool-list",
|
toolCallId: "tool-list",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
input: { path: "/tmp" },
|
input: { path: "/tmp" },
|
||||||
content: [
|
content: [],
|
||||||
{
|
structured: {
|
||||||
type: "text",
|
entries: [
|
||||||
text: JSON.stringify({
|
{ path: "a.ts", type: "file" },
|
||||||
entries: [
|
{ path: "src", type: "directory" },
|
||||||
{ path: "a.ts", type: "file" },
|
],
|
||||||
{ path: "src", type: "directory" },
|
},
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).content,
|
}).content,
|
||||||
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
|
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
|
||||||
})
|
})
|
||||||
@@ -177,7 +171,7 @@ describe("acp tools", () => {
|
|||||||
newString: "after",
|
newString: "after",
|
||||||
},
|
},
|
||||||
content: [{ type: "text", text: "Edit applied successfully." }],
|
content: [{ type: "text", text: "Edit applied successfully." }],
|
||||||
metadata: { output: "Edit applied successfully." },
|
structured: { output: "Edit applied successfully." },
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
toolCallId: "tool-1",
|
toolCallId: "tool-1",
|
||||||
@@ -195,7 +189,7 @@ describe("acp tools", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
rawOutput: {
|
rawOutput: {
|
||||||
metadata: { output: "Edit applied successfully." },
|
structured: { output: "Edit applied successfully." },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -215,7 +209,7 @@ describe("acp tools", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("builds completed raw output with optional metadata", () => {
|
test("builds completed raw output with structured data and optional result", () => {
|
||||||
const attachments = [
|
const attachments = [
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
@@ -231,10 +225,12 @@ describe("acp tools", () => {
|
|||||||
toolName: "read",
|
toolName: "read",
|
||||||
input: {},
|
input: {},
|
||||||
content: [],
|
content: [],
|
||||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||||
|
result: "done",
|
||||||
}).rawOutput,
|
}).rawOutput,
|
||||||
).toEqual({
|
).toEqual({
|
||||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||||
|
result: "done",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -243,8 +239,9 @@ describe("acp tools", () => {
|
|||||||
toolName: "read",
|
toolName: "read",
|
||||||
input: {},
|
input: {},
|
||||||
content: [],
|
content: [],
|
||||||
|
structured: { output: "done" },
|
||||||
}).rawOutput,
|
}).rawOutput,
|
||||||
).toEqual({})
|
).toEqual({ structured: { output: "done" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("extracts image attachments only from data URLs", () => {
|
test("extracts image attachments only from data URLs", () => {
|
||||||
@@ -258,7 +255,7 @@ describe("acp tools", () => {
|
|||||||
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
|
{ type: "file", mime: "image/png", uri: "https://example.com/image.png" },
|
||||||
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
|
{ type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" },
|
||||||
],
|
],
|
||||||
metadata: {},
|
structured: {},
|
||||||
}).content,
|
}).content,
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
@@ -275,7 +272,7 @@ describe("acp tools", () => {
|
|||||||
toolName: "read",
|
toolName: "read",
|
||||||
input: { filePath: "/tmp/a" },
|
input: { filePath: "/tmp/a" },
|
||||||
content: [{ type: "text", text: "partial output" }],
|
content: [{ type: "text", text: "partial output" }],
|
||||||
metadata: { path: "/tmp/a" },
|
structured: { path: "/tmp/a" },
|
||||||
error: "failed",
|
error: "failed",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
@@ -289,7 +286,7 @@ describe("acp tools", () => {
|
|||||||
{ type: "content", content: { type: "text", text: "partial output" } },
|
{ type: "content", content: { type: "text", text: "partial output" } },
|
||||||
{ type: "content", content: { type: "text", text: "failed" } },
|
{ type: "content", content: { type: "text", text: "failed" } },
|
||||||
],
|
],
|
||||||
rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" },
|
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,12 +23,7 @@ describe("CLI frontend import boundaries", () => {
|
|||||||
|
|
||||||
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||||
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
||||||
expect(Object.keys(tool).sort()).toEqual([
|
expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"])
|
||||||
"nonEmptyToolContent",
|
|
||||||
"readDisplayText",
|
|
||||||
"toolInlineInfo",
|
|
||||||
"toolOutputText",
|
|
||||||
])
|
|
||||||
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -214,15 +214,11 @@ describe("mini command", () => {
|
|||||||
expect(result.exitCode).toBe(0)
|
expect(result.exitCode).toBe(0)
|
||||||
expect(result.stdout).toContain("--server string")
|
expect(result.stdout).toContain("--server string")
|
||||||
expect(result.stdout).toContain("--prompt string")
|
expect(result.stdout).toContain("--prompt string")
|
||||||
expect(result.stdout).toContain("--replay")
|
|
||||||
expect(result.stdout).toContain("disable with --no-replay")
|
|
||||||
expect(result.stdout).toContain("--replay-limit integer")
|
|
||||||
expect(result.stdout).toContain("Limit replay to the newest N messages (default: 200)")
|
|
||||||
expect(result.stdout).not.toContain("SUBCOMMANDS")
|
expect(result.stdout).not.toContain("SUBCOMMANDS")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("routes local and explicit-server invocations into mini", async () => {
|
test("routes local and explicit-server invocations into mini", async () => {
|
||||||
for (const args of [["mini"], ["mini", "--no-replay"], ["mini", "--server", "http://127.0.0.1:1"]]) {
|
for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) {
|
||||||
const result = await cli(args)
|
const result = await cli(args)
|
||||||
|
|
||||||
expect(result.exitCode).toBe(1)
|
expect(result.exitCode).toBe(1)
|
||||||
|
|||||||
@@ -134,14 +134,15 @@ function failedTool(inputID: string): V2Event[] {
|
|||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
assistantMessageID: "msg_failed_tool",
|
assistantMessageID: "msg_failed_tool",
|
||||||
callID: "call_failed_tool",
|
callID: "call_failed_tool",
|
||||||
metadata: { checkpoint: 1 },
|
structured: { checkpoint: 1 },
|
||||||
|
content: [{ type: "text", text: "partial output" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "evt_failed_tool_terminal",
|
id: "evt_failed_tool_terminal",
|
||||||
created: 4,
|
created: 4,
|
||||||
type: "session.tool.failed",
|
type: "session.tool.failed",
|
||||||
durable: { aggregateID: "ses_1", seq: 4, version: 2 },
|
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
assistantMessageID: "msg_failed_tool",
|
assistantMessageID: "msg_failed_tool",
|
||||||
@@ -189,12 +190,12 @@ function successfulGrep(inputID: string): V2Event[] {
|
|||||||
id: "evt_grep_success",
|
id: "evt_grep_success",
|
||||||
created: 3,
|
created: 3,
|
||||||
type: "session.tool.success",
|
type: "session.tool.success",
|
||||||
durable: { aggregateID: "ses_1", seq: 3, version: 2 },
|
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||||
data: {
|
data: {
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
assistantMessageID: "msg_grep",
|
assistantMessageID: "msg_grep",
|
||||||
callID: "call_grep",
|
callID: "call_grep",
|
||||||
metadata: { matches: 2 },
|
structured: { matches: 2 },
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text", text }],
|
||||||
executed: false,
|
executed: false,
|
||||||
},
|
},
|
||||||
@@ -257,7 +258,9 @@ async function run(input: {
|
|||||||
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
|
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
|
||||||
spyOn(sdk.message, "list").mockImplementation(() =>
|
spyOn(sdk.message, "list").mockImplementation(() =>
|
||||||
ok({
|
ok({
|
||||||
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
|
data: input.messages?.(promptID) ?? [
|
||||||
|
{ id: promptID, type: "user", text: "hello", time: { created: 1 } },
|
||||||
|
],
|
||||||
cursor: {},
|
cursor: {},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -313,7 +316,7 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("runNonInteractivePrompt", () => {
|
describe("runNonInteractivePrompt", () => {
|
||||||
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
|
test("keeps formatted tool output and compact structured metadata in JSON", async () => {
|
||||||
const output = await capture({ format: "json", turn: successfulGrep })
|
const output = await capture({ format: "json", turn: successfulGrep })
|
||||||
const events = output.stdout
|
const events = output.stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -329,13 +332,13 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
output: expect.stringContaining("Found 2 matches"),
|
output: expect.stringContaining("Found 2 matches"),
|
||||||
metadata: {
|
metadata: {
|
||||||
metadata: { matches: 2 },
|
structured: { matches: 2 },
|
||||||
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
|
content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(events[0].part.state.metadata.metadata).toEqual({ matches: 2 })
|
expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 })
|
||||||
expect(events[0].part.state.metadata.result).toBeUndefined()
|
expect(events[0].part.state.metadata.result).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -531,7 +534,7 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
id: "call_failed_tool",
|
id: "call_failed_tool",
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
metadata: { checkpoint: 1 },
|
structured: { checkpoint: 1 },
|
||||||
content: [{ type: "text", text: "partial output" }],
|
content: [{ type: "text", text: "partial output" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -541,7 +544,7 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
id: "call_failed_tool",
|
id: "call_failed_tool",
|
||||||
state: {
|
state: {
|
||||||
status: "error",
|
status: "error",
|
||||||
metadata: { checkpoint: 1 },
|
structured: { checkpoint: 1 },
|
||||||
content: [{ type: "text", text: "partial output" }],
|
content: [{ type: "text", text: "partial output" }],
|
||||||
error: { message: "tool failed" },
|
error: { message: "tool failed" },
|
||||||
},
|
},
|
||||||
@@ -571,7 +574,7 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(events[0].part.state.output).toBeUndefined()
|
expect(events[0].part.state.output).toBeUndefined()
|
||||||
expect(events[0].part.state.metadata.metadata).toBeUndefined()
|
expect(events[0].part.state.metadata.structured).toBeUndefined()
|
||||||
expect(events[0].part.state.metadata.content).toBeUndefined()
|
expect(events[0].part.state.metadata.content).toBeUndefined()
|
||||||
expect(output.stderr).toBe("")
|
expect(output.stderr).toBe("")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,10 +75,6 @@ export const define = sdk.Plugin.define`
|
|||||||
const effectPluginModule = promisePluginModule
|
const effectPluginModule = promisePluginModule
|
||||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||||
.replace("Promise plugin", "Effect plugin")
|
.replace("Promise plugin", "Effect plugin")
|
||||||
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
|
||||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
|
||||||
export const Tool = sdk.Tool
|
|
||||||
export const make = sdk.Tool.make`
|
|
||||||
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
|
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
|
||||||
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
|
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
|
||||||
export const Tool = sdk.Tool
|
export const Tool = sdk.Tool
|
||||||
@@ -87,8 +83,10 @@ export const RegistrationError = sdk.Tool.RegistrationError
|
|||||||
export const make = sdk.Tool.make
|
export const make = sdk.Tool.make
|
||||||
export const validateName = sdk.Tool.validateName
|
export const validateName = sdk.Tool.validateName
|
||||||
export const registrationEntries = sdk.Tool.registrationEntries
|
export const registrationEntries = sdk.Tool.registrationEntries
|
||||||
export const validateNamespace = sdk.Tool.validateNamespace
|
export const withPermission = sdk.Tool.withPermission
|
||||||
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
|
export const permission = sdk.Tool.permission
|
||||||
|
export const definition = sdk.Tool.definition
|
||||||
|
export const settle = sdk.Tool.settle`
|
||||||
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
|
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
|
||||||
import __cjs_mod__ from "node:module"
|
import __cjs_mod__ from "node:module"
|
||||||
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
|
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
|
||||||
@@ -102,7 +100,6 @@ const require = __cjs_mod__.createRequire(import.meta.url)
|
|||||||
const __ocPluginModules = ${JSON.stringify({
|
const __ocPluginModules = ${JSON.stringify({
|
||||||
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
|
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
|
||||||
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
|
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
|
||||||
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
|
|
||||||
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
|
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
|
||||||
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
||||||
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
|
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||||
@@ -110,7 +107,6 @@ const __ocPluginModules = ${JSON.stringify({
|
|||||||
const __ocPluginSources = ${JSON.stringify({
|
const __ocPluginSources = ${JSON.stringify({
|
||||||
"opencode:plugin-v2": promiseModule,
|
"opencode:plugin-v2": promiseModule,
|
||||||
"opencode:plugin-v2-plugin": promisePluginModule,
|
"opencode:plugin-v2-plugin": promisePluginModule,
|
||||||
"opencode:plugin-v2-tool": promiseToolModule,
|
|
||||||
"opencode:plugin-v2-effect": effectModule,
|
"opencode:plugin-v2-effect": effectModule,
|
||||||
"opencode:plugin-v2-effect-plugin": effectPluginModule,
|
"opencode:plugin-v2-effect-plugin": effectPluginModule,
|
||||||
"opencode:plugin-v2-effect-tool": effectToolModule,
|
"opencode:plugin-v2-effect-tool": effectToolModule,
|
||||||
|
|||||||
@@ -1030,13 +1030,22 @@ export interface VcsApi<E = never> {
|
|||||||
readonly diff: VcsDiffOperation<E>
|
readonly diff: VcsDiffOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
|
type Endpoint26_0Request = Parameters<RawClient["server.path"]["path.get"]>[0]
|
||||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
export type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
|
||||||
|
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.path"]["path.get"]>>
|
||||||
|
export type PathGetOperation<E = never> = (input?: Endpoint26_0Input) => Effect.Effect<Endpoint26_0Output, E>
|
||||||
|
|
||||||
type Endpoint26_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
|
export interface PathApi<E = never> {
|
||||||
export type Endpoint26_1Input = { readonly location?: Endpoint26_1Request["query"]["location"] }
|
readonly get: PathGetOperation<E>
|
||||||
export type Endpoint26_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
|
}
|
||||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
|
||||||
|
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
|
||||||
|
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
|
||||||
|
|
||||||
|
type Endpoint27_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
|
||||||
|
export type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
|
||||||
|
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
|
||||||
|
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
||||||
|
|
||||||
export interface DebugApi<E = never> {
|
export interface DebugApi<E = never> {
|
||||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||||
@@ -1069,5 +1078,6 @@ export interface AppApi<E = never> {
|
|||||||
readonly reference: ReferenceApi<E>
|
readonly reference: ReferenceApi<E>
|
||||||
readonly projectCopy: ProjectCopyApi<E>
|
readonly projectCopy: ProjectCopyApi<E>
|
||||||
readonly vcs: VcsApi<E>
|
readonly vcs: VcsApi<E>
|
||||||
|
readonly path: PathApi<E>
|
||||||
readonly debug: DebugApi<E>
|
readonly debug: DebugApi<E>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1232,16 +1232,23 @@ const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input
|
|||||||
|
|
||||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
|
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
|
||||||
|
|
||||||
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
type Endpoint26_0Request = Parameters<RawClient["server.path"]["path.get"]>[0]
|
||||||
|
type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
|
||||||
|
const Endpoint26_0 = (raw: RawClient["server.path"]) => (input?: Endpoint26_0Input) =>
|
||||||
|
raw["path.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
const adaptGroup26 = (raw: RawClient["server.path"]) => ({ get: Endpoint26_0(raw) })
|
||||||
|
|
||||||
|
const Endpoint27_0 = (raw: RawClient["server.debug"]) => () =>
|
||||||
raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))
|
raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint26_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
|
type Endpoint27_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
|
||||||
type Endpoint26_1Input = { readonly location?: Endpoint26_1Request["query"]["location"] }
|
type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
|
||||||
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
|
const Endpoint27_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint27_1Input) =>
|
||||||
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
const adaptGroup27 = (raw: RawClient["server.debug"]) => ({
|
||||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
location: { list: Endpoint27_0(raw), evict: Endpoint27_1(raw) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({
|
const adaptClient = (raw: RawClient) => ({
|
||||||
@@ -1271,7 +1278,8 @@ const adaptClient = (raw: RawClient) => ({
|
|||||||
reference: adaptGroup23(raw["server.reference"]),
|
reference: adaptGroup23(raw["server.reference"]),
|
||||||
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
||||||
vcs: adaptGroup25(raw["server.vcs"]),
|
vcs: adaptGroup25(raw["server.vcs"]),
|
||||||
debug: adaptGroup26(raw["server.debug"]),
|
path: adaptGroup26(raw["server.path"]),
|
||||||
|
debug: adaptGroup27(raw["server.debug"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
|
|||||||
@@ -3,13 +3,22 @@ type Client = ReturnType<typeof import("./generated/client.js").make>
|
|||||||
export type AgentApi = Client["agent"]
|
export type AgentApi = Client["agent"]
|
||||||
export type CommandApi = Client["command"]
|
export type CommandApi = Client["command"]
|
||||||
export type EventApi = Client["event"]
|
export type EventApi = Client["event"]
|
||||||
|
export type FileApi = Client["file"]
|
||||||
export type IntegrationApi = Client["integration"]
|
export type IntegrationApi = Client["integration"]
|
||||||
|
export type MessageApi = Client["message"]
|
||||||
|
export type McpApi = Client["mcp"]
|
||||||
export type ModelApi = Client["model"]
|
export type ModelApi = Client["model"]
|
||||||
export type PluginApi = Client["plugin"]
|
export type PluginApi = Client["plugin"]
|
||||||
|
export type PermissionApi = Client["permission"]
|
||||||
|
export type PathApi = Client["path"]
|
||||||
|
export type ProjectApi = Client["project"]
|
||||||
export type ProviderApi = Client["provider"]
|
export type ProviderApi = Client["provider"]
|
||||||
|
export type QuestionApi = Client["question"]
|
||||||
export type ReferenceApi = Client["reference"]
|
export type ReferenceApi = Client["reference"]
|
||||||
export type SessionApi = Client["session"]
|
export type SessionApi = Client["session"]
|
||||||
export type SkillApi = Client["skill"]
|
export type SkillApi = Client["skill"]
|
||||||
|
export type PtyApi = Client["pty"]
|
||||||
|
export type VcsApi = Client["vcs"]
|
||||||
|
|
||||||
export interface CatalogApi {
|
export interface CatalogApi {
|
||||||
readonly provider: ProviderApi
|
readonly provider: ProviderApi
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ import type {
|
|||||||
VcsStatusOutput,
|
VcsStatusOutput,
|
||||||
VcsDiffInput,
|
VcsDiffInput,
|
||||||
VcsDiffOutput,
|
VcsDiffOutput,
|
||||||
|
PathGetInput,
|
||||||
|
PathGetOutput,
|
||||||
DebugLocationListOutput,
|
DebugLocationListOutput,
|
||||||
DebugLocationEvictInput,
|
DebugLocationEvictInput,
|
||||||
DebugLocationEvictOutput,
|
DebugLocationEvictOutput,
|
||||||
@@ -1708,6 +1710,20 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
path: {
|
||||||
|
get: (input?: PathGetInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<PathGetOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/path`,
|
||||||
|
query: { location: input?.["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
debug: {
|
debug: {
|
||||||
location: {
|
location: {
|
||||||
list: (requestOptions?: RequestOptions) =>
|
list: (requestOptions?: RequestOptions) =>
|
||||||
|
|||||||
@@ -104,12 +104,6 @@ export type SessionMessageProviderState = { [x: string]: JsonValue }
|
|||||||
|
|
||||||
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
|
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
|
||||||
|
|
||||||
export type SessionMessageToolStateRunning = {
|
|
||||||
status: "running"
|
|
||||||
input: { [x: string]: JsonValue }
|
|
||||||
metadata: { [x: string]: JsonValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ToolTextContent = { type: "text"; text: string }
|
export type ToolTextContent = { type: "text"; text: string }
|
||||||
|
|
||||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string }
|
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string }
|
||||||
@@ -539,6 +533,8 @@ export type VcsFileStatus = {
|
|||||||
status: "added" | "deleted" | "modified"
|
status: "added" | "deleted" | "modified"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PathInfo = { home: string; state: string; config: string; worktree: string; directory: string }
|
||||||
|
|
||||||
export type SessionMessageModelSelected = {
|
export type SessionMessageModelSelected = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: { [x: string]: JsonValue }
|
metadata?: { [x: string]: JsonValue }
|
||||||
@@ -555,6 +551,7 @@ export type CommandInfo = {
|
|||||||
agent?: string
|
agent?: string
|
||||||
model?: ModelRef
|
model?: ModelRef
|
||||||
subtask?: boolean
|
subtask?: boolean
|
||||||
|
source?: "command" | "mcp"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProviderRequest = {
|
export type ProviderRequest = {
|
||||||
@@ -924,15 +921,6 @@ export type SessionToolInputDelta = {
|
|||||||
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
|
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionToolProgress = {
|
|
||||||
id: string
|
|
||||||
created: number
|
|
||||||
metadata?: { [x: string]: any }
|
|
||||||
type: "session.tool.progress"
|
|
||||||
location?: LocationRef
|
|
||||||
data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionCompactionDelta = {
|
export type SessionCompactionDelta = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -1824,19 +1812,28 @@ export type SessionPendingUserData1 = {
|
|||||||
metadata?: { [x: string]: any }
|
metadata?: { [x: string]: any }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionMessageToolStateRunning = {
|
||||||
|
status: "running"
|
||||||
|
input: { [x: string]: JsonValue }
|
||||||
|
structured: { [x: string]: JsonValue }
|
||||||
|
content: Array<LLMToolContent>
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionMessageToolStateCompleted = {
|
export type SessionMessageToolStateCompleted = {
|
||||||
status: "completed"
|
status: "completed"
|
||||||
input: { [x: string]: JsonValue }
|
input: { [x: string]: JsonValue }
|
||||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
content: Array<LLMToolContent>
|
||||||
metadata?: { [x: string]: JsonValue }
|
structured: { [x: string]: JsonValue }
|
||||||
|
result?: JsonValue
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMessageToolStateError = {
|
export type SessionMessageToolStateError = {
|
||||||
status: "error"
|
status: "error"
|
||||||
input: { [x: string]: JsonValue }
|
input: { [x: string]: JsonValue }
|
||||||
|
content: Array<LLMToolContent>
|
||||||
|
structured: { [x: string]: JsonValue }
|
||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
result?: JsonValue
|
||||||
metadata?: { [x: string]: JsonValue }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionToolSuccess = {
|
export type SessionToolSuccess = {
|
||||||
@@ -1844,14 +1841,15 @@ export type SessionToolSuccess = {
|
|||||||
created: number
|
created: number
|
||||||
metadata?: { [x: string]: any }
|
metadata?: { [x: string]: any }
|
||||||
type: "session.tool.success"
|
type: "session.tool.success"
|
||||||
durable: { aggregateID: string; seq: number; version: 2 }
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
assistantMessageID: string
|
assistantMessageID: string
|
||||||
callID: string
|
callID: string
|
||||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
structured: { [x: string]: any }
|
||||||
metadata?: { [x: string]: JsonValue }
|
content: Array<LLMToolContent>
|
||||||
|
result?: any
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState6
|
resultState?: SessionMessageProviderState6
|
||||||
}
|
}
|
||||||
@@ -1862,7 +1860,7 @@ export type SessionToolFailed = {
|
|||||||
created: number
|
created: number
|
||||||
metadata?: { [x: string]: any }
|
metadata?: { [x: string]: any }
|
||||||
type: "session.tool.failed"
|
type: "session.tool.failed"
|
||||||
durable: { aggregateID: string; seq: number; version: 2 }
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: {
|
data: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -1870,12 +1868,28 @@ export type SessionToolFailed = {
|
|||||||
callID: string
|
callID: string
|
||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||||
metadata?: { [x: string]: JsonValue }
|
metadata?: { [x: string]: any }
|
||||||
|
result?: any
|
||||||
executed: boolean
|
executed: boolean
|
||||||
resultState?: SessionMessageProviderState7
|
resultState?: SessionMessageProviderState7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionToolProgress = {
|
||||||
|
id: string
|
||||||
|
created: number
|
||||||
|
metadata?: { [x: string]: any }
|
||||||
|
type: "session.tool.progress"
|
||||||
|
location?: LocationRef
|
||||||
|
data: {
|
||||||
|
sessionID: string
|
||||||
|
assistantMessageID: string
|
||||||
|
callID: string
|
||||||
|
structured: { [x: string]: any }
|
||||||
|
content: Array<LLMToolContent>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionMessageCompaction =
|
export type SessionMessageCompaction =
|
||||||
| SessionMessageCompactionRunning
|
| SessionMessageCompactionRunning
|
||||||
| SessionMessageCompactionCompleted
|
| SessionMessageCompactionCompleted
|
||||||
@@ -4941,6 +4955,14 @@ export type VcsDiffOutput = {
|
|||||||
data: Array<FileDiffInfo>
|
data: Array<FileDiffInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PathGetInput = {
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PathGetOutput = PathInfo
|
||||||
|
|
||||||
export type DebugLocationListOutput = Array<LocationRef>
|
export type DebugLocationListOutput = Array<LocationRef>
|
||||||
|
|
||||||
export type DebugLocationEvictInput = {
|
export type DebugLocationEvictInput = {
|
||||||
|
|||||||
@@ -4,13 +4,22 @@ export type {
|
|||||||
CatalogApi,
|
CatalogApi,
|
||||||
CommandApi,
|
CommandApi,
|
||||||
EventApi,
|
EventApi,
|
||||||
|
FileApi,
|
||||||
IntegrationApi,
|
IntegrationApi,
|
||||||
|
MessageApi,
|
||||||
|
McpApi,
|
||||||
ModelApi,
|
ModelApi,
|
||||||
|
PermissionApi,
|
||||||
|
PathApi,
|
||||||
PluginApi,
|
PluginApi,
|
||||||
|
ProjectApi,
|
||||||
ProviderApi,
|
ProviderApi,
|
||||||
|
QuestionApi,
|
||||||
ReferenceApi,
|
ReferenceApi,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SkillApi,
|
SkillApi,
|
||||||
|
PtyApi,
|
||||||
|
VcsApi,
|
||||||
} from "./api.js"
|
} from "./api.js"
|
||||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
|
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
"reference",
|
"reference",
|
||||||
"projectCopy",
|
"projectCopy",
|
||||||
"vcs",
|
"vcs",
|
||||||
|
"path",
|
||||||
"debug",
|
"debug",
|
||||||
])
|
])
|
||||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const lookupOrder = Tool.make({
|
|||||||
description: "Look up an order by ID",
|
description: "Look up an order by ID",
|
||||||
input: Schema.Struct({ id: Schema.String }),
|
input: Schema.Struct({ id: Schema.String }),
|
||||||
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
|
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
|
||||||
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
|
run: ({ id }) => Effect.succeed({ id, status: "open" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const runtime = CodeMode.make({
|
const runtime = CodeMode.make({
|
||||||
@@ -55,10 +55,10 @@ const result = await Effect.runPromise(
|
|||||||
### `Tool.make`
|
### `Tool.make`
|
||||||
|
|
||||||
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is
|
`input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is
|
||||||
decoded before `execute`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas
|
decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only
|
||||||
only shape the model-visible signature. Without `output`, the signature uses `Promise<void>`.
|
shape the model-visible signature. Without `output`, the signature uses `Promise<unknown>`.
|
||||||
|
|
||||||
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
|
Descriptions and schemas are model-visible contracts. Authorization belongs in `run`.
|
||||||
|
|
||||||
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
|
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
|
||||||
`tools.issues.list(...)`. Other characters use bracket notation, such as
|
`tools.issues.list(...)`. Other characters use bracket notation, such as
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ ultimate source of truth.
|
|||||||
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
|
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
|
||||||
and a JavaScript `this` receiver remain outside the supported object/function model.
|
and a JavaScript `this` receiver remain outside the supported object/function model.
|
||||||
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
|
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
|
||||||
last tool supplied for a canonical path wins.
|
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.
|
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
|
||||||
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
|
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
|
||||||
`undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse
|
`undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
|
|||||||
const program = parseProgram(options.code)
|
const program = parseProgram(options.code)
|
||||||
const promises = new PromiseRuntime<Services<Provided>>(scope)
|
const promises = new PromiseRuntime<Services<Provided>>(scope)
|
||||||
const interpreter = new Interpreter<Services<Provided>>(
|
const interpreter = new Interpreter<Services<Provided>>(
|
||||||
tools.execute,
|
tools.invoke,
|
||||||
tools.search,
|
tools.search,
|
||||||
tools.keys,
|
tools.keys,
|
||||||
promises,
|
promises,
|
||||||
|
|||||||
@@ -271,10 +271,7 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution" }
|
|||||||
|
|
||||||
export class Interpreter<R> {
|
export class Interpreter<R> {
|
||||||
private scopes: ScopeStack
|
private scopes: ScopeStack
|
||||||
private readonly executeTool: (
|
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||||
path: ReadonlyArray<string>,
|
|
||||||
args: Array<unknown>,
|
|
||||||
) => Effect.Effect<unknown, unknown, R>
|
|
||||||
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||||
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||||
private readonly logs: Array<string>
|
private readonly logs: Array<string>
|
||||||
@@ -289,7 +286,7 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
executeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||||
invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||||
promises: PromiseRuntime<R>,
|
promises: PromiseRuntime<R>,
|
||||||
@@ -297,7 +294,7 @@ export class Interpreter<R> {
|
|||||||
) {
|
) {
|
||||||
const globalScope = new Map<string, Binding>()
|
const globalScope = new Map<string, Binding>()
|
||||||
this.scopes = new ScopeStack([globalScope])
|
this.scopes = new ScopeStack([globalScope])
|
||||||
this.executeTool = executeTool
|
this.invokeTool = invokeTool
|
||||||
this.invokeSearch = invokeSearch
|
this.invokeSearch = invokeSearch
|
||||||
this.toolKeys = toolKeys
|
this.toolKeys = toolKeys
|
||||||
this.logs = logs
|
this.logs = logs
|
||||||
@@ -372,7 +369,7 @@ export class Interpreter<R> {
|
|||||||
path: ReadonlyArray<string>,
|
path: ReadonlyArray<string>,
|
||||||
args: Array<unknown>,
|
args: Array<unknown>,
|
||||||
): Effect.Effect<CodeModePromise, never, R> {
|
): Effect.Effect<CodeModePromise, never, R> {
|
||||||
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
|
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||||
@@ -2082,7 +2079,7 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||||
const invocation = new Interpreter(this.executeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
|
const invocation = new Interpreter(this.invokeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
|
||||||
invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()])
|
invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()])
|
||||||
const run = Effect.gen(function* () {
|
const run = Effect.gen(function* () {
|
||||||
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
|
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HttpClient } from "effect/unstable/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import { make, type Tool } from "../tool.js"
|
import { make, type Definition } from "../tool.js"
|
||||||
import { invoke } from "./runtime.js"
|
import { invoke } from "./runtime.js"
|
||||||
import {
|
import {
|
||||||
componentDefinitions,
|
componentDefinitions,
|
||||||
@@ -108,7 +108,7 @@ export const fromSpec = (options: Options): Result => {
|
|||||||
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
||||||
input: inputSchema(input.fields, requestDefinitions),
|
input: inputSchema(input.fields, requestDefinitions),
|
||||||
output: output.value,
|
output: output.value,
|
||||||
execute: (input) => invoke(plan, input),
|
run: (input) => invoke(plan, input),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -117,16 +117,16 @@ export const fromSpec = (options: Options): Result => {
|
|||||||
return { tools, skipped }
|
return { tools, skipped }
|
||||||
}
|
}
|
||||||
|
|
||||||
const setTool = (tools: Tools, path: ReadonlyArray<string>, tool: Tool<HttpClient.HttpClient>): void => {
|
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
|
||||||
const [head, ...rest] = path
|
const [head, ...rest] = path
|
||||||
if (head === undefined) return
|
if (head === undefined) return
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
tools[head] = tool
|
tools[head] = definition
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const child = tools[head]
|
const child = tools[head]
|
||||||
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
|
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
|
||||||
tools[head] = Object.create(null) as Tools
|
tools[head] = Object.create(null) as Tools
|
||||||
}
|
}
|
||||||
setTool(tools[head] as Tools, rest, tool)
|
setTool(tools[head] as Tools, rest, definition)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpClient } from "effect/unstable/http"
|
import { HttpClient } from "effect/unstable/http"
|
||||||
import type { Tool, JsonSchema } from "../tool.js"
|
import type { Definition, JsonSchema } from "../tool.js"
|
||||||
|
|
||||||
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
|
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
|
||||||
export type Document = Record<string, unknown>
|
export type Document = Record<string, unknown>
|
||||||
@@ -58,7 +58,7 @@ export type Skipped = {
|
|||||||
readonly reason: string
|
readonly reason: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tools = { [name: string]: Tool<HttpClient.HttpClient> | Tools }
|
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
|
||||||
|
|
||||||
export type Result = {
|
export type Result = {
|
||||||
/** Namespaced tools; the host places them under a key in its `tools` object. */
|
/** Namespaced tools; the host places them under a key in its `tools` object. */
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
inputTypeScript,
|
inputTypeScript,
|
||||||
outputTypeScript,
|
outputTypeScript,
|
||||||
} from "./tool-schema.js"
|
} from "./tool-schema.js"
|
||||||
import { isTool, type Tool } from "./tool.js"
|
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
|
||||||
import type { Tools } from "./tools.js"
|
import type { Tools } from "./tools.js"
|
||||||
import {
|
import {
|
||||||
CodeModeDate,
|
CodeModeDate,
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
} from "./values.js"
|
} from "./values.js"
|
||||||
|
|
||||||
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
|
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
|
||||||
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
|
|
||||||
|
|
||||||
export type Services<T> = ServicesOf<T, []>
|
export type Services<T> = ServicesOf<T, []>
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] exten
|
|||||||
? never
|
? never
|
||||||
: T extends {
|
: T extends {
|
||||||
readonly _tag: "CodeModeTool"
|
readonly _tag: "CodeModeTool"
|
||||||
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
||||||
}
|
}
|
||||||
? R
|
? R
|
||||||
: T extends object
|
: T extends object
|
||||||
@@ -119,6 +118,8 @@ export class ToolRuntimeError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> => isToolDefinition<R>(value)
|
||||||
|
|
||||||
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
|
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
@@ -285,9 +286,9 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
|
// Dots in tool names are namespace separators; the last definition for a canonical path wins.
|
||||||
type ToolNode<R> = {
|
type ToolNode<R> = {
|
||||||
tool?: Tool<R>
|
definition?: Definition<R>
|
||||||
readonly children: Map<string, ToolNode<R>>
|
readonly children: Map<string, ToolNode<R>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +303,7 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
|||||||
current.children.set(segment, child)
|
current.children.set(segment, child)
|
||||||
current = child
|
current = child
|
||||||
}
|
}
|
||||||
if (isTool<R>(value)) current.tool = value
|
if (isDefinition(value)) current.definition = value
|
||||||
else insert(current, value)
|
else insert(current, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,29 +314,26 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
|||||||
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
|
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
|
||||||
path.flatMap((segment) => segment.split("."))
|
path.flatMap((segment) => segment.split("."))
|
||||||
|
|
||||||
const flattenTools = <R>(
|
const definitions = <R>(
|
||||||
node: ToolNode<R>,
|
node: ToolNode<R>,
|
||||||
path: ReadonlyArray<string> = [],
|
path: ReadonlyArray<string> = [],
|
||||||
): Array<{ path: string; tool: Tool<R> }> => [
|
): Array<{ path: string; definition: Definition<R> }> => [
|
||||||
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
|
...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]),
|
||||||
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
|
...Array.from(node.children, ([name, child]) => definitions(child, [...path, name])).flat(),
|
||||||
]
|
]
|
||||||
|
|
||||||
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
|
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
|
||||||
path,
|
path,
|
||||||
description: tool.description,
|
description: definition.description,
|
||||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
|
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
const visibleDefinitions = <R>(tools: Tools<R>) =>
|
||||||
const visibleTools = <R>(tools: Tools<R>) =>
|
definitions(toolTrie(tools)).map(({ path, definition }) => ({
|
||||||
flattenTools(toolTrie(tools))
|
path,
|
||||||
.sort((left, right) => compareText(left.path, right.path))
|
definition,
|
||||||
.map(({ path, tool }) => ({
|
description: describeDefinition(path, definition),
|
||||||
path,
|
}))
|
||||||
tool,
|
|
||||||
description: describeTool(path, tool),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export type DiscoveryPlan = {
|
export type DiscoveryPlan = {
|
||||||
readonly catalog: ReadonlyArray<ToolDescription>
|
readonly catalog: ReadonlyArray<ToolDescription>
|
||||||
@@ -363,12 +361,12 @@ const termForms = (term: string): Array<string> => {
|
|||||||
return forms
|
return forms
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
|
||||||
_tag: "CodeModeTool",
|
_tag: "CodeModeTool",
|
||||||
description: "Search available tools",
|
description: "Search available tools",
|
||||||
input: SearchInput,
|
input: SearchInput,
|
||||||
output: SearchOutput,
|
output: SearchOutput,
|
||||||
execute: (input) =>
|
run: (input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const request = input as typeof SearchInput.Type
|
const request = input as typeof SearchInput.Type
|
||||||
const query = request.query ?? ""
|
const query = request.query ?? ""
|
||||||
@@ -407,7 +405,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
|||||||
.filter(({ score }) => terms.length === 0 || score > 0)
|
.filter(({ score }) => terms.length === 0 || score > 0)
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
right.score - left.score || compareText(left.entry.description.path, right.entry.description.path),
|
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path),
|
||||||
)
|
)
|
||||||
.map(({ entry }) => entry)
|
.map(({ entry }) => entry)
|
||||||
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
|
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
|
||||||
@@ -424,8 +422,8 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const searchSignature = (() => {
|
const searchSignature = (() => {
|
||||||
const tool = makeSearchTool([])
|
const definition = makeSearchTool([])
|
||||||
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
|
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const catalogLine = (tool: ToolDescription) => {
|
const catalogLine = (tool: ToolDescription) => {
|
||||||
@@ -434,13 +432,13 @@ const catalogLine = (tool: ToolDescription) => {
|
|||||||
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
|
const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
|
||||||
description,
|
description,
|
||||||
namespace: path.split(".", 1)[0]!,
|
namespace: path.split(".", 1)[0]!,
|
||||||
searchText: [
|
searchText: [
|
||||||
path,
|
path,
|
||||||
tool.description,
|
definition.description,
|
||||||
...inputProperties(tool).flatMap(({ name, description: property }) =>
|
...inputProperties(definition).flatMap(({ name, description: property }) =>
|
||||||
property === undefined ? [name] : [name, property],
|
property === undefined ? [name] : [name, property],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -449,14 +447,14 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
|
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
|
||||||
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
|
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
||||||
|
|
||||||
// Budget signatures round-robin so every namespace remains visible.
|
// Budget signatures round-robin so every namespace remains visible.
|
||||||
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
||||||
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
||||||
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
|
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
|
||||||
}
|
}
|
||||||
const visible = visibleTools(tools)
|
const visible = visibleDefinitions(tools)
|
||||||
const described = visible.map(({ description }) => description)
|
const described = visible.map(({ description }) => description)
|
||||||
|
|
||||||
const namespaces = new Map<string, Array<ToolDescription>>()
|
const namespaces = new Map<string, Array<ToolDescription>>()
|
||||||
@@ -466,14 +464,14 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
|
|||||||
group.push(tool)
|
group.push(tool)
|
||||||
namespaces.set(namespace, group)
|
namespaces.set(namespace, group)
|
||||||
}
|
}
|
||||||
const ordered = [...namespaces].sort(([left], [right]) => compareText(left, right))
|
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
|
||||||
const selections = ordered.map(([namespace, group]) => ({
|
const selections = ordered.map(([namespace, group]) => ({
|
||||||
namespace,
|
namespace,
|
||||||
picked: new Set<ToolDescription>(),
|
picked: new Set<ToolDescription>(),
|
||||||
queue: [...group].sort(
|
queue: [...group].sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || compareText(left.path, right.path),
|
estimateTokens(catalogLine(left)) - estimateTokens(catalogLine(right)) || left.path.localeCompare(right.path),
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
let used = 0
|
let used = 0
|
||||||
@@ -591,7 +589,7 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
|
|||||||
return {
|
return {
|
||||||
catalog: described,
|
catalog: described,
|
||||||
instructions: lines.join("\n"),
|
instructions: lines.join("\n"),
|
||||||
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
|
searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +605,7 @@ const namespaceKeys = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Reado
|
|||||||
return Array.from(node.children.keys())
|
return Array.from(node.children.keys())
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> => {
|
const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Definition<R> => {
|
||||||
const segments = canonicalSegments(path)
|
const segments = canonicalSegments(path)
|
||||||
const node = lookup(root, segments)
|
const node = lookup(root, segments)
|
||||||
if (node === undefined) {
|
if (node === undefined) {
|
||||||
@@ -615,16 +613,16 @@ const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> =>
|
|||||||
"Use search({ query }) to find available described tools.",
|
"Use search({ query }) to find available described tools.",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
if (node.tool === undefined) {
|
if (node.definition === undefined) {
|
||||||
throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`)
|
throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`)
|
||||||
}
|
}
|
||||||
return node.tool
|
return node.definition
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolRuntime<R = never> = {
|
export type ToolRuntime<R = never> = {
|
||||||
readonly root: ToolReference
|
readonly root: ToolReference
|
||||||
readonly calls: Array<ToolCall>
|
readonly calls: Array<ToolCall>
|
||||||
readonly execute: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||||
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||||
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||||
}
|
}
|
||||||
@@ -678,7 +676,7 @@ export const make = <R>(
|
|||||||
return calls.length - 1
|
return calls.length - 1
|
||||||
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
|
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
|
||||||
|
|
||||||
const executeTool = (name: string, tool: Tool<R>, externalArgs: Array<unknown>) =>
|
const invokeDefinition = (name: string, tool: Definition<R>, externalArgs: Array<unknown>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (externalArgs.length !== 1)
|
if (externalArgs.length !== 1)
|
||||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||||
@@ -690,7 +688,7 @@ export const make = <R>(
|
|||||||
const index = yield* recordAndObserve(name, input)
|
const index = yield* recordAndObserve(name, input)
|
||||||
return yield* observeEnd(
|
return yield* observeEnd(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
|
const raw = yield* runHost(Effect.suspend(() => tool.run(input)))
|
||||||
const result = yield* Effect.try({
|
const result = yield* Effect.try({
|
||||||
try: () => decodeToolOutput(tool, raw),
|
try: () => decodeToolOutput(tool, raw),
|
||||||
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
||||||
@@ -707,18 +705,18 @@ export const make = <R>(
|
|||||||
keys: (path) => namespaceKeys(root, path),
|
keys: (path) => namespaceKeys(root, path),
|
||||||
search: (args) =>
|
search: (args) =>
|
||||||
Effect.suspend(() =>
|
Effect.suspend(() =>
|
||||||
executeTool(
|
invokeDefinition(
|
||||||
"search",
|
"search",
|
||||||
searchTool,
|
searchTool,
|
||||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
|
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
execute: (path, args) =>
|
invoke: (path, args) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const name = canonicalSegments(path).join(".")
|
const name = canonicalSegments(path).join(".")
|
||||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
|
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
|
||||||
const tool = resolve(root, path)
|
const tool = resolve(root, path)
|
||||||
return yield* executeTool(name, tool, externalArgs)
|
return yield* invokeDefinition(name, tool, externalArgs)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { JsonPointer, Schema } from "effect"
|
import { JsonPointer, Schema } from "effect"
|
||||||
import type { Tool, JsonSchema, SchemaType } from "./tool.js"
|
import type { Definition, JsonSchema, SchemaType } from "./tool.js"
|
||||||
|
|
||||||
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
|
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
|
||||||
|
|
||||||
@@ -192,16 +192,16 @@ export type InputProperty = {
|
|||||||
readonly required: boolean
|
readonly required: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
|
||||||
try {
|
try {
|
||||||
const document = isEffectSchema(tool.input)
|
const document = isEffectSchema(definition.input)
|
||||||
? (Schema.toJsonSchemaDocument(tool.input) as {
|
? (Schema.toJsonSchemaDocument(definition.input) as {
|
||||||
readonly schema: JsonSchema
|
readonly schema: JsonSchema
|
||||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||||
})
|
})
|
||||||
: {
|
: {
|
||||||
schema: tool.input,
|
schema: definition.input,
|
||||||
definitions: { ...(tool.input.definitions ?? {}), ...(tool.input.$defs ?? {}) },
|
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
|
||||||
}
|
}
|
||||||
const definitions = document.definitions ?? {}
|
const definitions = document.definitions ?? {}
|
||||||
let schema = document.schema
|
let schema = document.schema
|
||||||
@@ -223,22 +223,22 @@ export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||||
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
|
isEffectSchema(definition.input)
|
||||||
|
? toTypeScript(definition.input, false, pretty)
|
||||||
|
: jsonSchemaToTypeScript(definition.input, pretty)
|
||||||
|
|
||||||
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||||
tool.output === undefined
|
definition.output === undefined
|
||||||
? "void"
|
? "unknown"
|
||||||
: isEffectSchema(tool.output)
|
: isEffectSchema(definition.output)
|
||||||
? toTypeScript(tool.output, true, pretty)
|
? toTypeScript(definition.output, true, pretty)
|
||||||
: jsonSchemaToTypeScript(tool.output, pretty)
|
: jsonSchemaToTypeScript(definition.output, pretty)
|
||||||
|
|
||||||
export const decodeInput = <R>(tool: Tool<R>, value: unknown): unknown =>
|
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||||
isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(value) : value
|
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
|
||||||
|
|
||||||
export const decodeOutput = <R>(tool: Tool<R>, value: unknown): unknown =>
|
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||||
tool.output === undefined
|
definition.output !== undefined && isEffectSchema(definition.output)
|
||||||
? undefined
|
? Schema.decodeUnknownSync(definition.output)(value)
|
||||||
: isEffectSchema(tool.output)
|
: value
|
||||||
? Schema.decodeUnknownSync(tool.output)(value)
|
|
||||||
: value
|
|
||||||
|
|||||||
@@ -29,29 +29,29 @@ export type JsonSchema = {
|
|||||||
/** Either a validating Effect Schema or a render-only JSON Schema document. */
|
/** Either a validating Effect Schema or a render-only JSON Schema document. */
|
||||||
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
|
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
|
||||||
|
|
||||||
/** Executable tool exposed through CodeMode's `tools` object. */
|
/** Schema-backed tool definition exposed through CodeMode's `tools` object. */
|
||||||
export type Tool<R = never> = {
|
export type Definition<R = never> = {
|
||||||
readonly _tag: "CodeModeTool"
|
readonly _tag: "CodeModeTool"
|
||||||
readonly description: string
|
readonly description: string
|
||||||
readonly input: SchemaType
|
readonly input: SchemaType
|
||||||
readonly output: SchemaType | undefined
|
readonly output: SchemaType | undefined
|
||||||
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
|
||||||
}
|
}
|
||||||
|
|
||||||
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
|
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
|
||||||
|
|
||||||
type ResultType<S> = S extends undefined ? void : S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
||||||
|
|
||||||
/** Options for declaring one CodeMode tool. */
|
/** Options for defining one CodeMode tool. */
|
||||||
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
|
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
|
||||||
readonly description: string
|
readonly description: string
|
||||||
readonly input: I
|
readonly input: I
|
||||||
readonly output?: O
|
readonly output?: O
|
||||||
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
|
readonly run: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
|
// Object.hasOwn: an inherited _tag must not classify a namespace as a Definition.
|
||||||
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
|
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
"_tag" in value &&
|
"_tag" in value &&
|
||||||
@@ -59,18 +59,18 @@ export const isTool = <R = never>(value: unknown): value is Tool<R> =>
|
|||||||
value._tag === "CodeModeTool"
|
value._tag === "CodeModeTool"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
|
* Defines one schema-described tool available to a CodeMode program through `tools.*`.
|
||||||
*
|
*
|
||||||
* Effect Schemas validate values; JSON Schemas only shape the model-visible signature.
|
* Effect Schemas validate values; JSON Schemas only shape the model-visible signature.
|
||||||
* Without `output`, results are exposed as `void`. Hosts remain responsible for authorization
|
* Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization
|
||||||
* and durable side effects.
|
* and durable side effects.
|
||||||
*/
|
*/
|
||||||
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
|
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
|
||||||
options: Options<I, O, R>,
|
options: Options<I, O, R>,
|
||||||
): Tool<R> => ({
|
): Definition<R> => ({
|
||||||
_tag: "CodeModeTool",
|
_tag: "CodeModeTool",
|
||||||
description: options.description,
|
description: options.description,
|
||||||
input: options.input,
|
input: options.input,
|
||||||
output: options.output,
|
output: options.output,
|
||||||
execute: (input) => options.execute(input as InputType<I>),
|
run: (input) => options.run(input as InputType<I>),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Tool } from "./tool.js"
|
import type { Definition } from "./tool.js"
|
||||||
|
|
||||||
export type Tools<R = never> = {
|
export type Tools<R = never> = {
|
||||||
readonly [name: string]: Tool<R> | Tools<R>
|
readonly [name: string]: Definition<R> | Tools<R>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const echo = Tool.make({
|
|||||||
description: "Echo the input",
|
description: "Echo the input",
|
||||||
input: Schema.Struct({ id: Schema.Number }),
|
input: Schema.Struct({ id: Schema.Number }),
|
||||||
output: Schema.Number,
|
output: Schema.Number,
|
||||||
execute: (input: { id: number }) => Effect.succeed(input.id),
|
run: (input: { id: number }) => Effect.succeed(input.id),
|
||||||
})
|
})
|
||||||
const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code))
|
const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code))
|
||||||
const toolError = async (code: string) => {
|
const toolError = async (code: string) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { Cause, Effect, Schema } from "effect"
|
import { Cause, Effect, Schema } from "effect"
|
||||||
import { CodeMode, Tool, toolError } from "../src/index.js"
|
import { CodeMode, Tool, toolError } from "../src/index.js"
|
||||||
|
|
||||||
const run = (tool: Tool.Tool<never>) =>
|
const run = (tool: Tool.Definition<never>) =>
|
||||||
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
|
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
|
||||||
|
|
||||||
class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
|
class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
|
||||||
@@ -16,7 +16,7 @@ describe("CodeMode host failure boundary", () => {
|
|||||||
description: "Fail safely",
|
description: "Fail safely",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.fail(toolError("Authorized request was refused")),
|
run: () => Effect.fail(toolError("Authorized request was refused")),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ describe("CodeMode host failure boundary", () => {
|
|||||||
description: "Fail safely",
|
description: "Fail safely",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.fail(toolError("File not found: /tmp/report.json")),
|
run: () => Effect.fail(toolError("File not found: /tmp/report.json")),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ describe("CodeMode host failure boundary", () => {
|
|||||||
description: "Fail internally",
|
description: "Fail internally",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => failure,
|
run: () => failure,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ describe("CodeMode host failure boundary", () => {
|
|||||||
description: "Return invalid output",
|
description: "Return invalid output",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Struct({ safe: Schema.String }),
|
output: Schema.Struct({ safe: Schema.String }),
|
||||||
execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
|
run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ describe("CodeMode host failure boundary", () => {
|
|||||||
description: "Return hostile output",
|
description: "Return hostile output",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Unknown,
|
output: Schema.Unknown,
|
||||||
execute: () =>
|
run: () =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
new Proxy(
|
new Proxy(
|
||||||
{},
|
{},
|
||||||
@@ -118,7 +118,7 @@ describe("CodeMode host failure boundary", () => {
|
|||||||
description: "Refuse",
|
description: "Refuse",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.fail(toolError("Refused")),
|
run: () => Effect.fail(toolError("Refused")),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -145,7 +145,7 @@ describe("CodeMode host failure boundary", () => {
|
|||||||
description: "Interrupt",
|
description: "Interrupt",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.interrupt,
|
run: () => Effect.interrupt,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -166,7 +166,7 @@ describe("CodeMode tool-call observation", () => {
|
|||||||
description: "Look up a value",
|
description: "Look up a value",
|
||||||
input: Schema.Struct({ query: Schema.String }),
|
input: Schema.Struct({ query: Schema.String }),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: ({ query }) => Effect.succeed(query),
|
run: ({ query }) => Effect.succeed(query),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await Effect.runPromise(
|
const result = await Effect.runPromise(
|
||||||
@@ -189,7 +189,7 @@ describe("CodeMode tool-call observation", () => {
|
|||||||
description: "Look up a value",
|
description: "Look up a value",
|
||||||
input: Schema.Struct({ query: Schema.String }),
|
input: Schema.Struct({ query: Schema.String }),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
|
run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const runtime = CodeMode.make({
|
const runtime = CodeMode.make({
|
||||||
@@ -430,7 +430,7 @@ describe("CodeMode schema flexibility", () => {
|
|||||||
properties: { id: { type: "string" }, count: { type: "number" } },
|
properties: { id: { type: "string" }, count: { type: "number" } },
|
||||||
required: ["id"],
|
required: ["id"],
|
||||||
},
|
},
|
||||||
execute: (input) =>
|
run: (input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
observed.push(input)
|
observed.push(input)
|
||||||
return { echoed: input }
|
return { echoed: input }
|
||||||
@@ -442,14 +442,14 @@ describe("CodeMode schema flexibility", () => {
|
|||||||
{
|
{
|
||||||
path: "adapter.call",
|
path: "adapter.call",
|
||||||
description: "Call an adapter-described tool",
|
description: "Call an adapter-described tool",
|
||||||
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<void>",
|
signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise<unknown>",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
// JSON Schema is render-only: mistyped input passes through unvalidated.
|
// JSON Schema is render-only: mistyped input passes through unvalidated.
|
||||||
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
|
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (result.ok) expect(result.value).toBeNull()
|
if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } })
|
||||||
expect(observed).toStrictEqual([{ id: 42 }])
|
expect(observed).toStrictEqual([{ id: 42 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -458,7 +458,7 @@ describe("CodeMode schema flexibility", () => {
|
|||||||
const call = Tool.make({
|
const call = Tool.make({
|
||||||
description: "Observe raw input",
|
description: "Observe raw input",
|
||||||
input: { type: "object" },
|
input: { type: "object" },
|
||||||
execute: (input) =>
|
run: (input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
observed.push(input)
|
observed.push(input)
|
||||||
return "ok"
|
return "ok"
|
||||||
@@ -483,7 +483,7 @@ describe("CodeMode schema flexibility", () => {
|
|||||||
const find = Tool.make({
|
const find = Tool.make({
|
||||||
description: "Find things",
|
description: "Find things",
|
||||||
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
|
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
|
||||||
execute: (input) =>
|
run: (input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
observed.push(input)
|
observed.push(input)
|
||||||
return "ok"
|
return "ok"
|
||||||
@@ -517,7 +517,7 @@ describe("CodeMode schema flexibility", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
execute: () => Effect.succeed({ login: "kit", id: 7 }),
|
run: () => Effect.succeed({ login: "kit", id: 7 }),
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({ tools: { users: { lookup } } })
|
const runtime = CodeMode.make({ tools: { users: { lookup } } })
|
||||||
|
|
||||||
@@ -534,18 +534,18 @@ describe("CodeMode schema flexibility", () => {
|
|||||||
if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 })
|
if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Effect Schema output without an input transform renders void when omitted", async () => {
|
test("Effect Schema output without an input transform still renders unknown when omitted", async () => {
|
||||||
const ping = Tool.make({
|
const ping = Tool.make({
|
||||||
description: "Ping",
|
description: "Ping",
|
||||||
input: Schema.Struct({ host: Schema.String }),
|
input: Schema.Struct({ host: Schema.String }),
|
||||||
execute: () => Effect.succeed("pong"),
|
run: () => Effect.succeed("pong"),
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({ tools: { net: { ping } } })
|
const runtime = CodeMode.make({ tools: { net: { ping } } })
|
||||||
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<void>")
|
expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise<unknown>")
|
||||||
|
|
||||||
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
|
const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`))
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (result.ok) expect(result.value).toBeNull()
|
if (result.ok) expect(result.value).toBe("pong")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -554,7 +554,7 @@ describe("CodeMode public contract", () => {
|
|||||||
description: "Look up an order by ID",
|
description: "Look up an order by ID",
|
||||||
input: Schema.Struct({ id: Schema.String }),
|
input: Schema.Struct({ id: Schema.String }),
|
||||||
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
|
output: Schema.Struct({ id: Schema.String, status: Schema.String }),
|
||||||
execute: ({ id }) => Effect.succeed({ id, status: "open" }),
|
run: ({ id }) => Effect.succeed({ id, status: "open" }),
|
||||||
})
|
})
|
||||||
const tools = { orders: { lookup } }
|
const tools = { orders: { lookup } }
|
||||||
const source = `return await tools.orders.lookup({ id: "order_42" })`
|
const source = `return await tools.orders.lookup({ id: "order_42" })`
|
||||||
@@ -577,7 +577,7 @@ describe("CodeMode public contract", () => {
|
|||||||
description: "echo",
|
description: "echo",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Number,
|
output: Schema.Number,
|
||||||
execute: () => Effect.succeed(1),
|
run: () => Effect.succeed(1),
|
||||||
})
|
})
|
||||||
const effect = CodeMode.execute({
|
const effect = CodeMode.execute({
|
||||||
tools: { host: { echo } },
|
tools: { host: { echo } },
|
||||||
@@ -629,47 +629,12 @@ describe("CodeMode public contract", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders equivalent catalogs identically regardless of tool insertion order", () => {
|
|
||||||
const alpha = Tool.make({
|
|
||||||
description: "Alpha tool",
|
|
||||||
input: Schema.Struct({}),
|
|
||||||
output: Schema.Void,
|
|
||||||
execute: () => Effect.void,
|
|
||||||
})
|
|
||||||
const zeta = Tool.make({
|
|
||||||
description: "Zeta tool",
|
|
||||||
input: Schema.Struct({}),
|
|
||||||
output: Schema.Void,
|
|
||||||
execute: () => Effect.void,
|
|
||||||
})
|
|
||||||
const first = CodeMode.make({ tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } } })
|
|
||||||
const second = CodeMode.make({ tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } } })
|
|
||||||
|
|
||||||
expect(first.catalog()).toStrictEqual(second.catalog())
|
|
||||||
expect(first.instructions()).toBe(second.instructions())
|
|
||||||
expect(first.catalog().map((tool) => tool.path)).toEqual(["alpha.alpha", "alpha.zeta", "zeta.alpha", "zeta.zeta"])
|
|
||||||
|
|
||||||
for (const catalogBudget of [0, 10, 20, 40]) {
|
|
||||||
expect(
|
|
||||||
CodeMode.make({
|
|
||||||
tools: { zeta: { zeta, alpha }, alpha: { zeta, alpha } },
|
|
||||||
discovery: { catalogBudget },
|
|
||||||
}).instructions(),
|
|
||||||
).toBe(
|
|
||||||
CodeMode.make({
|
|
||||||
tools: { alpha: { alpha, zeta }, zeta: { alpha, zeta } },
|
|
||||||
discovery: { catalogBudget },
|
|
||||||
}).instructions(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
|
test("renders bracket notation for tool names that are not JavaScript identifiers", async () => {
|
||||||
const resolveLibrary = Tool.make({
|
const resolveLibrary = Tool.make({
|
||||||
description: "Resolve a library ID",
|
description: "Resolve a library ID",
|
||||||
input: Schema.Struct({ libraryName: Schema.String }),
|
input: Schema.Struct({ libraryName: Schema.String }),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
|
run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`),
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||||
|
|
||||||
@@ -795,18 +760,18 @@ describe("CodeMode public contract", () => {
|
|||||||
expect(instructions).not.toContain("search(")
|
expect(instructions).not.toContain("search(")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses one ranked search returning complete tools for large catalogs", async () => {
|
test("uses one ranked search returning complete definitions for large catalogs", async () => {
|
||||||
const upload = Tool.make({
|
const upload = Tool.make({
|
||||||
description: "Upload one readable local file to the current Discord thread",
|
description: "Upload one readable local file to the current Discord thread",
|
||||||
input: Schema.Struct({ path: Schema.String }),
|
input: Schema.Struct({ path: Schema.String }),
|
||||||
output: Schema.Struct({ sent: Schema.Boolean }),
|
output: Schema.Struct({ sent: Schema.Boolean }),
|
||||||
execute: () => Effect.succeed({ sent: true }),
|
run: () => Effect.succeed({ sent: true }),
|
||||||
})
|
})
|
||||||
const generate = Tool.make({
|
const generate = Tool.make({
|
||||||
description: "Generate an image and upload it to the current Discord thread",
|
description: "Generate an image and upload it to the current Discord thread",
|
||||||
input: Schema.Struct({ prompt: Schema.String }),
|
input: Schema.Struct({ prompt: Schema.String }),
|
||||||
output: Schema.Struct({ sent: Schema.Boolean }),
|
output: Schema.Struct({ sent: Schema.Boolean }),
|
||||||
execute: () => Effect.succeed({ sent: true }),
|
run: () => Effect.succeed({ sent: true }),
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({
|
const runtime = CodeMode.make({
|
||||||
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
||||||
@@ -900,7 +865,7 @@ describe("CodeMode public contract", () => {
|
|||||||
description: `Numbered tool ${index}`,
|
description: `Numbered tool ${index}`,
|
||||||
input: Schema.Struct({ id: Schema.String }),
|
input: Schema.Struct({ id: Schema.String }),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.succeed("ok"),
|
run: () => Effect.succeed("ok"),
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({
|
const runtime = CodeMode.make({
|
||||||
tools: {
|
tools: {
|
||||||
@@ -946,7 +911,7 @@ describe("CodeMode public contract", () => {
|
|||||||
description,
|
description,
|
||||||
input: Schema.Struct({ id: Schema.String }),
|
input: Schema.Struct({ id: Schema.String }),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.succeed("ok"),
|
run: () => Effect.succeed("ok"),
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({
|
const runtime = CodeMode.make({
|
||||||
tools: {
|
tools: {
|
||||||
@@ -989,13 +954,13 @@ describe("CodeMode public contract", () => {
|
|||||||
properties: { attachment: { type: "string", description: "Local path of the payload to send" } },
|
properties: { attachment: { type: "string", description: "Local path of the payload to send" } },
|
||||||
required: ["attachment"],
|
required: ["attachment"],
|
||||||
},
|
},
|
||||||
execute: () => Effect.succeed("ok"),
|
run: () => Effect.succeed("ok"),
|
||||||
})
|
})
|
||||||
const other = Tool.make({
|
const other = Tool.make({
|
||||||
description: "Rename the workspace",
|
description: "Rename the workspace",
|
||||||
input: Schema.Struct({ name: Schema.String }),
|
input: Schema.Struct({ name: Schema.String }),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.succeed("ok"),
|
run: () => Effect.succeed("ok"),
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({ tools: { files: { upload, other } } })
|
const runtime = CodeMode.make({ tools: { files: { upload, other } } })
|
||||||
|
|
||||||
@@ -1025,7 +990,7 @@ describe("CodeMode public contract", () => {
|
|||||||
description,
|
description,
|
||||||
input: Schema.Struct({ id: Schema.String }),
|
input: Schema.Struct({ id: Schema.String }),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.succeed("ok"),
|
run: () => Effect.succeed("ok"),
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({
|
const runtime = CodeMode.make({
|
||||||
tools: {
|
tools: {
|
||||||
@@ -1064,7 +1029,7 @@ describe("CodeMode public contract", () => {
|
|||||||
description,
|
description,
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.succeed("ok"),
|
run: () => Effect.succeed("ok"),
|
||||||
})
|
})
|
||||||
// Deliberately declared out of alphabetical order.
|
// Deliberately declared out of alphabetical order.
|
||||||
const runtime = CodeMode.make({
|
const runtime = CodeMode.make({
|
||||||
@@ -1106,7 +1071,7 @@ describe("CodeMode public contract", () => {
|
|||||||
description: "Cheap",
|
description: "Cheap",
|
||||||
input: Schema.Struct({ q: Schema.String }),
|
input: Schema.Struct({ q: Schema.String }),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.succeed("ok"),
|
run: () => Effect.succeed("ok"),
|
||||||
})
|
})
|
||||||
const expensive = Tool.make({
|
const expensive = Tool.make({
|
||||||
description:
|
description:
|
||||||
@@ -1116,7 +1081,7 @@ describe("CodeMode public contract", () => {
|
|||||||
anotherEvenLongerParameterName: Schema.Number,
|
anotherEvenLongerParameterName: Schema.Number,
|
||||||
}),
|
}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.succeed("ok"),
|
run: () => Effect.succeed("ok"),
|
||||||
})
|
})
|
||||||
// Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2
|
// Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2
|
||||||
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
|
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
|
||||||
@@ -1147,7 +1112,7 @@ describe("CodeMode public contract", () => {
|
|||||||
},
|
},
|
||||||
required: ["id"],
|
required: ["id"],
|
||||||
} as const,
|
} as const,
|
||||||
execute: () => Effect.succeed("ok"),
|
run: () => Effect.succeed("ok"),
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({
|
const runtime = CodeMode.make({
|
||||||
tools: { records: { lookup: documented } },
|
tools: { records: { lookup: documented } },
|
||||||
@@ -1165,7 +1130,7 @@ describe("CodeMode public contract", () => {
|
|||||||
description: "Double a number",
|
description: "Double a number",
|
||||||
input: Schema.Struct({ value: Schema.NumberFromString }),
|
input: Schema.Struct({ value: Schema.NumberFromString }),
|
||||||
output: Schema.NumberFromString,
|
output: Schema.NumberFromString,
|
||||||
execute: ({ value }) =>
|
run: ({ value }) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
observed.push(value)
|
observed.push(value)
|
||||||
return String(value * 2)
|
return String(value * 2)
|
||||||
@@ -1261,7 +1226,7 @@ describe("CodeMode public contract", () => {
|
|||||||
description: "Count invocations",
|
description: "Count invocations",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Number,
|
output: Schema.Number,
|
||||||
execute: () => Effect.succeed(1),
|
run: () => Effect.succeed(1),
|
||||||
})
|
})
|
||||||
const result = await Effect.runPromise(
|
const result = await Effect.runPromise(
|
||||||
CodeMode.execute({
|
CodeMode.execute({
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const echo = (description: string) =>
|
|||||||
description,
|
description,
|
||||||
input: Schema.Struct({ value: Schema.String }),
|
input: Schema.Struct({ value: Schema.String }),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: ({ value }) => Effect.succeed(value),
|
run: ({ value }) => Effect.succeed(value),
|
||||||
})
|
})
|
||||||
|
|
||||||
const tools = {
|
const tools = {
|
||||||
|
|||||||
@@ -143,7 +143,12 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
const remove = toolAt(api.tools, "users.remove")
|
const remove = toolAt(api.tools, "users.remove")
|
||||||
|
|
||||||
expect(api.skipped).toEqual([])
|
expect(api.skipped).toEqual([])
|
||||||
if (!Tool.isTool(get) || !Tool.isTool(create) || !Tool.isTool(search) || !Tool.isTool(remove)) {
|
if (
|
||||||
|
!Tool.isDefinition(get) ||
|
||||||
|
!Tool.isDefinition(create) ||
|
||||||
|
!Tool.isDefinition(search) ||
|
||||||
|
!Tool.isDefinition(remove)
|
||||||
|
) {
|
||||||
throw new Error("happy-path fixture did not generate every operation")
|
throw new Error("happy-path fixture did not generate every operation")
|
||||||
}
|
}
|
||||||
expect(inputTypeScript(get)).toBe(
|
expect(inputTypeScript(get)).toBe(
|
||||||
@@ -236,23 +241,23 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
|
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
|
||||||
|
|
||||||
const sessionGet = toolAt(result.tools, "v2.session.get")
|
const sessionGet = toolAt(result.tools, "v2.session.get")
|
||||||
expect(Tool.isTool(sessionGet)).toBe(true)
|
expect(Tool.isDefinition(sessionGet)).toBe(true)
|
||||||
if (!Tool.isTool(sessionGet)) throw new Error("v2.session.get was not generated")
|
if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated")
|
||||||
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
|
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
|
||||||
expect(outputTypeScript(sessionGet)).toContain("id: string")
|
expect(outputTypeScript(sessionGet)).toContain("id: string")
|
||||||
expect(outputTypeScript(sessionGet)).toContain("additions: number")
|
expect(outputTypeScript(sessionGet)).toContain("additions: number")
|
||||||
|
|
||||||
const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
|
const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
|
||||||
expect(Tool.isTool(switchAgent)).toBe(true)
|
expect(Tool.isDefinition(switchAgent)).toBe(true)
|
||||||
if (!Tool.isTool(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
|
if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
|
||||||
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
|
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
|
||||||
|
|
||||||
const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
|
const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
|
||||||
expect(Tool.isTool(instructionPut)).toBe(true)
|
expect(Tool.isDefinition(instructionPut)).toBe(true)
|
||||||
if (!Tool.isTool(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
|
if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
|
||||||
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
|
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
|
||||||
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
|
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
|
||||||
expect(Tool.isTool(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
|
expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
|
||||||
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
|
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
|
||||||
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
|
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
|
||||||
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
|
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
|
||||||
@@ -273,9 +278,9 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(Tool.isTool(toolAt(result.tools, "group.item"))).toBe(true)
|
expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true)
|
||||||
expect(Tool.isTool(toolAt(result.tools, "group_item_2"))).toBe(true)
|
expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true)
|
||||||
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
|
expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("synthesizes flat operation IDs from methods and paths", () => {
|
test("synthesizes flat operation IDs from methods and paths", () => {
|
||||||
@@ -300,7 +305,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
"deleteUsersById",
|
"deleteUsersById",
|
||||||
"getOrganizationsByOrganizationidUsersById",
|
"getOrganizationsByOrganizationidUsersById",
|
||||||
]) {
|
]) {
|
||||||
expect(Tool.isTool(toolAt(tools, path))).toBe(true)
|
expect(Tool.isDefinition(toolAt(tools, path))).toBe(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -325,7 +330,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
expect(inputTypeScript(tool)).toBe("{ limit: number }")
|
expect(inputTypeScript(tool)).toBe("{ limit: number }")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -353,8 +358,8 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
})
|
})
|
||||||
const search = toolAt(result.tools, "search")
|
const search = toolAt(result.tools, "search")
|
||||||
|
|
||||||
expect(Tool.isTool(search)).toBe(true)
|
expect(Tool.isDefinition(search)).toBe(true)
|
||||||
if (!Tool.isTool(search)) throw new Error("search was not generated")
|
if (!Tool.isDefinition(search)) throw new Error("search was not generated")
|
||||||
expect(inputTypeScript(search)).toBe("{ value?: string | null }")
|
expect(inputTypeScript(search)).toBe("{ value?: string | null }")
|
||||||
const schema: unknown = search.input
|
const schema: unknown = search.input
|
||||||
const input = isRecord(schema) ? schema : {}
|
const input = isRecord(schema) ? schema : {}
|
||||||
@@ -392,14 +397,14 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!Tool.isTool(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
|
if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
|
||||||
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
|
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("projects read-only and write-only properties by schema direction", () => {
|
test("projects read-only and write-only properties by schema direction", () => {
|
||||||
for (const version of ["3.0.3", "3.1.0"]) {
|
for (const version of ["3.0.3", "3.1.0"]) {
|
||||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
|
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
|
||||||
if (!Tool.isTool(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
|
||||||
throw new Error(`users.create was not generated for OpenAPI ${version}`)
|
throw new Error(`users.create was not generated for OpenAPI ${version}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +467,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||||
})
|
})
|
||||||
@@ -513,7 +518,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
const record = isRecord(properties.record) ? properties.record : {}
|
const record = isRecord(properties.record) ? properties.record : {}
|
||||||
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||||
@@ -562,7 +567,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||||
})
|
})
|
||||||
@@ -602,7 +607,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||||
const node = isRecord(definitions.Node) ? definitions.Node : {}
|
const node = isRecord(definitions.Node) ? definitions.Node : {}
|
||||||
|
|
||||||
@@ -643,7 +648,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||||
const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
|
const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
|
||||||
|
|
||||||
@@ -681,7 +686,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||||
}
|
}
|
||||||
@@ -720,7 +725,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
|
const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
|
||||||
|
|
||||||
@@ -758,7 +763,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
|
const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
|
||||||
const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
|
const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
|
||||||
@@ -802,7 +807,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
const record = isRecord(properties.record) ? properties.record : {}
|
const record = isRecord(properties.record) ? properties.record : {}
|
||||||
|
|
||||||
@@ -831,7 +836,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
|
expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
|
||||||
})
|
})
|
||||||
@@ -861,7 +866,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
|
expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
|
||||||
})
|
})
|
||||||
@@ -896,7 +901,7 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
const body = isRecord(properties.body) ? properties.body : {}
|
const body = isRecord(properties.body) ? properties.body : {}
|
||||||
const allOf = Array.isArray(body.allOf) ? body.allOf : []
|
const allOf = Array.isArray(body.allOf) ? body.allOf : []
|
||||||
@@ -918,11 +923,11 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
|
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
|
||||||
if (!Tool.isTool(tool)) throw new Error("users.create was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated")
|
||||||
|
|
||||||
const result = await Effect.runPromise(
|
const result = await Effect.runPromise(
|
||||||
tool
|
tool
|
||||||
.execute({
|
.run({
|
||||||
id: "ignored-top-level",
|
id: "ignored-top-level",
|
||||||
generated: "ignored-generated",
|
generated: "ignored-generated",
|
||||||
name: "Ada",
|
name: "Ada",
|
||||||
@@ -1017,12 +1022,10 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
test("serializes deep-object query parameters from the opencode fixture", async () => {
|
test("serializes deep-object query parameters from the opencode fixture", async () => {
|
||||||
const client = recordingClient(() => json({ directory: "/tmp" }))
|
const client = recordingClient(() => json({ directory: "/tmp" }))
|
||||||
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
|
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
|
||||||
if (!Tool.isTool(location)) throw new Error("v2.location.get was not generated")
|
if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated")
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
location
|
location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)),
|
||||||
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
|
|
||||||
.pipe(Effect.provide(client.layer)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const url = new URL(client.requests[0]!.url)
|
const url = new URL(client.requests[0]!.url)
|
||||||
@@ -1055,11 +1058,11 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const tool = toolAt(result.tools, "items")
|
const tool = toolAt(result.tools, "items")
|
||||||
if (!Tool.isTool(tool)) throw new Error("items was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("items was not generated")
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
tool
|
tool
|
||||||
.execute({
|
.run({
|
||||||
keys: ["a!", "b*"],
|
keys: ["a!", "b*"],
|
||||||
tags: ["x", "y"],
|
tags: ["x", "y"],
|
||||||
filter: { state: "open", page: 2 },
|
filter: { state: "open", page: 2 },
|
||||||
@@ -1078,9 +1081,9 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
expect(url.searchParams.get("nullable")).toBe("null")
|
expect(url.searchParams.get("nullable")).toBe("null")
|
||||||
expect(url.searchParams.get("constructor")).toBe("safe")
|
expect(url.searchParams.get("constructor")).toBe("safe")
|
||||||
expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
|
expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
|
||||||
await expect(
|
await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||||
Effect.runPromise(tool.execute({ keys: [undefined] }).pipe(Effect.provide(client.layer))),
|
"unsupported nested value",
|
||||||
).rejects.toThrow("unsupported nested value")
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves ordered exploded and deep-object query parameters", async () => {
|
test("preserves ordered exploded and deep-object query parameters", async () => {
|
||||||
@@ -1098,11 +1101,11 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
tool
|
tool
|
||||||
.execute({
|
.run({
|
||||||
tags: ["first value", "second&value"],
|
tags: ["first value", "second&value"],
|
||||||
filter: { state: "open now", page: 2 },
|
filter: { state: "open now", page: 2 },
|
||||||
location: { directory: "/tmp/a b", workspace: "work&1" },
|
location: { directory: "/tmp/a b", workspace: "work&1" },
|
||||||
@@ -1113,14 +1116,14 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
expect(client.requests[0]?.url).toBe(
|
expect(client.requests[0]?.url).toBe(
|
||||||
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
|
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
|
||||||
)
|
)
|
||||||
await expect(Effect.runPromise(tool.execute({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||||
"Parameter 'tags' contains an unsupported nested value.",
|
"Parameter 'tags' contains an unsupported nested value.",
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
Effect.runPromise(tool.execute({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
|
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
|
||||||
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
|
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
|
||||||
await expect(
|
await expect(
|
||||||
Effect.runPromise(tool.execute({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
|
Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
|
||||||
).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.")
|
).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.")
|
||||||
expect(client.requests).toHaveLength(1)
|
expect(client.requests).toHaveLength(1)
|
||||||
})
|
})
|
||||||
@@ -1200,9 +1203,9 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"getTest",
|
"getTest",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
|
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
|
||||||
|
|
||||||
expect(inputTypeScript(tool)).toBe("{}")
|
expect(inputTypeScript(tool)).toBe("{}")
|
||||||
expect(client.requests[0]!.headers.authorization).toBe("Bearer secret")
|
expect(client.requests[0]!.headers.authorization).toBe("Bearer secret")
|
||||||
@@ -1237,9 +1240,9 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools,
|
authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(prototype)) throw new Error("prototype auth tool was not generated")
|
if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated")
|
||||||
|
|
||||||
await Effect.runPromise(prototype.execute({}).pipe(Effect.provide(client.layer)))
|
await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer)))
|
||||||
expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
|
expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
|
||||||
|
|
||||||
const duplicate = toolAt(
|
const duplicate = toolAt(
|
||||||
@@ -1249,8 +1252,8 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(duplicate)) throw new Error("duplicate auth tool was not generated")
|
if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
|
||||||
await expect(Effect.runPromise(duplicate.execute({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||||
"multiple credentials",
|
"multiple credentials",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1275,8 +1278,8 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const alternativeTool = toolAt(alternative.tools, "test")
|
const alternativeTool = toolAt(alternative.tools, "test")
|
||||||
if (!Tool.isTool(alternativeTool)) throw new Error("supported auth alternative was not generated")
|
if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated")
|
||||||
await Effect.runPromise(alternativeTool.execute({}).pipe(Effect.provide(client.layer)))
|
await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer)))
|
||||||
expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret")
|
expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1287,9 +1290,9 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
servers: [{ url: "https://document.example" }],
|
servers: [{ url: "https://document.example" }],
|
||||||
} satisfies Document
|
} satisfies Document
|
||||||
const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test")
|
const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test")
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))
|
await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
|
||||||
expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
|
expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
|
||||||
|
|
||||||
const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
|
const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
|
||||||
@@ -1360,10 +1363,10 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
Effect.runPromise(tool.execute({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
|
Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
|
||||||
).rejects.toThrow("unsupported nested value")
|
).rejects.toThrow("unsupported nested value")
|
||||||
expect(resolutions).toEqual([])
|
expect(resolutions).toEqual([])
|
||||||
expect(client.requests).toEqual([])
|
expect(client.requests).toEqual([])
|
||||||
@@ -1386,33 +1389,33 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
}).tools,
|
}).tools,
|
||||||
"test",
|
"test",
|
||||||
)
|
)
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
await Effect.runPromise(tool.execute({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
|
await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
|
||||||
expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json")
|
expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json")
|
||||||
const cyclic: Record<string, unknown> = {}
|
const cyclic: Record<string, unknown> = {}
|
||||||
cyclic.self = cyclic
|
cyclic.self = cyclic
|
||||||
await expect(Effect.runPromise(tool.execute({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||||
"Invalid JSON body",
|
"Invalid JSON body",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects oversized and malformed JSON responses", async () => {
|
test("rejects oversized and malformed JSON responses", async () => {
|
||||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test")
|
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test")
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
const oversized = recordingClient(
|
const oversized = recordingClient(
|
||||||
() => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
|
() => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
|
||||||
)
|
)
|
||||||
const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
|
const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
|
||||||
const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
|
const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
|
||||||
|
|
||||||
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
|
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
|
||||||
"response exceeds 50 MiB",
|
"response exceeds 50 MiB",
|
||||||
)
|
)
|
||||||
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
|
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
|
||||||
"returned malformed JSON",
|
"returned malformed JSON",
|
||||||
)
|
)
|
||||||
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
|
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
|
||||||
"response exceeds 50 MiB",
|
"response exceeds 50 MiB",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1425,11 +1428,11 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test")
|
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test")
|
||||||
if (!Tool.isTool(tool)) throw new Error("test was not generated")
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } }))
|
const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } }))
|
||||||
|
|
||||||
expect(outputTypeScript(tool)).toBe("string | null")
|
expect(outputTypeScript(tool)).toBe("string | null")
|
||||||
await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
|
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("fails missing required parameters before auth and network", async () => {
|
test("fails missing required parameters before auth and network", async () => {
|
||||||
@@ -1494,13 +1497,13 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
const update = toolAt(tools, "things.update")
|
const update = toolAt(tools, "things.update")
|
||||||
const echo = toolAt(tools, "echo")
|
const echo = toolAt(tools, "echo")
|
||||||
|
|
||||||
expect(Tool.isTool(update)).toBe(true)
|
expect(Tool.isDefinition(update)).toBe(true)
|
||||||
if (!Tool.isTool(update)) throw new Error("things.update was not generated")
|
if (!Tool.isDefinition(update)) throw new Error("things.update was not generated")
|
||||||
expect(inputTypeScript(update)).toBe(
|
expect(inputTypeScript(update)).toBe(
|
||||||
"{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }",
|
"{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }",
|
||||||
)
|
)
|
||||||
expect(Tool.isTool(echo)).toBe(true)
|
expect(Tool.isDefinition(echo)).toBe(true)
|
||||||
if (!Tool.isTool(echo)) throw new Error("echo was not generated")
|
if (!Tool.isDefinition(echo)) throw new Error("echo was not generated")
|
||||||
expect(inputTypeScript(echo)).toBe("{ body: string }")
|
expect(inputTypeScript(echo)).toBe("{ body: string }")
|
||||||
|
|
||||||
const runtime = CodeMode.make({ tools })
|
const runtime = CodeMode.make({ tools })
|
||||||
@@ -1581,13 +1584,13 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
|
|
||||||
for (const name of ["optional", "dictionary", "composed", "nullable"]) {
|
for (const name of ["optional", "dictionary", "composed", "nullable"]) {
|
||||||
const tool = toolAt(tools, `body.${name}`)
|
const tool = toolAt(tools, `body.${name}`)
|
||||||
expect(Tool.isTool(tool)).toBe(true)
|
expect(Tool.isDefinition(tool)).toBe(true)
|
||||||
if (!Tool.isTool(tool)) throw new Error(`body.${name} was not generated`)
|
if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`)
|
||||||
const input = isRecord(tool.input) ? tool.input : {}
|
const input = isRecord(tool.input) ? tool.input : {}
|
||||||
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"])
|
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"])
|
||||||
}
|
}
|
||||||
const optional = toolAt(tools, "body.optional")
|
const optional = toolAt(tools, "body.optional")
|
||||||
if (!Tool.isTool(optional)) throw new Error("body.optional was not generated")
|
if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated")
|
||||||
expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }")
|
expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const echoTool = (trace: Trace) =>
|
|||||||
description: "Echo an id immediately",
|
description: "Echo an id immediately",
|
||||||
input: Schema.Struct({ id: Schema.Number }),
|
input: Schema.Struct({ id: Schema.Number }),
|
||||||
output: Schema.Number,
|
output: Schema.Number,
|
||||||
execute: ({ id }) =>
|
run: ({ id }) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
trace.starts.push(id)
|
trace.starts.push(id)
|
||||||
trace.completed += 1
|
trace.completed += 1
|
||||||
@@ -46,7 +46,7 @@ const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>)
|
|||||||
description: "Echo an id once its gate opens",
|
description: "Echo an id once its gate opens",
|
||||||
input: Schema.Struct({ id: Schema.Number }),
|
input: Schema.Struct({ id: Schema.Number }),
|
||||||
output: Schema.Number,
|
output: Schema.Number,
|
||||||
execute: ({ id }) =>
|
run: ({ id }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
trace.starts.push(id)
|
trace.starts.push(id)
|
||||||
trace.active += 1
|
trace.active += 1
|
||||||
@@ -70,7 +70,7 @@ const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
|
|||||||
description: "Open the gate for an id",
|
description: "Open the gate for an id",
|
||||||
input: Schema.Struct({ id: Schema.Number }),
|
input: Schema.Struct({ id: Schema.Number }),
|
||||||
output: Schema.Boolean,
|
output: Schema.Boolean,
|
||||||
execute: ({ id }) => Deferred.succeed(gate(id), undefined),
|
run: ({ id }) => Deferred.succeed(gate(id), undefined),
|
||||||
})
|
})
|
||||||
|
|
||||||
const pendingTool = (trace: Trace) =>
|
const pendingTool = (trace: Trace) =>
|
||||||
@@ -78,7 +78,7 @@ const pendingTool = (trace: Trace) =>
|
|||||||
description: "Never settle",
|
description: "Never settle",
|
||||||
input: Schema.Struct({ id: Schema.Number }),
|
input: Schema.Struct({ id: Schema.Number }),
|
||||||
output: Schema.Number,
|
output: Schema.Number,
|
||||||
execute: ({ id }) =>
|
run: ({ id }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
trace.starts.push(id)
|
trace.starts.push(id)
|
||||||
trace.active += 1
|
trace.active += 1
|
||||||
@@ -98,14 +98,14 @@ const failingTool = Tool.make({
|
|||||||
description: "Always refuse",
|
description: "Always refuse",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.fail(toolError("Lookup refused")),
|
run: () => Effect.fail(toolError("Lookup refused")),
|
||||||
})
|
})
|
||||||
|
|
||||||
const interruptedTool = Tool.make({
|
const interruptedTool = Tool.make({
|
||||||
description: "Interrupt this call",
|
description: "Interrupt this call",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.interrupt,
|
run: () => Effect.interrupt,
|
||||||
})
|
})
|
||||||
|
|
||||||
const completedTool = (trace: Trace) =>
|
const completedTool = (trace: Trace) =>
|
||||||
@@ -113,7 +113,7 @@ const completedTool = (trace: Trace) =>
|
|||||||
description: "Return the number of completed calls",
|
description: "Return the number of completed calls",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.Number,
|
output: Schema.Number,
|
||||||
execute: () => Effect.succeed(trace.completed),
|
run: () => Effect.succeed(trace.completed),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
|
/** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */
|
||||||
@@ -122,7 +122,7 @@ const stubbornTool = (trace: Trace) =>
|
|||||||
description: "Never settle; clean up slowly when interrupted",
|
description: "Never settle; clean up slowly when interrupted",
|
||||||
input: Schema.Struct({ cleanupMs: Schema.Number }),
|
input: Schema.Struct({ cleanupMs: Schema.Number }),
|
||||||
output: Schema.Number,
|
output: Schema.Number,
|
||||||
execute: ({ cleanupMs }) =>
|
run: ({ cleanupMs }) =>
|
||||||
Effect.never.pipe(
|
Effect.never.pipe(
|
||||||
Effect.onInterrupt(() =>
|
Effect.onInterrupt(() =>
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ const listIssues = Tool.make({
|
|||||||
},
|
},
|
||||||
required: ["owner"],
|
required: ["owner"],
|
||||||
},
|
},
|
||||||
output: {},
|
run: () => Effect.succeed("[]"),
|
||||||
execute: () => Effect.succeed("[]"),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
|
// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
|
||||||
@@ -32,7 +31,7 @@ const lookupOrder = Tool.make({
|
|||||||
output: Schema.Struct({
|
output: Schema.Struct({
|
||||||
status: Schema.String.annotate({ description: "Current order status" }),
|
status: Schema.String.annotate({ description: "Current order status" }),
|
||||||
}),
|
}),
|
||||||
execute: () => Effect.succeed({ status: "open" }),
|
run: () => Effect.succeed({ status: "open" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("pretty signature rendering", () => {
|
describe("pretty signature rendering", () => {
|
||||||
@@ -262,7 +261,7 @@ describe("non-identifier property names render as quoted keys", () => {
|
|||||||
properties: { "content-type": { type: "string" } },
|
properties: { "content-type": { type: "string" } },
|
||||||
required: ["content-type"],
|
required: ["content-type"],
|
||||||
} as const,
|
} as const,
|
||||||
execute: () => Effect.succeed({ "content-type": "text/plain" }),
|
run: () => Effect.succeed({ "content-type": "text/plain" }),
|
||||||
})
|
})
|
||||||
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
|
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
|
||||||
expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
|
expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
|
||||||
@@ -273,7 +272,7 @@ describe("non-identifier property names render as quoted keys", () => {
|
|||||||
const tool = Tool.make({
|
const tool = Tool.make({
|
||||||
description: "Schema tool with awkward field names",
|
description: "Schema tool with awkward field names",
|
||||||
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
|
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
|
||||||
execute: () => Effect.succeed(null),
|
run: () => Effect.succeed(null),
|
||||||
})
|
})
|
||||||
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
|
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
|
||||||
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
|
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
|
||||||
@@ -307,7 +306,7 @@ describe("union schemas render every alternative", () => {
|
|||||||
},
|
},
|
||||||
} as const,
|
} as const,
|
||||||
output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const,
|
output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const,
|
||||||
execute: () => Effect.succeed(1),
|
run: () => Effect.succeed(1),
|
||||||
})
|
})
|
||||||
expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
|
expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
|
||||||
expect(outputTypeScript(tool)).toBe("number | boolean")
|
expect(outputTypeScript(tool)).toBe("number | boolean")
|
||||||
@@ -418,8 +417,7 @@ describe("non-identifier tool paths", () => {
|
|||||||
},
|
},
|
||||||
required: ["query", "libraryName"],
|
required: ["query", "libraryName"],
|
||||||
} as const,
|
} as const,
|
||||||
output: {},
|
run: () => Effect.succeed("/reactjs/react.dev"),
|
||||||
execute: () => Effect.succeed("/reactjs/react.dev"),
|
|
||||||
})
|
})
|
||||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||||
|
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ describe("RegExp", () => {
|
|||||||
description: "Decorate a string",
|
description: "Decorate a string",
|
||||||
input: Schema.String,
|
input: Schema.String,
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: (input) => Effect.succeed(`[${input}]`),
|
run: (input) => Effect.succeed(`[${input}]`),
|
||||||
})
|
})
|
||||||
const result = await Effect.runPromise(
|
const result = await Effect.runPromise(
|
||||||
CodeMode.execute({
|
CodeMode.execute({
|
||||||
@@ -1028,7 +1028,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
|
|||||||
const capture = Tool.make({
|
const capture = Tool.make({
|
||||||
description: "Capture the exact input the host receives",
|
description: "Capture the exact input the host receives",
|
||||||
input: { type: "object" },
|
input: { type: "object" },
|
||||||
execute: (input) =>
|
run: (input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
observed.push(input)
|
observed.push(input)
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const echo = (description: string, result: string) =>
|
|||||||
description,
|
description,
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.succeed(result),
|
run: () => Effect.succeed(result),
|
||||||
})
|
})
|
||||||
|
|
||||||
const value = async (runtime: CodeMode.Runtime, code: string) => {
|
const value = async (runtime: CodeMode.Runtime, code: string) => {
|
||||||
@@ -88,7 +88,7 @@ describe("callable namespaces", () => {
|
|||||||
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
|
expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a namespace without its own tool stays non-callable", async () => {
|
test("a namespace without its own definition stays non-callable", async () => {
|
||||||
const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } })
|
const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } })
|
||||||
const diagnostic = await failure(nested, `return await tools.issues({})`)
|
const diagnostic = await failure(nested, `return await tools.issues({})`)
|
||||||
expect(diagnostic.kind).toBe("UnknownTool")
|
expect(diagnostic.kind).toBe("UnknownTool")
|
||||||
@@ -106,7 +106,7 @@ describe("blocked member names on tool paths", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("tools may use blocked member names because path segments never touch real properties", async () => {
|
test("tools may use blocked member names because path segments never touch real properties", async () => {
|
||||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
|
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["prototype", "issues.constructor", "nested.__proto__"])
|
||||||
expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
|
expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
|
||||||
expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
|
expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
|
||||||
expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
|
expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
|
||||||
@@ -114,9 +114,9 @@ describe("blocked member names on tool paths", () => {
|
|||||||
expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"])
|
expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a literal __proto__ key cannot poison a namespace into a fake tool", async () => {
|
test("a literal __proto__ key cannot poison a namespace into a fake definition", async () => {
|
||||||
const poisoned = CodeMode.make({
|
const poisoned = CodeMode.make({
|
||||||
tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
|
tools: { ns: { "__proto__": echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
|
||||||
})
|
})
|
||||||
expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
|
expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
|
||||||
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
|
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
|
||||||
@@ -138,7 +138,7 @@ describe("empty segments", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("canonical path collisions", () => {
|
describe("canonical path collisions", () => {
|
||||||
test("the last tool supplied for a canonical path wins", async () => {
|
test("the last definition supplied for a canonical path wins", async () => {
|
||||||
const runtime = CodeMode.make({
|
const runtime = CodeMode.make({
|
||||||
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
|
tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
|
||||||
})
|
})
|
||||||
@@ -155,7 +155,8 @@ describe("canonical path collisions", () => {
|
|||||||
"issues.close": echo("Close issue", "closed"),
|
"issues.close": echo("Close issue", "closed"),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
|
// Catalog order follows first appearance of each canonical path.
|
||||||
|
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.list", "issues.get", "issues.close"])
|
||||||
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
|
expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
|
||||||
expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
|
expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
|
||||||
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
|
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
"@ai-sdk/google": "3.0.73",
|
"@ai-sdk/google": "3.0.73",
|
||||||
"@ai-sdk/google-vertex": "4.0.128",
|
"@ai-sdk/google-vertex": "4.0.128",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.51",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.84",
|
"@ai-sdk/openai": "3.0.84",
|
||||||
"@ai-sdk/openai-compatible": "2.0.41",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
"@effect/sql-sqlite-bun": "catalog:",
|
"@effect/sql-sqlite-bun": "catalog:",
|
||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@ff-labs/fff-bun": "0.10.1",
|
"@ff-labs/fff-bun": "0.9.4",
|
||||||
"@opencode-ai/codemode": "workspace:*",
|
"@opencode-ai/codemode": "workspace:*",
|
||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
|
|||||||
presencePenalty: request.generation?.presencePenalty,
|
presencePenalty: request.generation?.presencePenalty,
|
||||||
frequencyPenalty: request.generation?.frequencyPenalty,
|
frequencyPenalty: request.generation?.frequencyPenalty,
|
||||||
seed: request.generation?.seed,
|
seed: request.generation?.seed,
|
||||||
|
responseFormat: responseFormat(request),
|
||||||
tools: request.tools.map(tool),
|
tools: request.tools.map(tool),
|
||||||
toolChoice: toolChoice(request.toolChoice),
|
toolChoice: toolChoice(request.toolChoice),
|
||||||
headers: request.http?.headers,
|
headers: request.http?.headers,
|
||||||
@@ -526,6 +527,12 @@ function toolChoice(input: LLMRequest["toolChoice"]): LanguageModelV3ToolChoice
|
|||||||
return { type: input.type }
|
return { type: input.type }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function responseFormat(request: LLMRequest): LanguageModelV3CallOptions["responseFormat"] {
|
||||||
|
if (request.responseFormat?.type === "json")
|
||||||
|
return { type: "json", schema: request.responseFormat.schema as JSONSchema7 }
|
||||||
|
if (request.responseFormat) return { type: "text" }
|
||||||
|
}
|
||||||
|
|
||||||
function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined {
|
function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined {
|
||||||
if (!input) return undefined
|
if (!input) return undefined
|
||||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
||||||
@@ -659,12 +666,12 @@ function streamPartEvents(
|
|||||||
return Effect.succeed([
|
return Effect.succeed([
|
||||||
LLMEvent.stepFinish({
|
LLMEvent.stepFinish({
|
||||||
index: state.step++,
|
index: state.step++,
|
||||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
reason: finishReason(event.finishReason),
|
||||||
usage: usage(event.usage),
|
usage: usage(event.usage),
|
||||||
providerMetadata: providerMetadata(event.providerMetadata),
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
}),
|
}),
|
||||||
LLMEvent.finish({
|
LLMEvent.finish({
|
||||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
reason: finishReason(event.finishReason),
|
||||||
usage: usage(event.usage),
|
usage: usage(event.usage),
|
||||||
providerMetadata: providerMetadata(event.providerMetadata),
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const layer = Layer.effect(
|
|||||||
draft.providers.set(providerID, record)
|
draft.providers.set(providerID, record)
|
||||||
}
|
}
|
||||||
const model =
|
const model =
|
||||||
record.models.get(modelID) ?? (ModelV2.Info.default(providerID, modelID) as ModelV2.MutableInfo)
|
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
|
||||||
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
||||||
fn(model)
|
fn(model)
|
||||||
model.id = modelID
|
model.id = modelID
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ import { Context, Effect, Layer, Scope } from "effect"
|
|||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { PermissionV2 } from "./permission"
|
import { PermissionV2 } from "./permission"
|
||||||
import { ExecuteTool } from "./tool/execute"
|
import { ExecuteTool } from "./tool/execute"
|
||||||
import type { Any, Registration } from "./tool/tool"
|
import { permission, registrationEntries, type AnyTool } from "./tool/tool"
|
||||||
|
import { Tools } from "./tool/tools"
|
||||||
import { Wildcard } from "./util/wildcard"
|
import { Wildcard } from "./util/wildcard"
|
||||||
|
|
||||||
export interface Materialization {
|
export interface Materialization {
|
||||||
readonly tool?: Any
|
readonly tool?: AnyTool
|
||||||
readonly instructions?: string
|
readonly instructions?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly register: (
|
readonly register: (
|
||||||
registrations: ReadonlyArray<Registration & { readonly key: string }>,
|
tools: Readonly<Record<string, AnyTool>>,
|
||||||
|
options?: Tools.RegisterOptions,
|
||||||
) => Effect.Effect<void, never, Scope.Scope>
|
) => Effect.Effect<void, never, Scope.Scope>
|
||||||
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||||
}
|
}
|
||||||
@@ -24,28 +26,29 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
|
const local = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>
|
||||||
|
>()
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
register: Effect.fn("CodeMode.register")(function* (registrations) {
|
register: Effect.fn("CodeMode.register")(function* (tools, options) {
|
||||||
if (registrations.length === 0) return
|
const entries = registrationEntries(tools, options?.namespace)
|
||||||
|
if (entries.length === 0) return
|
||||||
yield* Effect.uninterruptible(
|
yield* Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const token = {}
|
const token = {}
|
||||||
for (const registration of registrations)
|
for (const entry of entries)
|
||||||
local.set(registration.key, [
|
local.set(entry.key, [
|
||||||
...(local.get(registration.key) ?? []),
|
...(local.get(entry.key) ?? []),
|
||||||
{
|
{ token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } },
|
||||||
token,
|
|
||||||
registration,
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
for (const registration of registrations) {
|
for (const entry of entries) {
|
||||||
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
|
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
|
||||||
if (remaining.length > 0) local.set(registration.key, remaining)
|
if (registrations.length > 0) local.set(entry.key, registrations)
|
||||||
else local.delete(registration.key)
|
else local.delete(entry.key)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -58,7 +61,7 @@ const layer = Layer.effect(
|
|||||||
for (const [name, entries] of local) {
|
for (const [name, entries] of local) {
|
||||||
const registration = entries.at(-1)?.registration
|
const registration = entries.at(-1)?.registration
|
||||||
if (!registration) continue
|
if (!registration) continue
|
||||||
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
|
const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action))
|
||||||
if (rule?.resource === "*" && rule.effect === "deny") continue
|
if (rule?.resource === "*" && rule.effect === "deny") continue
|
||||||
registrations.set(name, registration)
|
registrations.set(name, registration)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
|||||||
name: mcpCommandName(prompt.server, prompt.name),
|
name: mcpCommandName(prompt.server, prompt.name),
|
||||||
template: "",
|
template: "",
|
||||||
description: prompt.description,
|
description: prompt.description,
|
||||||
|
source: "mcp",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -98,7 +99,10 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
|||||||
return (yield* mcpCommands()).find((command) => command.name === name)
|
return (yield* mcpCommands()).find((command) => command.name === name)
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("CommandV2.list")(function* () {
|
list: Effect.fn("CommandV2.list")(function* () {
|
||||||
const commands = Array.from(state.get().commands.values()) as Info[]
|
const commands = Array.from(state.get().commands.values()).map((command) => ({
|
||||||
|
...command,
|
||||||
|
source: command.source ?? "command" as const,
|
||||||
|
}))
|
||||||
const names = new Set(commands.map((command) => command.name))
|
const names = new Set(commands.map((command) => command.name))
|
||||||
return [
|
return [
|
||||||
...commands,
|
...commands,
|
||||||
|
|||||||
-1
@@ -56,6 +56,5 @@ export const migrations = (
|
|||||||
import("./migration/20260710025429_instruction_sync"),
|
import("./migration/20260710025429_instruction_sync"),
|
||||||
import("./migration/20260716020354_kv"),
|
import("./migration/20260716020354_kv"),
|
||||||
import("./migration/20260722011141_delete_tool_progress_events"),
|
import("./migration/20260722011141_delete_tool_progress_events"),
|
||||||
import("./migration/20260722170000_canonical_tool_results"),
|
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
import { sql } from "drizzle-orm"
|
|
||||||
import { Effect, Schema } from "effect"
|
|
||||||
import type { DatabaseMigration } from "../migration"
|
|
||||||
|
|
||||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
|
||||||
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
|
|
||||||
const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json))
|
|
||||||
|
|
||||||
const object = (value: unknown): Record<string, unknown> => (isObject(value) ? value : {})
|
|
||||||
|
|
||||||
const stringify = (value: unknown) => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value, null, 2) ?? String(value)
|
|
||||||
} catch {
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentOf = (state: Record<string, unknown>) => (Array.isArray(state.content) ? state.content : [])
|
|
||||||
const resultOf = (state: Record<string, unknown>) =>
|
|
||||||
isObject(state.result) && "value" in state.result ? state.result.value : state.result
|
|
||||||
const metadataOf = (state: Record<string, unknown>) => {
|
|
||||||
if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0)
|
|
||||||
return { metadata: state.structured }
|
|
||||||
return isJsonObject(state.metadata) ? { metadata: state.metadata } : {}
|
|
||||||
}
|
|
||||||
const completedContent = (state: Record<string, unknown>) => {
|
|
||||||
const preserved = contentOf(state)
|
|
||||||
if (preserved.length > 0) return preserved
|
|
||||||
return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-time rewrite of projected tool rows into the canonical result shape:
|
|
||||||
* terminal states store model content plus optional metadata; the generic
|
|
||||||
* `structured` and `result` fields disappear. Provider-hosted result payloads
|
|
||||||
* move into provider-owned result state so hosted continuation survives.
|
|
||||||
* Pre-release durable event versions are intentionally left untouched.
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
id: "20260722170000_canonical_tool_results",
|
|
||||||
up(tx) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
// Keyset-paginated batches keep memory bounded: production databases hold
|
|
||||||
// gigabytes of assistant rows, and materializing them all at once was
|
|
||||||
// measured at a ~5GB RSS spike.
|
|
||||||
let cursor = ""
|
|
||||||
while (true) {
|
|
||||||
const messages = yield* tx.all<{ id: string; data: string }>(
|
|
||||||
sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`,
|
|
||||||
)
|
|
||||||
if (messages.length === 0) break
|
|
||||||
cursor = messages[messages.length - 1].id
|
|
||||||
yield* rewrite(tx, messages)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
} satisfies DatabaseMigration.Migration
|
|
||||||
|
|
||||||
function rewrite(tx: Parameters<DatabaseMigration.Migration["up"]>[0], messages: { id: string; data: string }[]) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
for (const row of messages) {
|
|
||||||
// A row that never decoded is skipped rather than failing the whole
|
|
||||||
// migration on every startup; it was equally unreadable before.
|
|
||||||
const decoded = decodeJson(row.data)
|
|
||||||
if (decoded._tag === "None") {
|
|
||||||
yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id }))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const data = object(decoded.value)
|
|
||||||
if (!Array.isArray(data.content)) continue
|
|
||||||
let changed = false
|
|
||||||
const content = data.content.map((part) => {
|
|
||||||
const tool = object(part)
|
|
||||||
if (tool.type !== "tool" || !isObject(tool.state)) return part
|
|
||||||
const state = tool.state
|
|
||||||
if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part
|
|
||||||
if (!("structured" in state) && !("result" in state)) return part
|
|
||||||
changed = true
|
|
||||||
if (state.status === "running")
|
|
||||||
return {
|
|
||||||
...tool,
|
|
||||||
state: {
|
|
||||||
status: "running",
|
|
||||||
input: object(state.input),
|
|
||||||
metadata: object(state.structured),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// Hosted payloads are irreducible provider replay state; keep them under
|
|
||||||
// the provider-owned result state instead of a generic result field.
|
|
||||||
const hosted =
|
|
||||||
tool.executed === true && isObject(state.result) && "value" in state.result
|
|
||||||
? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } }
|
|
||||||
: {}
|
|
||||||
const preserved = contentOf(state)
|
|
||||||
if (state.status === "completed")
|
|
||||||
return {
|
|
||||||
...tool,
|
|
||||||
...hosted,
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: object(state.input),
|
|
||||||
content: completedContent(state),
|
|
||||||
...metadataOf(state),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...tool,
|
|
||||||
...hosted,
|
|
||||||
state: {
|
|
||||||
status: "error",
|
|
||||||
input: object(state.input),
|
|
||||||
error: state.error,
|
|
||||||
...(preserved.length > 0 ? { content: preserved } : {}),
|
|
||||||
...metadataOf(state),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!changed) continue
|
|
||||||
yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -135,7 +135,7 @@ function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?:
|
|||||||
const released = previous?.time.released || Date.parse(version)
|
const released = previous?.time.released || Date.parse(version)
|
||||||
|
|
||||||
return ModelV2.Info.make({
|
return ModelV2.Info.make({
|
||||||
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, id),
|
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, id),
|
||||||
id,
|
id,
|
||||||
modelID: ModelV2.ID.make(remote.id),
|
modelID: ModelV2.ID.make(remote.id),
|
||||||
providerID: ProviderV2.ID.githubCopilot,
|
providerID: ProviderV2.ID.githubCopilot,
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
get: (providerID, modelID) =>
|
||||||
|
catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||||
list: () => response(catalog.model.available()),
|
list: () => response(catalog.model.available()),
|
||||||
default: () => response(catalog.model.default()),
|
default: () => response(catalog.model.default()),
|
||||||
},
|
},
|
||||||
@@ -357,7 +358,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const registrations: Array<{
|
const registrations: Array<{
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly tool: Tool.Any
|
readonly tool: Tool.AnyTool
|
||||||
readonly options?: Tool.RegisterOptions
|
readonly options?: Tool.RegisterOptions
|
||||||
}> = []
|
}> = []
|
||||||
yield* Effect.sync(() =>
|
yield* Effect.sync(() =>
|
||||||
@@ -394,7 +395,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return toolHooks.hook.after((event) => {
|
return toolHooks.hook.after((event) => {
|
||||||
// Decode first so plugin mutations cannot alias the canonical outcome.
|
|
||||||
const output = {
|
const output = {
|
||||||
tool: event.tool,
|
tool: event.tool,
|
||||||
sessionID: event.sessionID,
|
sessionID: event.sessionID,
|
||||||
@@ -402,30 +402,18 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
messageID: event.messageID,
|
messageID: event.messageID,
|
||||||
callID: event.callID,
|
callID: event.callID,
|
||||||
input: event.input,
|
input: event.input,
|
||||||
...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event),
|
result: event.result,
|
||||||
|
output: event.output,
|
||||||
|
outputPaths: event.outputPaths,
|
||||||
}
|
}
|
||||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||||
Effect.tap(() => {
|
Effect.tap(() =>
|
||||||
const decoded = Schema.decodeUnknownOption(Tool.ExecuteAfterOutcome)(output)
|
Effect.sync(() => {
|
||||||
if (decoded._tag === "None")
|
event.result = output.result
|
||||||
return Effect.logWarning("ignoring invalid execute.after tool outcome", { tool: event.tool })
|
event.output = output.output
|
||||||
if (decoded.value.status !== event.status)
|
event.outputPaths = output.outputPaths
|
||||||
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
|
}),
|
||||||
return Effect.sync(() => {
|
),
|
||||||
if (event.status === "completed" && decoded.value.status === "completed") {
|
|
||||||
event.content = decoded.value.content
|
|
||||||
event.metadata = decoded.value.metadata
|
|
||||||
event.outputPaths = decoded.value.outputPaths
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.status === "error" && decoded.value.status === "error") {
|
|
||||||
event.error = decoded.value.error
|
|
||||||
event.content = decoded.value.content
|
|
||||||
event.metadata = decoded.value.metadata
|
|
||||||
event.outputPaths = decoded.value.outputPaths
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user