Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eef1e38bec |
@@ -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.
|
||||
@@ -97,11 +97,6 @@ jobs:
|
||||
working-directory: packages/client
|
||||
run: bun run check:generated
|
||||
|
||||
- name: Check generated documentation
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/docs
|
||||
run: bun run check:generated
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -125,7 +124,7 @@
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -351,7 +350,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@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-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -363,7 +362,7 @@
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
@@ -471,9 +470,7 @@
|
||||
"packages/docs": {
|
||||
"name": "@opencode-ai/docs",
|
||||
"devDependencies": {
|
||||
"effect": "catalog:",
|
||||
"mint": "4.2.666",
|
||||
"prettier": "3.6.2",
|
||||
},
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
@@ -709,7 +706,6 @@
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -1083,7 +1079,6 @@
|
||||
"@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",
|
||||
"@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",
|
||||
"@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",
|
||||
@@ -1178,7 +1173,7 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -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/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=="],
|
||||
|
||||
@@ -1646,25 +1641,23 @@
|
||||
|
||||
"@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.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=="],
|
||||
"@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=="],
|
||||
|
||||
"@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/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.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/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/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=="],
|
||||
|
||||
"@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/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/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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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": {
|
||||
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
||||
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
||||
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
||||
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
||||
"x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
|
||||
"aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
|
||||
"aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
|
||||
"x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -155,12 +155,12 @@
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"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",
|
||||
"@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",
|
||||
"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/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.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",
|
||||
|
||||
@@ -81,7 +81,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
|
||||
|
||||
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> {
|
||||
constructor(
|
||||
|
||||
@@ -27,7 +27,6 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "anthropic-messages"
|
||||
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
|
||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||
export const PATH = "/messages"
|
||||
|
||||
@@ -57,17 +56,6 @@ const AnthropicImageBlock = Schema.Struct({
|
||||
})
|
||||
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
|
||||
|
||||
const AnthropicDocumentBlock = Schema.Struct({
|
||||
type: Schema.tag("document"),
|
||||
source: Schema.Struct({
|
||||
type: Schema.tag("base64"),
|
||||
media_type: Schema.Literal("application/pdf"),
|
||||
data: Schema.String,
|
||||
}),
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
})
|
||||
type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
|
||||
|
||||
const AnthropicThinkingBlock = Schema.Struct({
|
||||
type: Schema.tag("thinking"),
|
||||
thinking: Schema.String,
|
||||
@@ -113,10 +101,13 @@ const AnthropicServerToolResultBlock = Schema.Struct({
|
||||
})
|
||||
type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
|
||||
|
||||
// Anthropic accepts either a plain string or an ordered array of text, image, and
|
||||
// document blocks inside `tool_result.content`. The array form keeps media as native
|
||||
// model input instead of JSON-stringifying base64 into prompt text.
|
||||
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicDocumentBlock])
|
||||
// Anthropic accepts either a plain string or an ordered array of text/image
|
||||
// blocks inside `tool_result.content`. The array form is required when a tool
|
||||
// returns image bytes (screenshot, image search, etc.) so they can be passed
|
||||
// to the model as proper image inputs instead of being JSON-stringified into
|
||||
// the prompt — which silently inflates context by megabytes and can push the
|
||||
// conversation over the model's token limit.
|
||||
const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock])
|
||||
|
||||
const AnthropicToolResultBlock = Schema.Struct({
|
||||
type: Schema.tag("tool_result"),
|
||||
@@ -126,12 +117,7 @@ const AnthropicToolResultBlock = Schema.Struct({
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
})
|
||||
|
||||
const AnthropicUserBlock = Schema.Union([
|
||||
AnthropicTextBlock,
|
||||
AnthropicImageBlock,
|
||||
AnthropicDocumentBlock,
|
||||
AnthropicToolResultBlock,
|
||||
])
|
||||
const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock])
|
||||
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||
const AnthropicAssistantBlock = Schema.Union([
|
||||
AnthropicTextBlock,
|
||||
@@ -159,7 +145,7 @@ const AnthropicTool = Schema.Struct({
|
||||
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
||||
|
||||
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 }),
|
||||
])
|
||||
|
||||
@@ -297,7 +283,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
|
||||
auto: () => ({ type: "auto" as const }),
|
||||
none: () => ({ type: "none" as const }),
|
||||
none: () => undefined,
|
||||
required: () => ({ type: "any" as const }),
|
||||
tool: (name) => ({ type: "tool" as const, name }),
|
||||
})
|
||||
@@ -330,23 +316,15 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||
const wireType = serverToolResultType(part.name)
|
||||
if (!wireType)
|
||||
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
|
||||
// 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
|
||||
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||
const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES)
|
||||
if (media.mime === "application/pdf")
|
||||
return {
|
||||
type: "document" as const,
|
||||
source: {
|
||||
type: "base64" as const,
|
||||
media_type: "application/pdf" as const,
|
||||
data: media.base64,
|
||||
},
|
||||
} satisfies AnthropicDocumentBlock
|
||||
const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) {
|
||||
const media = yield* ProviderShared.validateMedia(
|
||||
"Anthropic Messages",
|
||||
part,
|
||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
||||
)
|
||||
return {
|
||||
type: "image" as const,
|
||||
source: {
|
||||
@@ -357,13 +335,25 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
|
||||
} satisfies AnthropicImageBlock
|
||||
})
|
||||
|
||||
// Tool results may carry structured text, images, and documents. Keep media as provider-native
|
||||
// Tool results may carry structured text/images. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
||||
item: ToolContent,
|
||||
) {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
||||
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
||||
const media = yield* ProviderShared.validateToolFile(
|
||||
"Anthropic Messages",
|
||||
item,
|
||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
||||
)
|
||||
return {
|
||||
type: "image" as const,
|
||||
source: {
|
||||
type: "base64" as const,
|
||||
media_type: media.mime,
|
||||
data: media.base64,
|
||||
},
|
||||
} satisfies AnthropicImageBlock
|
||||
})
|
||||
|
||||
const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
|
||||
@@ -455,7 +445,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
content.push(yield* lowerMedia(part))
|
||||
content.push(yield* lowerImage(part))
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
||||
@@ -545,6 +535,7 @@ const outputConfig = (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 toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
||||
@@ -553,7 +544,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const tools =
|
||||
request.tools.length === 0
|
||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
@@ -562,9 +553,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
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 =
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
@@ -685,9 +673,7 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
|
||||
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
||||
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
||||
providerExecuted: true,
|
||||
// The complete payload is irreducible provider replay state: subsequent
|
||||
// stateless requests must round-trip the typed result block verbatim.
|
||||
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
|
||||
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ const BedrockToolResultContentItem = Schema.Union([
|
||||
Schema.Struct({ text: Schema.String }),
|
||||
Schema.Struct({ json: Schema.Unknown }),
|
||||
BedrockMedia.ImageBlock,
|
||||
BedrockMedia.DocumentBlock,
|
||||
])
|
||||
|
||||
const BedrockToolResultBlock = Schema.Struct({
|
||||
@@ -284,6 +283,8 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
|
||||
data: item.uri,
|
||||
filename: item.name,
|
||||
})
|
||||
if (!("image" in media))
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
|
||||
content.push(media)
|
||||
}
|
||||
return content
|
||||
@@ -392,13 +393,8 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const toolConfig =
|
||||
request.tools.length > 0
|
||||
? {
|
||||
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,
|
||||
}
|
||||
request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
|
||||
: undefined
|
||||
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
@@ -441,22 +437,21 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// AWS reports inputTokens separately from cache reads and writes.
|
||||
// Bedrock does not break reasoning out of outputTokens for current models.
|
||||
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
|
||||
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
|
||||
// the total through and derive the non-cached breakdown. Bedrock does
|
||||
// not break reasoning out of `outputTokens` for any current model.
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const inputTokens = ProviderShared.sumTokens(
|
||||
usage.inputTokens,
|
||||
usage.cacheReadInputTokens,
|
||||
usage.cacheWriteInputTokens,
|
||||
)
|
||||
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
nonCachedInputTokens: usage.inputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
providerMetadata: { bedrock: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,11 +41,9 @@ const GeminiInlineDataPart = Schema.Struct({
|
||||
data: Schema.String,
|
||||
}),
|
||||
})
|
||||
type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||
|
||||
const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
}),
|
||||
@@ -54,10 +52,8 @@ const GeminiFunctionCallPart = Schema.Struct({
|
||||
|
||||
const GeminiFunctionResponsePart = Schema.Struct({
|
||||
functionResponse: Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.String,
|
||||
response: Schema.Unknown,
|
||||
parts: Schema.optional(Schema.Array(GeminiInlineDataPart)),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -201,13 +197,8 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
: undefined
|
||||
}
|
||||
|
||||
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart) => ({
|
||||
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
|
||||
functionCall: { name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||
})
|
||||
|
||||
@@ -264,7 +255,6 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (part.result.type !== "content") {
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
id: functionCallId(part.providerMetadata),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -276,23 +266,20 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
}
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
const media: GeminiInlineDataPart[] = []
|
||||
for (const item of content) {
|
||||
if (item.type === "text") continue
|
||||
const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
|
||||
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
||||
}
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
id: functionCallId(part.providerMetadata),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
content: text.join("\n"),
|
||||
},
|
||||
parts: media.length > 0 ? media : undefined,
|
||||
},
|
||||
})
|
||||
for (const item of content) {
|
||||
if (item.type === "text") continue
|
||||
const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
|
||||
parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
|
||||
}
|
||||
}
|
||||
contents.push({ role: "user", parts })
|
||||
}
|
||||
@@ -313,7 +300,7 @@ const thinkingConfig = (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 toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
@@ -329,7 +316,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
contents: yield* lowerMessages(request),
|
||||
systemInstruction:
|
||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||
tools: hasTools
|
||||
tools: toolsEnabled
|
||||
? [
|
||||
{
|
||||
functionDeclarations: request.tools.map((tool) =>
|
||||
@@ -338,7 +325,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
},
|
||||
]
|
||||
: 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
|
||||
: undefined,
|
||||
@@ -454,10 +441,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
const metadata = {
|
||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
@@ -470,7 +453,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
id,
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
|
||||
providerMetadata: part.thoughtSignature
|
||||
? googleMetadata({ thoughtSignature: part.thoughtSignature })
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ReasoningPart,
|
||||
type TextPart,
|
||||
@@ -29,7 +28,6 @@ import { ToolStream } from "./utils/tool-stream"
|
||||
import { OpenAIImage } from "./utils/openai-image"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/responses"
|
||||
|
||||
@@ -44,17 +42,7 @@ const OpenAIResponsesInputImage = Schema.Struct({
|
||||
type: Schema.tag("input_image"),
|
||||
image_url: Schema.String,
|
||||
})
|
||||
const OpenAIResponsesInputFile = Schema.Struct({
|
||||
type: Schema.tag("input_file"),
|
||||
filename: Schema.String,
|
||||
file_data: Schema.String,
|
||||
mime_type: Schema.optional(Schema.String),
|
||||
})
|
||||
const OpenAIResponsesInputContent = Schema.Union([
|
||||
OpenAIResponsesInputText,
|
||||
OpenAIResponsesInputImage,
|
||||
OpenAIResponsesInputFile,
|
||||
])
|
||||
const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
|
||||
type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
|
||||
|
||||
const OpenAIResponsesOutputText = Schema.Struct({
|
||||
@@ -80,13 +68,9 @@ const OpenAIResponsesItemReference = Schema.Struct({
|
||||
})
|
||||
|
||||
// `function_call_output.output` accepts either a plain string or an ordered
|
||||
// array of content items so tools can return images and files in addition to text.
|
||||
// array of content items so tools can return images in addition to text.
|
||||
// https://platform.openai.com/docs/api-reference/responses/object
|
||||
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([
|
||||
OpenAIResponsesInputText,
|
||||
OpenAIResponsesInputImage,
|
||||
OpenAIResponsesInputFile,
|
||||
])
|
||||
const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
|
||||
|
||||
const OpenAIResponsesFunctionCallOutput = Schema.Union([
|
||||
Schema.String,
|
||||
@@ -253,7 +237,7 @@ const OpenAIResponsesEvent = Schema.Struct({
|
||||
Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
service_tier: optionalNull(Schema.String),
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
|
||||
usage: optionalNull(OpenAIResponsesUsage),
|
||||
error: optionalNull(OpenAIResponsesErrorPayload),
|
||||
}),
|
||||
@@ -359,58 +343,42 @@ const hostedToolItemID = (part: ToolResultPart) => {
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("OpenAIResponses.lowerMedia")(function* (part: MediaPart, provider: string) {
|
||||
const media = yield* ProviderShared.validateMedia("OpenAI Responses", part, MEDIA_MIMES)
|
||||
if (media.mime === "application/pdf") {
|
||||
// xAI models inline bytes and MIME separately; OpenAI uses a data URL in file_data.
|
||||
if (provider === "xai")
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? "document.pdf",
|
||||
file_data: media.base64,
|
||||
mime_type: media.mime,
|
||||
}
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? "document.pdf",
|
||||
file_data: media.dataUrl,
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: media.dataUrl }
|
||||
})
|
||||
|
||||
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
|
||||
part: LLMRequest["messages"][number]["content"][number],
|
||||
provider: string,
|
||||
) {
|
||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||
if (part.type === "media") return yield* lowerMedia(part, provider)
|
||||
if (part.type === "media") {
|
||||
const media = yield* ProviderShared.validateMedia(
|
||||
"OpenAI Responses",
|
||||
part,
|
||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
||||
)
|
||||
return { type: "input_image" as const, image_url: media.dataUrl }
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
|
||||
})
|
||||
|
||||
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||
// Tool results may carry structured text/images. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
|
||||
item: ToolContent,
|
||||
provider: string,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
provider,
|
||||
const media = yield* ProviderShared.validateToolFile(
|
||||
"OpenAI Responses",
|
||||
item,
|
||||
new Set<string>(ProviderShared.IMAGE_MIMES),
|
||||
)
|
||||
return { type: "input_image" as const, image_url: media.dataUrl }
|
||||
})
|
||||
|
||||
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (
|
||||
part: ToolResultPart,
|
||||
provider: string,
|
||||
) {
|
||||
const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) {
|
||||
// Text/json/error results are encoded as a plain string for backward
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, provider))
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
|
||||
@@ -433,10 +401,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.provider)),
|
||||
})
|
||||
input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -495,9 +460,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
lowerToolResultContentItem(item, request.model.provider),
|
||||
),
|
||||
content: yield* Effect.forEach(content, lowerToolResultContentItem),
|
||||
})
|
||||
}
|
||||
if (itemID) hostedToolReferences.add(itemID)
|
||||
@@ -520,7 +483,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: part.id,
|
||||
output: yield* lowerToolResultOutput(part, request.model.provider),
|
||||
output: yield* lowerToolResultOutput(part),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -602,8 +565,7 @@ const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
|
||||
|
||||
const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => {
|
||||
const reason = event.response?.incomplete_details?.reason
|
||||
if (reason === undefined || reason === null)
|
||||
return hasFunctionCall ? "tool-calls" : event.type === "response.incomplete" ? "unknown" : "stop"
|
||||
if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop"
|
||||
if (reason === "max_output_tokens") return "length"
|
||||
if (reason === "content_filter") return "content-filter"
|
||||
return hasFunctionCall ? "tool-calls" : "unknown"
|
||||
|
||||
@@ -158,8 +158,7 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
|
||||
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
|
||||
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
|
||||
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
|
||||
export const PDF_MIMES = ["application/pdf"] as const
|
||||
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const
|
||||
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
|
||||
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
|
||||
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -49,10 +49,10 @@ const DOCUMENT_FORMATS = {
|
||||
"text/markdown": "md",
|
||||
} as const satisfies Record<string, DocumentFormat>
|
||||
|
||||
const documentBlock = (name: string, format: DocumentFormat, bytes: string): DocumentBlock => ({
|
||||
const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({
|
||||
document: {
|
||||
format,
|
||||
name,
|
||||
name: part.filename ?? `document.${format}`,
|
||||
source: { bytes },
|
||||
},
|
||||
})
|
||||
@@ -77,14 +77,12 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
|
||||
if (documentFormat) {
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
const media = yield* ProviderShared.validateMedia(
|
||||
"Bedrock Converse",
|
||||
part,
|
||||
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
|
||||
)
|
||||
return documentBlock(part.filename, documentFormat, media.base64)
|
||||
return documentBlock(part, documentFormat, media.base64)
|
||||
}
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
@@ -34,12 +34,11 @@ import { ProviderFailureClassification } from "./errors"
|
||||
*
|
||||
* **Semantics by provider**:
|
||||
*
|
||||
* - OpenAI Chat / Responses / Gemini: provider reports inclusive
|
||||
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
|
||||
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
|
||||
* derive the breakdown.
|
||||
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
|
||||
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
|
||||
* mappers sum the breakdown to derive the inclusive `inputTokens`.
|
||||
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
|
||||
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
|
||||
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
|
||||
* `reasoningTokens` is `undefined` and `outputTokens` carries the
|
||||
* combined total — a documented limitation of the Anthropic API.
|
||||
|
||||
@@ -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")({
|
||||
id: Schema.optional(Schema.String),
|
||||
model: ModelSchema,
|
||||
@@ -271,6 +278,7 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
generation: Schema.optional(GenerationOptions),
|
||||
providerOptions: Schema.optional(ProviderOptions),
|
||||
http: Schema.optional(HttpOptions),
|
||||
responseFormat: Schema.optional(ResponseFormat),
|
||||
cache: Schema.optional(CachePolicy),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
@@ -288,6 +296,7 @@ export namespace LLMRequest {
|
||||
generation: request.generation,
|
||||
providerOptions: request.providerOptions,
|
||||
http: request.http,
|
||||
responseFormat: request.responseFormat,
|
||||
cache: request.cache,
|
||||
metadata: request.metadata,
|
||||
})
|
||||
|
||||
@@ -68,29 +68,10 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
||||
events:
|
||||
settlement.result.type === "error"
|
||||
? [
|
||||
LLMEvent.toolError({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
message: String(settlement.result.value),
|
||||
error,
|
||||
providerMetadata: call.providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
result: settlement.result,
|
||||
providerMetadata: call.providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolError({ id: call.id, name: call.name, message: String(settlement.result.value), error }),
|
||||
LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result }),
|
||||
]
|
||||
: [
|
||||
LLMEvent.toolResult({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
providerMetadata: call.providerMetadata,
|
||||
}),
|
||||
],
|
||||
: [LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result, output: settlement.output })],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-53
File diff suppressed because one or more lines are too long
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:anthropic",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"name": "pdf/anthropic-tool-result",
|
||||
"recordedAt": "2026-07-22T18:15:39.002Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzxyRpSVFgwTm6ccUr\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:anthropic",
|
||||
"protocol:anthropic-messages",
|
||||
"user-input"
|
||||
],
|
||||
"name": "pdf/anthropic-user-input",
|
||||
"recordedAt": "2026-07-22T18:15:37.979Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.anthropic.com/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzsayb45rgfamcjFt3\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"name": "pdf/bedrock-tool-result",
|
||||
"recordedAt": "2026-07-22T18:15:52.400Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"call_pdf_1\",\"content\":[{\"text\":\"PDF read successfully\"},{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}}}]}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/vnd.amazon.eventstream"
|
||||
},
|
||||
"body": "AAAAqgAAAFLa0GiGCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSUyIsInJvbGUiOiJhc3Npc3RhbnQifXIDPnsAAADIAAAAV0lIuCQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUSJ9z2HHHgAAAMUAAABXsdh8lQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJJRC0ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PIn2+8d8RAAAAywAAAFcO6ML0CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVlcifSIeZ+kAAACzAAAAV8dKafoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiMzkxIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dyJ9HAStJQAAAMAAAABWDj/Dcws6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyJ9EPTSwQAAALAAAABRaYm2Hws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0SuCAcAAAA+AAAAE6MAqhiCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MzkyMn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIiwidXNhZ2UiOnsiaW5wdXRUb2tlbnMiOjIyNDEsIm91dHB1dFRva2VucyI6Niwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjIyNDd9fcd35Hw=",
|
||||
"bodyEncoding": "base64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"user-input"
|
||||
],
|
||||
"name": "pdf/bedrock-user-input",
|
||||
"recordedAt": "2026-07-22T18:15:48.408Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/vnd.amazon.eventstream"
|
||||
},
|
||||
"body": "AAAAtgAAAFJ/wBIFCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNCIsInJvbGUiOiJhc3Npc3RhbnQifURlAvAAAADGAAAAV/Z4BkULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk8ifU1V/fQAAADWAAAAV5aYkccLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSUQtIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMDEyMzQ1In1Rr1g8AAAAoAAAAFfgCoSoCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZiJ9UwQMPQAAAM8AAABX+2hkNAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIzOTEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWSJ9ZmoyCwAAAJAAAABWNiwMuAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbCJ9wtmmXgAAAIgAAABR+NhFWAs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmciLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifa8D/doAAADvAAAATl7C4/ALOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjo0NTQ1fSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXYiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MTYxNCwib3V0cHV0VG9rZW5zIjo2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTYyMH19db4j2Q==",
|
||||
"bodyEncoding": "base64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:google",
|
||||
"protocol:gemini",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"name": "pdf/gemini-tool-result",
|
||||
"recordedAt": "2026-07-22T18:21:59.606Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"read_pdf\",\"args\": {\"path\": \"verification.pdf\"},\"id\": \"58shgmez\"},\"thoughtSignature\": \"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 81,\"candidatesTokenCount\": 18,\"totalTokenCount\": 151,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 81}],\"thoughtsTokenCount\": 52,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RAphaui3OaSHz7IPy8Kb4Ak\"}\r\n\r\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Use read_pdf with path verification.pdf and return the verification code.\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"args\":{\"path\":\"verification.pdf\"}},\"thoughtSignature\":\"EqkCCqYCARFNMg/JrCTv5i3zYENFBVpZNFL3pbzJmi5Eu387ncF703xFMB4pwyaP7a1gi49EqBhCI2hWOpesU5nZQOLAhGgExKGa2GM+HzpEB5g62r0NFblm/BGkVZaImTuHR7bytfRC5jHQlHKo4OS27OLUVjvkMkBIYsvjhDErY7niERbXJVpyxTVqUf1GgZMSu8kC9/5WDlMs9xVKNT/6KMW4PhhSR9nXg4KZUa+bC03/ydhsWWgBa5aLCgvTq7WPj217xIsmUkSiRedIffPsUSNjYdMHUvWi8bOlvM1veEEP6GIfv5h9gXXzjnHbEHfQxV8PZuBAyY7iM6nqyfkJNdkZ1HdB7DXMBsMsRN6SgrIrFoXX2WaGrkoEI5tdZx1t/gdwF1jEVT6k\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"58shgmez\",\"name\":\"read_pdf\",\"response\":{\"name\":\"read_pdf\",\"content\":\"PDF read successfully\"},\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"required\":[\"path\"],\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCHID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 123,\"candidatesTokenCount\": 8,\"totalTokenCount\": 184,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 123}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EqECCp4CARFNMg9obBl8O6iU9lawUIWiE+1vztZm9NtaT9FuyJz343hd9ruz+xPco4Q1DY1GF81ZiSI2ElBkt8Wfwsqtix9LNGSMvbZhhk/ZnB54t05M/Dft1kujcMvEdZUWUI/jWaJ349tO1bKVH9MacG5+gl0n4y8DwyQZSV3xIcet547drSkcA/TM03RB+yj1/dcLHsvUjmv9EnO897vZgO2Dk4tbZ2NyCtOeQ3JKVhUTLg2pjkGk+POCNiOdESWiUzxdQKw9LiV6nnzi071tXNiMeVimq6d7xAzRVNapI2uXynvn9Uk3eyn85purOFa8cKriK9oD6vcyGMqgd9+gu2m3to0IHqd7o+2YSr1m5qV1xT1R2/WRQEtb1b1AuOAU6w==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 1277,\"candidatesTokenCount\": 8,\"totalTokenCount\": 1338,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 1102},{\"modality\": \"TEXT\",\"tokenCount\": 175}],\"thoughtsTokenCount\": 53,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"RgphaoL6CMjQz7IPjOnEmQI\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:google",
|
||||
"protocol:gemini",
|
||||
"user-input"
|
||||
],
|
||||
"name": "pdf/gemini-user-input",
|
||||
"recordedAt": "2026-07-22T18:20:55.140Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"inlineData\":{\"mimeType\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ORCH\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 2,\"totalTokenCount\": 127,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"ID-7391\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 10,\"candidatesTokenCount\": 8,\"totalTokenCount\": 133,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EokECoYEARFNMg8L4fpLqaX8tIQZcvw2vLt3WsFjGqpuJGgna0/AGczwuzndRcf3LGIEaliCf4ijVOb1AG4/VPBh1kMzfjeAyHhvWIe4yQVoBwI7BjpFyLie+SnGTXQXKKy5ygRqRLFsV6DcAixNXXBHJw2x/2Nhtriryqs4fhWrL/P7ppHC10sMnTwN6Mw5x20NKwgT+rrw6lvYmQe9rdQsBJ6Zmp0GpPlwZZiAgzvwPfVoNwHSGb54xe/T9wjISjwWNgpedhbsIBDRZFDwruS4x57KBKeMPO69GLfeMP8PJ7rpR0HgT7nRbrl/OdykG/jqSMTvoRSqxawsD+Yr/DukgGatyfB5Ic+X4RhD07URpkGTAu/cakBtzhSmM/hpzKU9m/cId1UCjopLTtonUqSAKkroPdp8kIYw0MI2OZCNVwbDrdClUPmjRKfcTkcC2jNj1rS+WDFbm+mo+SP3rDSvvCdyJuiXHGKiM2EhbYnu42aHVC6w7eAe4Gv3Fq/0faW47r0ihbiAohFB9XUA+fD07g83EjIuc9Q6BRVTTcBfoRkrR/yFZKt3qwPq02W6rPD13/1wAnMtabNcxePMMGk7Dlxwng9yPS0NEge2KD+miOj9SC4aTvOTq2451tfK1x3UZqqb205zGOPbjizhH/CA/PGkG84hdkAG4mrUK0rEHqeWwRXDsxpyfto=\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 530,\"candidatesTokenCount\": 8,\"totalTokenCount\": 653,\"promptTokensDetails\": [{\"modality\": \"IMAGE\",\"tokenCount\": 520},{\"modality\": \"TEXT\",\"tokenCount\": 10}],\"thoughtsTokenCount\": 115,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BQpharW2KaPgz7IP6uSLiAw\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -59,12 +59,7 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
|
||||
...request.messages,
|
||||
Message.assistant(state.assistantContent),
|
||||
...dispatched.map(([call, dispatched]) =>
|
||||
Message.tool({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
result: dispatched.result,
|
||||
providerMetadata: call.providerMetadata,
|
||||
}),
|
||||
Message.tool({ id: call.id, name: call.name, result: dispatched.result }),
|
||||
),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -235,37 +235,9 @@ 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
|
||||
// not JSON-stringified into `tool_result.content`.
|
||||
it.effect("lowers media tool-result content as structured blocks", () =>
|
||||
// Regression: screenshot/read tool results must stay structured so base64
|
||||
// image data is not JSON-stringified into `tool_result.content`.
|
||||
it.effect("lowers image tool-result content as structured image blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
@@ -281,7 +253,6 @@ describe("Anthropic Messages route", () => {
|
||||
result: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
|
||||
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
@@ -292,7 +263,6 @@ describe("Anthropic Messages route", () => {
|
||||
expect(expectToolResult(prepared.body).content).toEqual([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
|
||||
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -322,7 +292,7 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
|
||||
it.effect("rejects non-image media in tool-result content with a clear error", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
@@ -664,14 +634,7 @@ describe("Anthropic Messages route", () => {
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] },
|
||||
providerExecuted: true,
|
||||
// The complete payload rides in provider metadata as irreducible replay
|
||||
// state for later stateless requests.
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
blockType: "web_search_tool_result",
|
||||
result: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
|
||||
},
|
||||
},
|
||||
providerMetadata: { anthropic: { blockType: "web_search_tool_result" } },
|
||||
})
|
||||
expect(response.text).toBe("Found it.")
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
@@ -793,7 +756,7 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a conversation with user media content", () =>
|
||||
it.effect("continues a conversation with user image content", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
@@ -803,7 +766,6 @@ describe("Anthropic Messages route", () => {
|
||||
Message.user([
|
||||
{ type: "text", text: "What is in this image?" },
|
||||
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -819,7 +781,6 @@ describe("Anthropic Messages route", () => {
|
||||
content: [
|
||||
{ type: "text", text: "What is in this image?" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
|
||||
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,8 +13,12 @@ const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
|
||||
// call wouldn't deterministically prove cache mapping works. Override with
|
||||
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
|
||||
const model = AmazonBedrock.configure({
|
||||
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
|
||||
region: RECORDING_REGION,
|
||||
credentials: {
|
||||
region: RECORDING_REGION,
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
|
||||
sessionToken: process.env.AWS_SESSION_TOKEN,
|
||||
},
|
||||
}).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0")
|
||||
|
||||
const cacheRequest = LLM.request({
|
||||
@@ -32,7 +36,7 @@ const recorded = recordedTests({
|
||||
prefix: "bedrock-converse-cache",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "bedrock-converse",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
|
||||
// Two identical requests in one cassette — replay walks the cassette in
|
||||
// recording order so the second call replays the cached-hit interaction.
|
||||
})
|
||||
@@ -41,20 +45,10 @@ describe("Bedrock Converse cache recorded", () => {
|
||||
recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
expect(first.usage?.cacheWriteInputTokens ?? 0).toBeGreaterThan(0)
|
||||
expect(first.usage?.inputTokens).toBe(
|
||||
(first.usage?.nonCachedInputTokens ?? 0) +
|
||||
(first.usage?.cacheReadInputTokens ?? 0) +
|
||||
(first.usage?.cacheWriteInputTokens ?? 0),
|
||||
)
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
expect(second.usage?.inputTokens).toBe(
|
||||
(second.usage?.nonCachedInputTokens ?? 0) +
|
||||
(second.usage?.cacheReadInputTokens ?? 0) +
|
||||
(second.usage?.cacheWriteInputTokens ?? 0),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
@@ -299,39 +269,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds cache reads and writes to Bedrock input usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
[
|
||||
"metadata",
|
||||
{
|
||||
usage: {
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
totalTokens: 12,
|
||||
cacheReadInputTokens: 3,
|
||||
cacheWriteInputTokens: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 10,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: 3,
|
||||
cacheWriteInputTokens: 2,
|
||||
outputTokens: 2,
|
||||
totalTokens: 12,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
@@ -612,12 +549,10 @@ describe("Bedrock Converse route", () => {
|
||||
LLM.request({
|
||||
id: "req_doc",
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "Summarize these documents." },
|
||||
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
|
||||
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==", filename: "data.csv" },
|
||||
{ type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -628,9 +563,10 @@ describe("Bedrock Converse route", () => {
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ text: "Summarize these documents." },
|
||||
// Filename round-trips when supplied.
|
||||
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
|
||||
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
|
||||
// Falls back to a stable placeholder when filename is missing.
|
||||
{ document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -638,96 +574,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires names for document media", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("document media requires a filename")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes named document-only messages through for provider validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user({
|
||||
type: "media",
|
||||
mediaType: "application/pdf",
|
||||
data: "UERGREFUQQ==",
|
||||
filename: "report.pdf",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers document media in tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Read successfully" },
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:application/pdf;base64,UERGREFUQQ==",
|
||||
mime: "application/pdf",
|
||||
name: "report",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ toolUse: { toolUseId: "call_1", name: "read", input: { path: "report.pdf" } } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "call_1",
|
||||
status: "success",
|
||||
content: [
|
||||
{ text: "Read successfully" },
|
||||
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported image media types", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
|
||||
@@ -70,7 +70,6 @@ describe("Gemini route", () => {
|
||||
Message.user([
|
||||
{ type: "text", text: "What is in this image?" },
|
||||
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=" },
|
||||
]),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
|
||||
@@ -82,11 +81,7 @@ describe("Gemini route", () => {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "What is in this image?" },
|
||||
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||
],
|
||||
parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
|
||||
},
|
||||
{
|
||||
role: "model",
|
||||
@@ -95,12 +90,7 @@ describe("Gemini route", () => {
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: '{"forecast":"sunny"}' },
|
||||
},
|
||||
},
|
||||
{ functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -120,7 +110,7 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues media tool results as inline model input without base64 text", () =>
|
||||
it.effect("continues image tool results as inline vision input without base64 text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
LLM.request({
|
||||
@@ -135,7 +125,6 @@ describe("Gemini route", () => {
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
|
||||
{ type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -152,12 +141,9 @@ describe("Gemini route", () => {
|
||||
functionResponse: {
|
||||
name: "read",
|
||||
response: { name: "read", content: "Image read successfully" },
|
||||
parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -188,13 +174,8 @@ describe("Gemini route", () => {
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: "read",
|
||||
response: { name: "read", content: "" },
|
||||
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
|
||||
},
|
||||
},
|
||||
{ functionResponse: { name: "read", response: { name: "read", content: "" } } },
|
||||
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -233,11 +214,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* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
id: "req_tool_choice_none",
|
||||
id: "req_no_tools",
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
@@ -245,10 +226,8 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
expect(prepared.body).toEqual({
|
||||
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
|
||||
tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }],
|
||||
toolConfig: { functionCallingConfig: { mode: "NONE" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -393,10 +372,7 @@ describe("Gemini route", () => {
|
||||
parts: [
|
||||
{ text: "thinking", thought: true },
|
||||
{ text: "", thought: true, thoughtSignature: "thought_sig" },
|
||||
{
|
||||
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "tool_sig",
|
||||
},
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
@@ -422,10 +398,7 @@ describe("Gemini route", () => {
|
||||
id: "reasoning-0",
|
||||
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
||||
})
|
||||
expect(toolCall).toMatchObject({
|
||||
id: "tool_0",
|
||||
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
|
||||
})
|
||||
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "tool-call"),
|
||||
)
|
||||
@@ -443,13 +416,6 @@ describe("Gemini route", () => {
|
||||
providerMetadata: toolCall?.providerMetadata,
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
result: "done",
|
||||
resultType: "text",
|
||||
providerMetadata: toolCall?.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
@@ -458,22 +424,7 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "thinking", thought: true, thoughtSignature: "thought_sig" },
|
||||
{
|
||||
functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "tool_sig",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "provider_call",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
},
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -547,7 +498,7 @@ describe("Gemini route", () => {
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { name: "lookup", args: { query: "news" } } },
|
||||
],
|
||||
},
|
||||
@@ -562,13 +513,7 @@ describe("Gemini route", () => {
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.toolCalls).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "tool_0" } },
|
||||
},
|
||||
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
|
||||
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
|
||||
|
||||
@@ -5,7 +5,6 @@ import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart,
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
import * as XAI from "../../src/providers/xai"
|
||||
import * as OpenAIResponses from "../../src/protocols/openai-responses"
|
||||
import * as ProviderShared from "../../src/protocols/shared"
|
||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
|
||||
@@ -17,8 +16,6 @@ const model = OpenAIResponses.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4.1-mini" })
|
||||
|
||||
const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
|
||||
|
||||
const request = LLM.request({
|
||||
id: "req_1",
|
||||
model,
|
||||
@@ -527,77 +524,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers PDF tool-result content as structured input_file array", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
id: "req_tool_result_pdf",
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
mime: "application/pdf",
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses xAI inline file encoding for PDF tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model: xaiModel,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
mime: "application/pdf",
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
mime_type: "application/pdf",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
|
||||
it.effect("rejects non-image media in tool-result content with a clear error", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
@@ -870,32 +797,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps incomplete response reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const generate = (incompleteDetails: object) =>
|
||||
LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "response.incomplete",
|
||||
response: { id: "resp_incomplete", incomplete_details: incompleteDetails },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const length = yield* generate({ reason: "max_output_tokens" })
|
||||
const contentFilter = yield* generate({ reason: "content_filter" })
|
||||
const unknown = yield* generate({})
|
||||
|
||||
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
|
||||
"length",
|
||||
"content-filter",
|
||||
"unknown",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
// OpenAI's documented stream orders output text within one message item; no
|
||||
// provider-valid same-kind overlap is evidenced, so done boundaries close it.
|
||||
it.effect("closes sequential output messages before starting the next", () =>
|
||||
@@ -1625,64 +1526,20 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers user image and PDF content", () =>
|
||||
it.effect("lowers user image content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
id: "req_media",
|
||||
model,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
|
||||
]),
|
||||
],
|
||||
messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses xAI inline file encoding for user PDFs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model: xaiModel,
|
||||
messages: [
|
||||
Message.user({
|
||||
type: "media",
|
||||
mediaType: "application/pdf",
|
||||
data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
filename: "report.pdf",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
mime_type: "application/pdf",
|
||||
},
|
||||
],
|
||||
content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1694,11 +1551,11 @@ describe("OpenAI Responses route", () => {
|
||||
LLM.request({
|
||||
id: "req_media",
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })],
|
||||
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar")
|
||||
expect(error.message).toContain("OpenAI Responses does not support media type application/pdf")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLM, LLMResponse, Message, ToolDefinition, type Model } from "../../src"
|
||||
import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { Tool } from "../../src/tool"
|
||||
import { runTools } from "../lib/tool-runtime"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const CODE = "ORCHID-7391"
|
||||
const PDF =
|
||||
"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" })
|
||||
const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" })
|
||||
const google = Google.configure({ apiKey: process.env.GOOGLE_API_KEY ?? "fixture" })
|
||||
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
|
||||
const bedrock = AmazonBedrock.configure({
|
||||
apiKey: process.env.AWS_BEDROCK_API_KEY ?? "fixture",
|
||||
region: process.env.AWS_REGION ?? "us-east-1",
|
||||
})
|
||||
|
||||
const targets: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly provider: string
|
||||
readonly protocol: string
|
||||
readonly requires: string
|
||||
readonly filename: string
|
||||
readonly maxTokens: number
|
||||
readonly model: Model
|
||||
}> = [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI Responses gpt-4o-mini",
|
||||
provider: "openai",
|
||||
protocol: "openai-responses",
|
||||
requires: "OPENAI_API_KEY",
|
||||
filename: "verification.pdf",
|
||||
maxTokens: 40,
|
||||
model: openai.responses("gpt-4o-mini"),
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic Haiku 4.5",
|
||||
provider: "anthropic",
|
||||
protocol: "anthropic-messages",
|
||||
requires: "ANTHROPIC_API_KEY",
|
||||
filename: "verification.pdf",
|
||||
maxTokens: 40,
|
||||
model: anthropic.model("claude-haiku-4-5-20251001"),
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "Gemini 3.5 Flash",
|
||||
provider: "google",
|
||||
protocol: "gemini",
|
||||
requires: "GOOGLE_API_KEY",
|
||||
filename: "verification.pdf",
|
||||
maxTokens: 256,
|
||||
model: google.model("gemini-3.5-flash"),
|
||||
},
|
||||
{
|
||||
id: "xai",
|
||||
name: "xAI Grok 4.5",
|
||||
provider: "xai",
|
||||
protocol: "openai-responses",
|
||||
requires: "XAI_API_KEY",
|
||||
filename: "verification.pdf",
|
||||
maxTokens: 40,
|
||||
model: xai.responses("grok-4.5"),
|
||||
},
|
||||
{
|
||||
id: "bedrock",
|
||||
name: "Bedrock Claude Haiku 4.5",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "bedrock-converse",
|
||||
requires: "AWS_BEDROCK_API_KEY",
|
||||
filename: "verification",
|
||||
maxTokens: 40,
|
||||
model: bedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0"),
|
||||
},
|
||||
]
|
||||
|
||||
const recorded = recordedTests({ prefix: "pdf", tags: ["pdf"] })
|
||||
const prompt = "Return only the verification code from the PDF."
|
||||
const readPdf = ToolDefinition.make({
|
||||
name: "read_pdf",
|
||||
description: "Read the attached PDF.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
})
|
||||
const readPdfRuntime = Tool.make({
|
||||
description: readPdf.description,
|
||||
parameters: Schema.Struct({ path: Schema.String }),
|
||||
success: Schema.String,
|
||||
execute: () => Effect.succeed("PDF read successfully"),
|
||||
toModelOutput: () => [
|
||||
{ type: "text", text: "PDF read successfully" },
|
||||
{
|
||||
type: "file",
|
||||
uri: `data:application/pdf;base64,${PDF}`,
|
||||
mime: "application/pdf",
|
||||
name: "verification.pdf",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const expectCode = (response: LLMResponse) => {
|
||||
expect(response.finishReason).toBe("stop")
|
||||
expect(response.text.toUpperCase()).toContain(CODE)
|
||||
}
|
||||
|
||||
describe("PDF recorded", () => {
|
||||
for (const target of targets) {
|
||||
recorded.effect.with(
|
||||
`reads a user PDF with ${target.name}`,
|
||||
{
|
||||
id: `${target.id}-user-input`,
|
||||
provider: target.provider,
|
||||
protocol: target.protocol,
|
||||
requires: [target.requires],
|
||||
tags: ["user-input"],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
expectCode(
|
||||
yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: `recorded_pdf_${target.id}_user_input`,
|
||||
model: target.model,
|
||||
cache: "none",
|
||||
generation: { maxTokens: target.maxTokens, temperature: 0 },
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "application/pdf", data: PDF, filename: target.filename },
|
||||
{ type: "text", text: prompt },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
`reads a PDF tool result with ${target.name}`,
|
||||
{
|
||||
id: `${target.id}-tool-result`,
|
||||
provider: target.provider,
|
||||
protocol: target.protocol,
|
||||
requires: [target.requires],
|
||||
tags: ["tool", "tool-result"],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
if (target.id === "gemini") {
|
||||
const events = Array.from(
|
||||
yield* runTools({
|
||||
request: LLM.request({
|
||||
id: "recorded_pdf_gemini_tool_result",
|
||||
model: target.model,
|
||||
system:
|
||||
"Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.",
|
||||
prompt: "Use read_pdf with path verification.pdf and return the verification code.",
|
||||
cache: "none",
|
||||
generation: { maxTokens: target.maxTokens, temperature: 0 },
|
||||
}),
|
||||
tools: { read_pdf: readPdfRuntime },
|
||||
}).pipe(Stream.runCollect),
|
||||
)
|
||||
expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
|
||||
expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
|
||||
return
|
||||
}
|
||||
|
||||
expectCode(
|
||||
yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: `recorded_pdf_${target.id}_tool_result`,
|
||||
model: target.model,
|
||||
system: "Read the PDF returned by the tool and follow the user's response format exactly.",
|
||||
cache: "none",
|
||||
generation: { maxTokens: target.maxTokens, temperature: 0 },
|
||||
messages: [
|
||||
Message.user(prompt),
|
||||
Message.assistant([{ type: "tool-call", id: "call_pdf_1", name: readPdf.name, input: {} }]),
|
||||
Message.tool({
|
||||
id: "call_pdf_1",
|
||||
name: readPdf.name,
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "text", text: "PDF read successfully" },
|
||||
{
|
||||
type: "file",
|
||||
uri: `data:application/pdf;base64,${PDF}`,
|
||||
mime: "application/pdf",
|
||||
name: target.filename,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
tools: [readPdf],
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -183,51 +183,6 @@ describe("LLMClient tools", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves provider metadata on dispatched tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const tool = Tool.make({
|
||||
description: "Return text.",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.String,
|
||||
execute: () => Effect.succeed("hello"),
|
||||
})
|
||||
const providerMetadata = { google: { functionCallId: "provider_call" } }
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{ tool },
|
||||
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
||||
)
|
||||
|
||||
expect(dispatched.events).toEqual([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_1",
|
||||
name: "tool",
|
||||
result: { type: "text", value: "hello" },
|
||||
output: { structured: "hello", content: [{ type: "text", text: "hello" }] },
|
||||
providerMetadata,
|
||||
}),
|
||||
])
|
||||
|
||||
const failed = yield* ToolRuntime.dispatch(
|
||||
{},
|
||||
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
|
||||
)
|
||||
expect(failed.events).toEqual([
|
||||
LLMEvent.toolError({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
message: "Unknown tool: missing",
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
result: { type: "error", value: "Unknown tool: missing" },
|
||||
providerMetadata,
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the narrow default projection for encoded typed success", () =>
|
||||
Effect.gen(function* () {
|
||||
const text = Tool.make({
|
||||
|
||||
@@ -97,7 +97,6 @@ export async function setupTimeline(
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
protocol?: "v1" | "v2"
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
@@ -115,7 +114,6 @@ export async function setupTimeline(
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: input.protocol,
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
|
||||
@@ -32,23 +32,6 @@ for (const expanded of [false, true]) {
|
||||
})
|
||||
}
|
||||
|
||||
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
|
||||
const id = "prt_shell_running_command"
|
||||
const command = "sleep 10 && echo done"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
})
|
||||
|
||||
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
|
||||
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
|
||||
await tool.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
|
||||
})
|
||||
|
||||
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
||||
const reasoningID = "prt_reasoning_hidden"
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
|
||||
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||
})
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
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 })
|
||||
|
||||
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 }) => {
|
||||
|
||||
@@ -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"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
protocol?: "v1" | "v2"
|
||||
provider: unknown
|
||||
directory: string
|
||||
project: unknown
|
||||
@@ -54,20 +53,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
||||
const events = config.events?.()
|
||||
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 === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
@@ -96,129 +83,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
},
|
||||
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 (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])
|
||||
|
||||
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\/([^/]+)$/)
|
||||
if (sessionMatch) {
|
||||
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, [])
|
||||
|
||||
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$/)
|
||||
if (messagesMatch) {
|
||||
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) {
|
||||
return route.fulfill({
|
||||
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"}`,
|
||||
})
|
||||
}
|
||||
|
||||
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 = {
|
||||
id: number
|
||||
url: string
|
||||
path: "/global/event" | "/event" | "/api/event"
|
||||
path: "/global/event" | "/event"
|
||||
headers: Record<string, string>
|
||||
openedAt: number
|
||||
endedAt?: number
|
||||
@@ -93,21 +93,6 @@ export async function installSseTransport<T>(
|
||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].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 = (
|
||||
connection: Connection,
|
||||
bytes: number,
|
||||
@@ -155,13 +140,15 @@ export async function installSseTransport<T>(
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => {
|
||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||
})
|
||||
const encoded = input.deliveries.map((delivery) => ({
|
||||
delivery,
|
||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||
}))
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
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)
|
||||
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 request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (
|
||||
url.origin !== server ||
|
||||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
||||
)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
return originalFetch(request)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
@@ -193,10 +177,6 @@ export async function installSseTransport<T>(
|
||||
record.controller = controller
|
||||
connections.push(record)
|
||||
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(
|
||||
"abort",
|
||||
() => {
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
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", () => {
|
||||
const delta = (value: string, field = "text", partID = "part") => ({
|
||||
directory: "/repo",
|
||||
@@ -52,24 +34,6 @@ describe("coalesceServerEvents", () => {
|
||||
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", () => {
|
||||
const status = {
|
||||
directory: "/repo",
|
||||
|
||||
@@ -1,60 +1,21 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
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 { usePlatform } from "./platform"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { useGlobal } from "./global"
|
||||
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) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
export type ServerEvent = Event & { current?: OpenCodeEvent }
|
||||
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
|
||||
}
|
||||
type QueuedServerEvent = { directory: string; payload: Event }
|
||||
|
||||
const coalescedKey = (event: QueuedServerEvent) => {
|
||||
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[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
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") {
|
||||
output.push(event)
|
||||
return
|
||||
@@ -138,52 +71,12 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
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) {
|
||||
if (!event.persisted) return
|
||||
start()
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
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 {
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
||||
const platform = usePlatform()
|
||||
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({
|
||||
signal: abort.signal,
|
||||
fetch: eventFetch,
|
||||
server: server.http,
|
||||
})
|
||||
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: ServerEvent
|
||||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
type Queued = QueuedServerEvent
|
||||
@@ -251,6 +142,21 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
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 = () => {
|
||||
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
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
const onAbort = () => {
|
||||
attempt?.abort()
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const kind = await protocol
|
||||
const events =
|
||||
kind === "v1"
|
||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||
: eventApi.event.subscribe({ signal: attempt.signal })
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (isStreamClosed(error, attempt?.signal)) return
|
||||
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()
|
||||
for await (const event of events) {
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const legacy = "payload" in event
|
||||
if (legacy && event.payload.type === "sync") continue
|
||||
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
|
||||
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
if (event.payload.type !== "sync") {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload as Event
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
}
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
@@ -297,6 +214,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
@@ -315,11 +233,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
started = false
|
||||
generation++
|
||||
attempt?.abort()
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(window, "pagehide", stop)
|
||||
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(() => {
|
||||
@@ -333,24 +258,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
fetch: platform.fetch,
|
||||
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 {
|
||||
server,
|
||||
scope,
|
||||
protocol,
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
api,
|
||||
currentApi,
|
||||
event: {
|
||||
on: emitter.on.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 & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
@@ -395,7 +309,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
})
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
@@ -415,12 +329,6 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
scope: serverSDK.scope,
|
||||
directory,
|
||||
client,
|
||||
api: createCompatibleApi({
|
||||
protocol: serverSDK.protocol,
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
}),
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useSessionTabAvatarState(
|
||||
const global = useGlobal()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const permissionState = createMemo(() => permission.ensureServerState(server()))
|
||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||
const sync = createMemo(() => {
|
||||
const conn = connection()
|
||||
@@ -21,10 +22,9 @@ export function useSessionTabAvatarState(
|
||||
const hasPermissions = createMemo(() => {
|
||||
const serverSync = sync()
|
||||
if (!serverSync) return false
|
||||
const permissionState = permission.ensureServerState(server())
|
||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
|
||||
return !permissionState.autoResponds(item, directory())
|
||||
return !permissionState().autoResponds(item, directory())
|
||||
})
|
||||
})
|
||||
const hasQuestions = createMemo(() => {
|
||||
@@ -34,11 +34,9 @@ export function useSessionTabAvatarState(
|
||||
return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
|
||||
})
|
||||
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
||||
const notificationState = createMemo(() => {
|
||||
if (!connection()) return
|
||||
return notification.ensureServerState(server())
|
||||
})
|
||||
const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0)
|
||||
const unread = createMemo(
|
||||
() => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0,
|
||||
)
|
||||
const loading = createMemo(() => {
|
||||
const serverSync = sync()
|
||||
if (!serverSync) return false
|
||||
|
||||
@@ -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", () => {
|
||||
test("returns healthy response with version", async () => {
|
||||
let request: URL | undefined
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
const fetch = (async () =>
|
||||
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
})) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth(server, fetch)
|
||||
|
||||
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 () => {
|
||||
@@ -172,7 +142,7 @@ describe("checkServerHealth", () => {
|
||||
retryDelayMs: 1,
|
||||
})
|
||||
|
||||
expect(count).toBe(6)
|
||||
expect(count).toBe(3)
|
||||
expect(result).toEqual({ healthy: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { authTokenFromCredentials, createSdkForServer } from "./server"
|
||||
import { ClientError, OpenCode } from "@opencode-ai/client"
|
||||
import { createSdkForServer } from "./server"
|
||||
import { Accessor, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
|
||||
@@ -62,7 +61,6 @@ function wait(ms: number, signal?: AbortSignal) {
|
||||
|
||||
function retryable(error: unknown, signal?: AbortSignal) {
|
||||
if (signal?.aborted) return false
|
||||
if (error instanceof ClientError) return error.reason === "Transport"
|
||||
if (!(error instanceof Error)) return false
|
||||
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
||||
if (error instanceof TypeError) return true
|
||||
@@ -84,31 +82,15 @@ export async function checkServerHealth(
|
||||
.then(() => attempt(count + 1))
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
const attempt = async (count: number): Promise<ServerHealth> => {
|
||||
const current = await OpenCode.make({
|
||||
baseUrl: server.url,
|
||||
const attempt = (count: number): Promise<ServerHealth> =>
|
||||
createSdkForServer({
|
||||
server,
|
||||
fetch,
|
||||
headers: server.password
|
||||
? {
|
||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||
}
|
||||
: undefined,
|
||||
signal,
|
||||
})
|
||||
.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()
|
||||
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.catch((error) => next(count, error))
|
||||
}
|
||||
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 { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
@@ -40,23 +39,3 @@ export function createSdkForServer({
|
||||
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.
@@ -11,6 +11,7 @@
|
||||
"bin"
|
||||
],
|
||||
"exports": {
|
||||
"./daemon": "./src/daemon.ts",
|
||||
"./run": "./src/run/index.ts",
|
||||
"./server-process": "./src/server-process.ts"
|
||||
},
|
||||
@@ -22,7 +23,7 @@
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AuthenticateRequest,
|
||||
type CancelNotification,
|
||||
type CloseSessionRequest,
|
||||
type DeleteSessionRequest,
|
||||
type ForkSessionRequest,
|
||||
type InitializeRequest,
|
||||
type ListSessionsRequest,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
type PromptRequest,
|
||||
type ResumeSessionRequest,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
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)),
|
||||
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
|
||||
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
|
||||
deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)),
|
||||
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
|
||||
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
|
||||
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
|
||||
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
|
||||
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
|
||||
unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)),
|
||||
prompt: (params: PromptRequest) => run(service.prompt(params)),
|
||||
cancel: (params: CancelNotification) => run(service.cancel(params)),
|
||||
} satisfies Agent
|
||||
|
||||
@@ -28,7 +28,7 @@ export type TurnControl = {
|
||||
type ToolState = {
|
||||
readonly name: string
|
||||
input: ToolInput
|
||||
metadata: Record<string, unknown>
|
||||
structured: Record<string, unknown>
|
||||
content: ToolContent
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export type TurnStart =
|
||||
| { readonly type: "compaction"; readonly id: string }
|
||||
|
||||
function emptyToolState(): ToolState {
|
||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||
return { name: "tool", input: {}, structured: {}, content: [] }
|
||||
}
|
||||
|
||||
export async function streamTurn(input: {
|
||||
@@ -47,7 +47,7 @@ export async function streamTurn(input: {
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly writeTextFile: boolean
|
||||
readonly userMessageID?: string | null
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
}): Promise<PromptResponse> {
|
||||
@@ -120,7 +120,7 @@ export async function streamTurn(input: {
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
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({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
@@ -151,13 +151,15 @@ export async function streamTurn(input: {
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(event.data.callID)
|
||||
if (!current) continue
|
||||
current.metadata = event.data.metadata
|
||||
current.structured = event.data.structured
|
||||
current.content = event.data.content
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
content: current.content,
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
@@ -168,12 +170,11 @@ export async function streamTurn(input: {
|
||||
tools.delete(event.data.callID)
|
||||
await syncEditedFiles({
|
||||
connection: input.connection,
|
||||
writeTextFile: input.writeTextFile,
|
||||
sessionID: input.sessionID,
|
||||
cwd: input.cwd,
|
||||
toolName: current.name,
|
||||
toolInput: current.input,
|
||||
metadata: event.data.metadata ?? {},
|
||||
structured: event.data.structured,
|
||||
}).catch(() => {})
|
||||
await update({
|
||||
sessionUpdate: "tool_call_update",
|
||||
@@ -181,8 +182,9 @@ export async function streamTurn(input: {
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
metadata: event.data.metadata,
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
@@ -196,8 +198,8 @@ export async function streamTurn(input: {
|
||||
toolCallId: event.data.callID,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
metadata: event.data.metadata ?? current.metadata,
|
||||
content: event.data.content ?? current.content,
|
||||
structured: current.structured,
|
||||
content: current.content,
|
||||
error: event.data.error.message,
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
@@ -229,7 +231,7 @@ export async function streamTurn(input: {
|
||||
if (!started) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return response(undefined, undefined, "interrupted", true, undefined)
|
||||
return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID)
|
||||
}
|
||||
}
|
||||
const terminal = await completed
|
||||
@@ -244,6 +246,7 @@ export async function streamTurn(input: {
|
||||
terminal,
|
||||
control.cancelled,
|
||||
finish,
|
||||
input.userMessageID,
|
||||
)
|
||||
} catch (error) {
|
||||
streamController.abort()
|
||||
@@ -339,8 +342,9 @@ async function replayMessage(
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
structured: part.state.structured,
|
||||
content: part.state.content,
|
||||
result: part.state.result,
|
||||
}),
|
||||
},
|
||||
})
|
||||
@@ -354,6 +358,7 @@ async function replayMessage(
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.input },
|
||||
content: part.state.content,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
@@ -368,7 +373,7 @@ async function replayMessage(
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
structured: part.state.structured,
|
||||
content: part.state.content,
|
||||
error: part.state.error.message,
|
||||
cwd,
|
||||
@@ -395,6 +400,7 @@ function response(
|
||||
terminal: "succeeded" | "failed" | "interrupted",
|
||||
cancelled: boolean,
|
||||
finish: SessionMessageAssistant["finish"],
|
||||
messageID: string | null | undefined,
|
||||
): PromptResponse {
|
||||
const error = assistant?.error ?? executionError
|
||||
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
|
||||
@@ -417,7 +423,7 @@ function response(
|
||||
}
|
||||
: undefined
|
||||
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: {
|
||||
|
||||
@@ -53,16 +53,15 @@ export async function replyPermission(input: {
|
||||
|
||||
export async function syncEditedFiles(input: {
|
||||
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
readonly writeTextFile: boolean
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly toolName: string
|
||||
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
|
||||
const files = Array.isArray(input.metadata.files)
|
||||
? input.metadata.files.flatMap((file): string[] => {
|
||||
if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
|
||||
const files = Array.isArray(input.structured.files)
|
||||
? input.structured.files.flatMap((file): string[] => {
|
||||
if (!file || typeof file !== "object") return []
|
||||
const path = Reflect.get(file, "file")
|
||||
return typeof path === "string" ? [path] : []
|
||||
|
||||
@@ -16,8 +16,6 @@ import type {
|
||||
CancelNotification,
|
||||
CloseSessionRequest,
|
||||
CloseSessionResponse,
|
||||
DeleteSessionRequest,
|
||||
DeleteSessionResponse,
|
||||
ForkSessionRequest,
|
||||
ForkSessionResponse,
|
||||
InitializeRequest,
|
||||
@@ -35,6 +33,8 @@ import type {
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
@@ -47,8 +47,7 @@ import { ACPError } from "./error"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
||||
Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
|
||||
|
||||
type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
@@ -84,12 +83,12 @@ export interface Interface {
|
||||
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
|
||||
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
|
||||
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
|
||||
deleteSession(input: DeleteSessionRequest): Promise<DeleteSessionResponse>
|
||||
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
|
||||
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
|
||||
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
|
||||
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
|
||||
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
|
||||
setSessionModel(input: SetSessionModelRequest): Promise<SetSessionModelResponse>
|
||||
prompt(input: PromptRequest): Promise<PromptResponse>
|
||||
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 registeredMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, TurnControl>()
|
||||
const capabilities = { writeTextFile: false }
|
||||
|
||||
const catalog = (cwd: string) => {
|
||||
const cached = catalogs.get(cwd)
|
||||
@@ -159,7 +157,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
|
||||
return {
|
||||
initialize: async (params) => {
|
||||
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -176,7 +173,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
loadSession: true,
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
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 } : {}),
|
||||
}
|
||||
},
|
||||
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) => {
|
||||
const session = await getSession(input.client, params.sessionId)
|
||||
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)
|
||||
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) => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
if (active.has(state.id)) {
|
||||
@@ -299,7 +295,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
sessionID: state.id,
|
||||
cwd: state.cwd,
|
||||
start: prepared.start,
|
||||
writeTextFile: capabilities.writeTextFile,
|
||||
userMessageID: params.messageId,
|
||||
control,
|
||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||
}).finally(() => {
|
||||
@@ -483,7 +479,6 @@ async function registerMcpServers(
|
||||
|
||||
function mcpConfig(server: McpServer) {
|
||||
if ("type" in server) {
|
||||
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url: server.url,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
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 ToolContent = ReadonlyArray<
|
||||
@@ -101,12 +100,11 @@ export function completedToolUpdate(input: {
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content: ToolContent
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly result?: unknown
|
||||
}): ToolCallUpdate {
|
||||
const normalized = toolContent(input.content)
|
||||
// Read's model content is a JSON page envelope; show the clean text instead.
|
||||
const firstText = input.content.find((part) => part.type === "text")
|
||||
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
|
||||
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
|
||||
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
|
||||
const primary =
|
||||
read === undefined
|
||||
@@ -130,7 +128,8 @@ export function completedToolUpdate(input: {
|
||||
status: "completed",
|
||||
content: [...primary, ...diff, ...images],
|
||||
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 toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly content?: ToolContent
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
readonly content: ToolContent
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly error: string
|
||||
readonly cwd?: string
|
||||
}): ToolCallUpdate {
|
||||
@@ -151,11 +150,8 @@ export function errorToolUpdate(input: {
|
||||
title: toolTitle(input.toolName, input.input, undefined),
|
||||
locations: toLocations(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 } }],
|
||||
rawOutput: {
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
error: input.error,
|
||||
},
|
||||
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
|
||||
rawOutput: { structured: input.structured, 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) {
|
||||
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
|
||||
return fallback || toolName
|
||||
|
||||
@@ -32,7 +32,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
},
|
||||
commands: [
|
||||
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
||||
@@ -140,11 +139,11 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
replay: Flag.boolean("replay").pipe(
|
||||
Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"),
|
||||
Flag.optional,
|
||||
Flag.withDescription("Replay session history on resume and after resize"),
|
||||
Flag.withDefault(true),
|
||||
),
|
||||
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,
|
||||
),
|
||||
model: Flag.string("model").pipe(
|
||||
|
||||
@@ -55,11 +55,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
args: {
|
||||
continue: input.continue,
|
||||
sessionID: Option.getOrUndefined(input.session),
|
||||
prompt: Option.getOrUndefined(input.prompt),
|
||||
},
|
||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
config: {
|
||||
path: config.path,
|
||||
get: () => runPromise(config.get()),
|
||||
|
||||
@@ -28,8 +28,8 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
model: Option.getOrUndefined(input.model),
|
||||
agent: Option.getOrUndefined(input.agent),
|
||||
prompt: Option.getOrUndefined(input.prompt),
|
||||
replay: Option.getOrUndefined(input.replay) ?? resolved.mini?.replay ?? true,
|
||||
replayLimit: Option.getOrUndefined(input.replayLimit) ?? resolved.mini?.replay_limit,
|
||||
replay: input.replay,
|
||||
replayLimit: Option.getOrUndefined(input.replayLimit),
|
||||
demo: input.demo,
|
||||
tuiConfig: resolved,
|
||||
config: {
|
||||
|
||||
@@ -213,7 +213,62 @@ function parseModel(value?: string) {
|
||||
}
|
||||
|
||||
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
|
||||
return async (input) => ({ model: input.model, agent: requestedAgent ?? input.agent })
|
||||
return async (input) => {
|
||||
if (input.model || requestedAgent)
|
||||
await input.client.location
|
||||
.wait(
|
||||
{
|
||||
location: { directory: input.location.directory, workspace: input.location.workspaceID },
|
||||
},
|
||||
{ signal: input.signal },
|
||||
)
|
||||
.catch((error) => {
|
||||
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
|
||||
})
|
||||
return {
|
||||
model: input.model,
|
||||
agent: requestedAgent
|
||||
? await validateAgent(
|
||||
input.client,
|
||||
input.location.directory,
|
||||
input.location.workspaceID,
|
||||
requestedAgent,
|
||||
input.signal,
|
||||
)
|
||||
: input.agent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAgent(
|
||||
sdk: OpenCodeClient,
|
||||
directory: string,
|
||||
workspace: string | undefined,
|
||||
name?: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (!name) return
|
||||
const agents = await sdk.agent
|
||||
.list({ location: { directory, workspace } }, { signal })
|
||||
.catch((error) => {
|
||||
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
|
||||
})
|
||||
if (signal?.aborted) return
|
||||
if (!agents) {
|
||||
warning("failed to list agents. Falling back to default agent")
|
||||
return
|
||||
}
|
||||
const agent = agents.data.find((item) => item.id === name)
|
||||
if (agent?.mode === "subagent") {
|
||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
if (agent) return name
|
||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||
}
|
||||
|
||||
function warning(message: string) {
|
||||
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin/v2"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/tool"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
@@ -24,5 +23,4 @@ const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
Tool,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { EOL } from "node:os"
|
||||
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"
|
||||
|
||||
type Model = {
|
||||
@@ -55,7 +55,7 @@ type ToolState = StartedPart & {
|
||||
raw?: string
|
||||
provider?: unknown
|
||||
providerState?: SessionMessageAssistantTool["providerState"]
|
||||
metadata: Record<string, JsonValue>
|
||||
structured: Record<string, JsonValue>
|
||||
content: LLMToolContent[]
|
||||
}
|
||||
|
||||
@@ -211,17 +211,10 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
if (!promoted && event.type === "session.execution.failed") {
|
||||
prePromotionError = event.data.error
|
||||
if (finalizing) return
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!promoted &&
|
||||
finalizing &&
|
||||
(event.type === "session.execution.succeeded" || event.type === "session.execution.interrupted")
|
||||
)
|
||||
return
|
||||
if (!promoted) continue
|
||||
if (finalizing && !event.type.startsWith("session.execution.")) continue
|
||||
if (finalizing) continue
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
const part = {
|
||||
@@ -306,7 +299,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: event.data.name,
|
||||
input: {},
|
||||
metadata: {},
|
||||
structured: {},
|
||||
content: [],
|
||||
})
|
||||
continue
|
||||
@@ -334,7 +327,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
raw: current?.raw,
|
||||
provider: { executed: event.data.executed, state: event.data.state },
|
||||
providerState: event.data.state,
|
||||
metadata: {},
|
||||
structured: {},
|
||||
content: [],
|
||||
})
|
||||
continue
|
||||
@@ -342,7 +335,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
|
||||
if (current) {
|
||||
current.metadata = event.data.metadata
|
||||
current.structured = event.data.structured
|
||||
current.content = event.data.content
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -359,8 +353,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
state: {
|
||||
status: "completed",
|
||||
input: current.input,
|
||||
metadata: event.data.metadata,
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||
}
|
||||
@@ -377,8 +372,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
output: toolOutputText(current.tool, event.data.content),
|
||||
title: current.tool,
|
||||
metadata: {
|
||||
metadata: event.data.metadata,
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
@@ -395,8 +391,6 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
const key = toolKey(event.data.assistantMessageID, event.data.callID)
|
||||
const current = tools.get(key) ?? fallbackTool(event)
|
||||
const error = event.data.error.message
|
||||
const metadata = event.data.metadata ?? current.metadata
|
||||
const content = event.data.content ?? nonEmptyToolContent(current.content)
|
||||
const tool: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
@@ -407,9 +401,10 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
state: {
|
||||
status: "error",
|
||||
input: current.input,
|
||||
metadata,
|
||||
content,
|
||||
structured: current.structured,
|
||||
content: current.content,
|
||||
error: event.data.error,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { created: current.timestamp, ran: current.timestamp, completed: time },
|
||||
}
|
||||
@@ -425,6 +420,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
input: current.input,
|
||||
error,
|
||||
metadata: {
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: { executed: event.data.executed, state: event.data.resultState },
|
||||
rawInput: current.raw,
|
||||
@@ -436,14 +432,15 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
renderedTools.add(key)
|
||||
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
|
||||
if (!emit("tool_use", time, { part })) {
|
||||
if (content && toolOutputText(current.tool, content).trim())
|
||||
if (toolOutputText(current.tool, current.content).trim())
|
||||
await input.renderTool({
|
||||
...tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: current.input,
|
||||
metadata,
|
||||
content,
|
||||
structured: current.structured,
|
||||
content: current.content,
|
||||
result: event.data.result,
|
||||
},
|
||||
})
|
||||
await input.renderToolError(tool)
|
||||
@@ -591,14 +588,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
input: item.state.input,
|
||||
output: toolOutputText(item.name, item.state.content),
|
||||
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 },
|
||||
}
|
||||
: {
|
||||
status: "error",
|
||||
input: item.state.input,
|
||||
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 },
|
||||
},
|
||||
}
|
||||
@@ -608,16 +605,8 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
await input.renderTool(item)
|
||||
continue
|
||||
}
|
||||
if (item.state.content && toolOutputText(item.name, item.state.content).trim()) {
|
||||
await input.renderTool({
|
||||
...item,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: item.state.input,
|
||||
metadata: item.state.metadata,
|
||||
content: item.state.content,
|
||||
},
|
||||
})
|
||||
if (toolOutputText(item.name, item.state.content).trim()) {
|
||||
await input.renderTool({ ...item, state: { ...item.state, status: "completed" } })
|
||||
}
|
||||
await input.renderToolError(item)
|
||||
UI.error(item.state.error.message)
|
||||
@@ -629,10 +618,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
|
||||
}
|
||||
}
|
||||
return {
|
||||
found: projected.found,
|
||||
responded: projected.messages.some((message) => message.type === "assistant"),
|
||||
}
|
||||
return projected.found
|
||||
}
|
||||
|
||||
const interrupt = () => {
|
||||
@@ -722,18 +708,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
const waiting = input.client.session.wait({ sessionID: input.sessionID })
|
||||
await Promise.race([waiting, completed.then(() => waiting)])
|
||||
finalizing = true
|
||||
const projected = await reconcile()
|
||||
if (
|
||||
!projected.responded &&
|
||||
!interrupted &&
|
||||
!permissionRejected &&
|
||||
!formCancelled &&
|
||||
!emittedError &&
|
||||
!prePromotionError
|
||||
) {
|
||||
await completed
|
||||
}
|
||||
if (!projected.found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
|
||||
controller.abort()
|
||||
const found = await reconcile()
|
||||
if (!found && !interrupted && !permissionRejected && !formCancelled && !emittedError) {
|
||||
const error = prePromotionError ?? { type: "unknown", message: "Prompt was not promoted" }
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
@@ -794,7 +771,7 @@ function fallbackTool(event: {
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: "tool",
|
||||
input: {},
|
||||
metadata: {},
|
||||
structured: {},
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +92,13 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||
: undefined,
|
||||
agent: input.agent,
|
||||
prepare: async (next) => {
|
||||
const location = { location: { directory: next.location.directory, workspace: next.location.workspaceID } }
|
||||
if (next.model) await client.location.wait(location)
|
||||
const selected =
|
||||
next.model ??
|
||||
(options.variant
|
||||
? await client.model
|
||||
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
||||
.then((result) => result.data)
|
||||
: undefined)
|
||||
(await client.model
|
||||
.default(location)
|
||||
.then((result) => result.data))
|
||||
const model = selected
|
||||
? {
|
||||
providerID: selected.providerID,
|
||||
@@ -106,9 +106,17 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
|
||||
}
|
||||
: undefined
|
||||
const models = await client.model.list(location).then((result) => result.data)
|
||||
if ((options.variant ?? explicit?.variant) && !model)
|
||||
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
|
||||
return { model, agent: next.agent }
|
||||
if (model && !models.some((item) => item.providerID === model.providerID && item.id === model.id))
|
||||
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
|
||||
return {
|
||||
model,
|
||||
agent: input.agent
|
||||
? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent)
|
||||
: next.agent,
|
||||
}
|
||||
},
|
||||
}).catch((error) => {
|
||||
if (!(error instanceof RunTargetError)) throw error
|
||||
@@ -173,6 +181,28 @@ export function parseRunModel(value?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) {
|
||||
if (!name) return
|
||||
const agents = await client.agent
|
||||
.list({ location: { directory, workspace } })
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
if (!agents) {
|
||||
warning("failed to list agents. Falling back to default agent")
|
||||
return
|
||||
}
|
||||
const agent = agents.find((item) => item.id === name)
|
||||
if (!agent) {
|
||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
if (agent.mode === "subagent") {
|
||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
|
||||
const file = path.resolve(directory, input)
|
||||
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function resolveSessionTarget(input: {
|
||||
session,
|
||||
location,
|
||||
model: prepared.model,
|
||||
agent: prepared.agent ?? session.agent,
|
||||
agent: prepared.agent,
|
||||
resume: selected !== undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,11 +214,12 @@ describe("acp event behavior", () => {
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
durableEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_ok",
|
||||
metadata: { phase: 1 },
|
||||
structured: { phase: 1 },
|
||||
content: [{ type: "text", text: "working" }],
|
||||
}),
|
||||
)
|
||||
send(
|
||||
@@ -226,8 +227,9 @@ describe("acp event behavior", () => {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_ok",
|
||||
metadata: { exit: 0 },
|
||||
structured: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
result: { code: 0 },
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
@@ -249,11 +251,12 @@ describe("acp event behavior", () => {
|
||||
}),
|
||||
)
|
||||
send(
|
||||
ephemeralEvent("session.tool.progress", {
|
||||
durableEvent("session.tool.progress", {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_fail",
|
||||
metadata: { bytes: 0 },
|
||||
structured: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
}),
|
||||
)
|
||||
send(
|
||||
@@ -262,8 +265,6 @@ describe("acp event behavior", () => {
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_fail",
|
||||
error: { type: "tool.error", message: "not found" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "opening" }],
|
||||
executed: true,
|
||||
}),
|
||||
)
|
||||
@@ -310,10 +311,12 @@ describe("acp event behavior", () => {
|
||||
locations: [{ path: resolve("/workspace", "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({
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
rawOutput: { metadata: { exit: 0 } },
|
||||
rawOutput: { structured: { exit: 0 }, result: { code: 0 } },
|
||||
})
|
||||
expect(updates[7]?.update).toMatchObject({
|
||||
kind: "read",
|
||||
@@ -322,7 +325,7 @@ describe("acp event behavior", () => {
|
||||
{ type: "content", content: { type: "text", text: "opening" } },
|
||||
{ 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")
|
||||
} finally {
|
||||
@@ -374,7 +377,7 @@ describe("acp event behavior", () => {
|
||||
{ type: "content", content: { type: "text", text: "done" } },
|
||||
{ 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({
|
||||
toolCallId: "call_running",
|
||||
@@ -434,7 +437,6 @@ describe("acp event behavior", () => {
|
||||
sessionID: "ses_cancel",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: async (signal) => {
|
||||
await fixture.client.session.prompt(
|
||||
@@ -477,7 +479,6 @@ describe("acp event behavior", () => {
|
||||
sessionID: "ses_cancel_admission",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel_admission" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: (signal) =>
|
||||
fixture.client.session.prompt(
|
||||
@@ -563,7 +564,7 @@ function turn(input: {
|
||||
sessionID: input.sessionID,
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: input.inputID },
|
||||
writeTextFile: false,
|
||||
userMessageID: `client_${input.inputID}`,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) =>
|
||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
||||
@@ -613,11 +614,12 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
structured: { exit: 0 },
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
|
||||
],
|
||||
result: { code: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -628,7 +630,8 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "pwd" },
|
||||
metadata: {},
|
||||
structured: {},
|
||||
content: [{ type: "text", text: "/workspace" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -639,7 +642,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
state: {
|
||||
status: "error",
|
||||
input: { filePath: "/workspace/missing.ts" },
|
||||
metadata: { bytes: 0 },
|
||||
structured: { bytes: 0 },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
error: { type: "tool.error", message: "failed hard" },
|
||||
},
|
||||
@@ -672,7 +675,7 @@ function replayToolMessage(id: string) {
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "printf done" },
|
||||
metadata: { exit: 0 },
|
||||
structured: { exit: 0 },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -85,6 +85,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
|
||||
try {
|
||||
const id = "msg_prompt"
|
||||
const userMessageID = "client-message"
|
||||
const response = await streamTurn({
|
||||
client,
|
||||
connection: {
|
||||
@@ -96,7 +97,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
sessionID: "ses_test",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id },
|
||||
writeTextFile: false,
|
||||
userMessageID,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
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 {
|
||||
events?.close()
|
||||
await server.stop(true)
|
||||
|
||||
@@ -14,7 +14,6 @@ describe("acp initialize/auth subprocess", () => {
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
|
||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
CloseSessionResponse,
|
||||
DeleteSessionResponse,
|
||||
ListSessionsResponse,
|
||||
LoadSessionResponse,
|
||||
ResumeSessionResponse,
|
||||
@@ -61,20 +60,6 @@ describe("acp lifecycle subprocess", () => {
|
||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
|
||||
}, 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 () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const initialized = await initialize(fixture.spawn())
|
||||
|
||||
@@ -4,7 +4,6 @@ import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { streamTurn } from "../../src/acp/event"
|
||||
import { syncEditedFiles } from "../../src/acp/permission"
|
||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
@@ -13,27 +12,6 @@ type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission
|
||||
type Fixture = ReturnType<typeof createSseFixture>
|
||||
|
||||
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 () => {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
@@ -193,7 +171,7 @@ describe("acp permission behavior", () => {
|
||||
sessionID: "ses_edit",
|
||||
assistantMessageID: "msg_edit",
|
||||
callID: "call_edit",
|
||||
metadata: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||
structured: { files: [{ file: "file.ts" }], replacements: 1 },
|
||||
content: [{ type: "text", text: "edited" }],
|
||||
executed: true,
|
||||
}),
|
||||
@@ -286,7 +264,7 @@ describe("acp permission behavior", () => {
|
||||
sessionID: "ses_patch",
|
||||
assistantMessageID: "msg_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" }],
|
||||
executed: true,
|
||||
}),
|
||||
@@ -487,7 +465,6 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
|
||||
sessionID,
|
||||
cwd,
|
||||
start: { type: "input", id: inputID },
|
||||
writeTextFile: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
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", () => {
|
||||
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",
|
||||
value: "plan",
|
||||
})
|
||||
await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" })
|
||||
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
||||
|
||||
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
||||
@@ -147,6 +148,7 @@ describe("acp service directory behavior", () => {
|
||||
).toEqual([
|
||||
{ model: { providerID: "test", id: secondModel.id } },
|
||||
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
|
||||
{ model: { providerID: "test", id: testModel.id, variant: "high" } },
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
|
||||
@@ -225,33 +225,6 @@ describe("acp service lifecycle", () => {
|
||||
"/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) {
|
||||
|
||||
@@ -45,14 +45,17 @@ describe("acp service prompt routing and usage", () => {
|
||||
|
||||
const commandResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
messageId: "client-command",
|
||||
prompt: [{ type: "text", text: "/review now" }],
|
||||
})
|
||||
const skillResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
messageId: "client-skill",
|
||||
prompt: [{ type: "text", text: "/verify" }],
|
||||
})
|
||||
const compactResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
messageId: "client-compact",
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
})
|
||||
|
||||
@@ -151,11 +154,13 @@ describe("acp service prompt routing and usage", () => {
|
||||
|
||||
const response = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
messageId: "client-message",
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
userMessageId: "client-message",
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
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: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" },
|
||||
],
|
||||
metadata: {},
|
||||
structured: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
@@ -93,7 +93,7 @@ describe("acp tools", () => {
|
||||
content: "created",
|
||||
},
|
||||
content: [{ type: "text", text: "wrote /tmp/file.ts" }],
|
||||
metadata: {},
|
||||
structured: {},
|
||||
}).content,
|
||||
).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(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-read",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/file.ts" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{ type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
content: [{ type: "text", text: "<content>1: first\n2: second</content>" }],
|
||||
structured: {
|
||||
type: "text-page",
|
||||
content: "first\nsecond",
|
||||
mime: "text/plain",
|
||||
offset: 1,
|
||||
truncated: false,
|
||||
},
|
||||
}).content,
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }])
|
||||
|
||||
@@ -127,17 +125,13 @@ describe("acp tools", () => {
|
||||
toolCallId: "tool-list",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
entries: [
|
||||
{ path: "a.ts", type: "file" },
|
||||
{ path: "src", type: "directory" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
content: [],
|
||||
structured: {
|
||||
entries: [
|
||||
{ path: "a.ts", type: "file" },
|
||||
{ path: "src", type: "directory" },
|
||||
],
|
||||
},
|
||||
}).content,
|
||||
).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }])
|
||||
})
|
||||
@@ -177,7 +171,7 @@ describe("acp tools", () => {
|
||||
newString: "after",
|
||||
},
|
||||
content: [{ type: "text", text: "Edit applied successfully." }],
|
||||
metadata: { output: "Edit applied successfully." },
|
||||
structured: { output: "Edit applied successfully." },
|
||||
}),
|
||||
).toEqual({
|
||||
toolCallId: "tool-1",
|
||||
@@ -195,7 +189,7 @@ describe("acp tools", () => {
|
||||
},
|
||||
],
|
||||
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 = [
|
||||
{
|
||||
type: "file",
|
||||
@@ -231,10 +225,12 @@ describe("acp tools", () => {
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
result: "done",
|
||||
}).rawOutput,
|
||||
).toEqual({
|
||||
metadata: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
structured: { output: "done", metadata: { exit: 0 }, attachments },
|
||||
result: "done",
|
||||
})
|
||||
|
||||
expect(
|
||||
@@ -243,8 +239,9 @@ describe("acp tools", () => {
|
||||
toolName: "read",
|
||||
input: {},
|
||||
content: [],
|
||||
structured: { output: "done" },
|
||||
}).rawOutput,
|
||||
).toEqual({})
|
||||
).toEqual({ structured: { output: "done" } })
|
||||
})
|
||||
|
||||
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: "text/plain", uri: "data:text/plain;base64,BBBB" },
|
||||
],
|
||||
metadata: {},
|
||||
structured: {},
|
||||
}).content,
|
||||
).toEqual([
|
||||
{
|
||||
@@ -275,7 +272,7 @@ describe("acp tools", () => {
|
||||
toolName: "read",
|
||||
input: { filePath: "/tmp/a" },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
metadata: { path: "/tmp/a" },
|
||||
structured: { path: "/tmp/a" },
|
||||
error: "failed",
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -289,7 +286,7 @@ describe("acp tools", () => {
|
||||
{ type: "content", content: { type: "text", text: "partial output" } },
|
||||
{ type: "content", content: { type: "text", text: "failed" } },
|
||||
],
|
||||
rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" },
|
||||
rawOutput: { structured: { path: "/tmp/a" }, error: "failed" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -131,7 +131,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
|
||||
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -139,7 +139,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
expect(config).toEqual({
|
||||
animations: true,
|
||||
prompt: { paste: "compact" },
|
||||
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
|
||||
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true },
|
||||
})
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||
} finally {
|
||||
|
||||
@@ -76,16 +76,7 @@ export default defineScript({
|
||||
if (first.includes("drive mini response complete"))
|
||||
throw new Error("response rendered before prompt submission")
|
||||
|
||||
yield* Effect.promise(() => waitForPane(session, "Default model", 15_000))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"]))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Commands"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"]))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Switch model"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Select model"))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything..."))
|
||||
yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
|
||||
yield* Effect.sleep(100)
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||
@@ -147,7 +138,7 @@ export default defineScript({
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
||||
const armed = yield* Effect.promise(() => waitForPane(session, "esc again"))
|
||||
const armed = yield* Effect.promise(() => waitForPane(session, "again to interrupt"))
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
||||
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
|
||||
@@ -157,7 +148,7 @@ export default defineScript({
|
||||
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
|
||||
})
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
|
||||
yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForDeadPane(session))
|
||||
const status = yield* Effect.promise(() => paneDeadStatus(session))
|
||||
@@ -181,59 +172,47 @@ export default defineScript({
|
||||
const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
|
||||
yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForDeadPane(session))
|
||||
if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
|
||||
throw new Error("Explicit-model Mini did not exit cleanly")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
for (const failure of [
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"run",
|
||||
"--server",
|
||||
registration.url,
|
||||
"--model",
|
||||
"simulation/definitely-missing",
|
||||
"catalog readiness check",
|
||||
],
|
||||
{
|
||||
args: ["--model", "simulation/definitely-missing"],
|
||||
capture: "08-unavailable-model.txt",
|
||||
expected: "Model unavailable: simulation/definitely-missing",
|
||||
},
|
||||
{
|
||||
args: ["--agent", "definitely-missing"],
|
||||
capture: "09-unavailable-agent.txt",
|
||||
expected: 'Agent not found: "definitely-missing"',
|
||||
},
|
||||
]) {
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"run",
|
||||
"--server",
|
||||
registration.url,
|
||||
...failure.args,
|
||||
"optimistic selection check",
|
||||
],
|
||||
{
|
||||
cwd: path.join(root, "packages/cli"),
|
||||
env: {
|
||||
...process.env,
|
||||
PWD: path.join(artifacts, "files"),
|
||||
OPENCODE_PASSWORD: registration.password,
|
||||
OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: path.join(root, "packages/cli"),
|
||||
env: {
|
||||
...process.env,
|
||||
PWD: path.join(artifacts, "files"),
|
||||
OPENCODE_PASSWORD: registration.password,
|
||||
OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
},
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
await Bun.write(path.join(snapshots, failure.capture), stdout + stderr)
|
||||
if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`)
|
||||
if (!stderr.includes(failure.expected))
|
||||
throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`)
|
||||
}
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
await Bun.write(path.join(snapshots, "08-unavailable-model.txt"), stdout + stderr)
|
||||
if (exitCode !== 1) throw new Error(`Unavailable model run exited with status ${exitCode}`)
|
||||
if (!stderr.includes("Model unavailable: simulation/definitely-missing"))
|
||||
throw new Error(`Unavailable model was not diagnosed after catalog settlement: ${stderr}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,19 +249,6 @@ function captureVisiblePane(session) {
|
||||
return tmux(["capture-pane", "-p", "-t", session])
|
||||
}
|
||||
|
||||
/** @param {string} session @param {string} text @param {number} [timeout] */
|
||||
async function waitForVisiblePane(session, text, timeout = 5_000) {
|
||||
const deadline = Date.now() + timeout
|
||||
let last = ""
|
||||
while (Date.now() < deadline) {
|
||||
last = await captureVisiblePane(session)
|
||||
if (last.includes(text)) return last
|
||||
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`)
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
async function paneAlive(session) {
|
||||
return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
|
||||
|
||||
@@ -18,17 +18,10 @@ describe("CLI frontend import boundaries", () => {
|
||||
test("exposes only the intentional package entrypoints", async () => {
|
||||
const run = await import("@opencode-ai/cli/run")
|
||||
const mini = await import("@opencode-ai/tui/mini")
|
||||
const tool = await import("@opencode-ai/tui/mini/tool")
|
||||
const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
|
||||
|
||||
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
||||
expect(Object.keys(tool).sort()).toEqual([
|
||||
"nonEmptyToolContent",
|
||||
"readDisplayText",
|
||||
"toolInlineInfo",
|
||||
"toolOutputText",
|
||||
])
|
||||
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -121,12 +121,11 @@ describe("mini command", () => {
|
||||
expect(result.stdout).toContain("run Run OpenCode with a message")
|
||||
})
|
||||
|
||||
test("exposes run without legacy interactive, attach, or command modes", async () => {
|
||||
test("exposes run without legacy attach or command modes", async () => {
|
||||
const result = await cli(["run", "--help"])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("--server string")
|
||||
expect(result.stdout).not.toContain("--interactive")
|
||||
expect(result.stdout).not.toContain("--variant")
|
||||
expect(result.stdout).not.toContain("--attach")
|
||||
expect(result.stdout).not.toContain("--command")
|
||||
@@ -139,47 +138,72 @@ describe("mini command", () => {
|
||||
expect(result.stderr).not.toContain("You must provide a message")
|
||||
})
|
||||
|
||||
test("passes explicit selections to session creation without catalog preflight", async () => {
|
||||
const requests: string[] = []
|
||||
let session: unknown
|
||||
test("validates an explicit model after one readiness wait", async () => {
|
||||
let modelRequests = 0
|
||||
let waitRequests = 0
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname === "/api/health")
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
if (url.pathname === "/api/session") {
|
||||
session = await request.json()
|
||||
return new Response("boom", { status: 500 })
|
||||
if (url.pathname === "/api/location/wait") {
|
||||
waitRequests++
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
}
|
||||
if (url.pathname === "/api/model") {
|
||||
modelRequests++
|
||||
return Response.json({
|
||||
location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
|
||||
data: [],
|
||||
})
|
||||
}
|
||||
return new Response(undefined, { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await cli([
|
||||
"run",
|
||||
"--server",
|
||||
server.url.toString(),
|
||||
"--model",
|
||||
"definitely/missing",
|
||||
"--agent",
|
||||
"definitely-missing",
|
||||
"hi",
|
||||
])
|
||||
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("UnexpectedStatus")
|
||||
expect(session).toMatchObject({
|
||||
agent: "definitely-missing",
|
||||
model: { providerID: "definitely", id: "missing" },
|
||||
})
|
||||
expect(requests).not.toContain("/api/model")
|
||||
expect(requests).not.toContain("/api/agent")
|
||||
expect(requests).not.toContain("/api/location/wait")
|
||||
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
||||
expect(waitRequests).toBe(1)
|
||||
expect(modelRequests).toBe(1)
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("distinguishes catalog initialization timeout from settled model absence", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health")
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
if (url.pathname === "/api/location/wait")
|
||||
return Response.json(
|
||||
{
|
||||
_tag: "ServiceUnavailableError",
|
||||
message: "Location catalog initialization timed out",
|
||||
service: "location.catalog",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
return new Response(undefined, { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("Location catalog initialization timed out")
|
||||
expect(result.stderr).not.toContain("Model unavailable")
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
@@ -213,16 +237,11 @@ describe("mini command", () => {
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("--server 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")
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
|
||||
@@ -130,25 +130,25 @@ function failedTool(inputID: string): V2Event[] {
|
||||
id: "evt_failed_tool_progress",
|
||||
created: 3,
|
||||
type: "session.tool.progress",
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
metadata: { checkpoint: 1 },
|
||||
structured: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_failed_tool_terminal",
|
||||
created: 4,
|
||||
type: "session.tool.failed",
|
||||
durable: { aggregateID: "ses_1", seq: 4, version: 2 },
|
||||
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_failed_tool",
|
||||
callID: "call_failed_tool",
|
||||
error: { type: "unknown", message: "tool failed" },
|
||||
metadata: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
executed: true,
|
||||
},
|
||||
},
|
||||
@@ -156,53 +156,6 @@ function failedTool(inputID: string): V2Event[] {
|
||||
]
|
||||
}
|
||||
|
||||
function successfulGrep(inputID: string): V2Event[] {
|
||||
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
|
||||
return [
|
||||
prompted(inputID),
|
||||
{
|
||||
id: "evt_grep_input",
|
||||
created: 1,
|
||||
type: "session.tool.input.started",
|
||||
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_grep",
|
||||
callID: "call_grep",
|
||||
name: "grep",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_grep_called",
|
||||
created: 2,
|
||||
type: "session.tool.called",
|
||||
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_grep",
|
||||
callID: "call_grep",
|
||||
input: { pattern: "needle" },
|
||||
executed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_grep_success",
|
||||
created: 3,
|
||||
type: "session.tool.success",
|
||||
durable: { aggregateID: "ses_1", seq: 3, version: 2 },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_grep",
|
||||
callID: "call_grep",
|
||||
metadata: { matches: 2 },
|
||||
content: [{ type: "text", text }],
|
||||
executed: false,
|
||||
},
|
||||
},
|
||||
settled(),
|
||||
]
|
||||
}
|
||||
|
||||
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
||||
// live events the prompt admission triggers, keyed by the generated message ID.
|
||||
async function run(input: {
|
||||
@@ -216,7 +169,6 @@ async function run(input: {
|
||||
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
|
||||
messages?: (inputID: string) => SessionMessageInfo[]
|
||||
wait?: () => Promise<void>
|
||||
terminalDelay?: number
|
||||
}) {
|
||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||
@@ -231,10 +183,7 @@ async function run(input: {
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (value.type.startsWith("session.execution.")) {
|
||||
if (input.terminalDelay) await Bun.sleep(input.terminalDelay)
|
||||
setTimeout(wait.resolve, 0)
|
||||
}
|
||||
if (value.type.startsWith("session.execution.")) setTimeout(wait.resolve, 0)
|
||||
yield value
|
||||
}
|
||||
})()
|
||||
@@ -257,7 +206,9 @@ async function run(input: {
|
||||
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
|
||||
spyOn(sdk.message, "list").mockImplementation(() =>
|
||||
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: {},
|
||||
}),
|
||||
)
|
||||
@@ -300,7 +251,7 @@ async function capture(input: Parameters<typeof run>[0]) {
|
||||
})
|
||||
try {
|
||||
await run(input)
|
||||
return { stdout: stdout.join(""), stderr: stderr.join(""), exitCode: process.exitCode }
|
||||
return { stdout: stdout.join(""), stderr: stderr.join("") }
|
||||
} finally {
|
||||
process.exitCode = exitCode ?? 0
|
||||
stdoutWrite.mockRestore()
|
||||
@@ -313,32 +264,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe("runNonInteractivePrompt", () => {
|
||||
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
|
||||
const output = await capture({ format: "json", turn: successfulGrep })
|
||||
const events = output.stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "tool_use",
|
||||
part: {
|
||||
tool: "grep",
|
||||
state: {
|
||||
status: "completed",
|
||||
output: expect.stringContaining("Found 2 matches"),
|
||||
metadata: {
|
||||
metadata: { matches: 2 },
|
||||
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.result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses session.wait then reconciles projected output without a terminal event", async () => {
|
||||
const idle = Promise.withResolvers<void>()
|
||||
let done = false
|
||||
@@ -394,26 +319,6 @@ describe("runNonInteractivePrompt", () => {
|
||||
error: { type: "provider.transport", message: "instructions unavailable" },
|
||||
}),
|
||||
])
|
||||
expect(output.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
test("waits for a terminal failure when idle wins before projection", async () => {
|
||||
for (const promotedBeforeFailure of [true, false]) {
|
||||
const output = await capture({
|
||||
format: "json",
|
||||
turn: (messageID) => [
|
||||
...(promotedBeforeFailure ? [prompted(messageID)] : []),
|
||||
executionFailed("selection unavailable"),
|
||||
],
|
||||
messages: (messageID) =>
|
||||
promotedBeforeFailure ? [{ id: messageID, type: "user", text: "hello", time: { created: 1 } }] : [],
|
||||
wait: () => Promise.resolve(),
|
||||
terminalDelay: 10,
|
||||
})
|
||||
|
||||
expect(output.exitCode).toBe(1)
|
||||
expect(output.stdout).toContain("selection unavailable")
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
|
||||
@@ -508,14 +413,14 @@ describe("runNonInteractivePrompt", () => {
|
||||
],
|
||||
})
|
||||
|
||||
expect(output).toEqual({ stdout: "", stderr: "", exitCode: 0 })
|
||||
expect(output).toEqual({ stdout: "", stderr: "" })
|
||||
})
|
||||
|
||||
test("renders a native terminal failure snapshot when live progress was missed", async () => {
|
||||
test("renders native failed tool output before the terminal error", async () => {
|
||||
const rendered: SessionMessageAssistantTool[] = []
|
||||
const failed: SessionMessageAssistantTool[] = []
|
||||
await capture({
|
||||
turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"),
|
||||
turn: failedTool,
|
||||
renderTool: (part) => {
|
||||
rendered.push(part)
|
||||
return Promise.resolve()
|
||||
@@ -531,7 +436,7 @@ describe("runNonInteractivePrompt", () => {
|
||||
id: "call_failed_tool",
|
||||
state: {
|
||||
status: "completed",
|
||||
metadata: { checkpoint: 1 },
|
||||
structured: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
},
|
||||
},
|
||||
@@ -541,7 +446,7 @@ describe("runNonInteractivePrompt", () => {
|
||||
id: "call_failed_tool",
|
||||
state: {
|
||||
status: "error",
|
||||
metadata: { checkpoint: 1 },
|
||||
structured: { checkpoint: 1 },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
error: { message: "tool failed" },
|
||||
},
|
||||
@@ -571,7 +476,7 @@ describe("runNonInteractivePrompt", () => {
|
||||
},
|
||||
})
|
||||
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(output.stderr).toBe("")
|
||||
})
|
||||
|
||||
@@ -78,15 +78,6 @@ describe("session target resolver", () => {
|
||||
expect(order).toEqual(["prepare", "create"])
|
||||
})
|
||||
|
||||
test("uses the agent resolved by the server for a fresh Session", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
|
||||
|
||||
const target = await resolveSessionTarget({ client, prepare })
|
||||
expect(target.agent).toBe("review")
|
||||
})
|
||||
|
||||
test("does not retry an ambiguous Session creation", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||
|
||||
@@ -75,10 +75,6 @@ export const define = sdk.Plugin.define`
|
||||
const effectPluginModule = promisePluginModule
|
||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||
.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")]
|
||||
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
|
||||
export const Tool = sdk.Tool
|
||||
@@ -87,8 +83,10 @@ export const RegistrationError = sdk.Tool.RegistrationError
|
||||
export const make = sdk.Tool.make
|
||||
export const validateName = sdk.Tool.validateName
|
||||
export const registrationEntries = sdk.Tool.registrationEntries
|
||||
export const validateNamespace = sdk.Tool.validateNamespace
|
||||
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
|
||||
export const withPermission = sdk.Tool.withPermission
|
||||
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(" ")}
|
||||
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"
|
||||
@@ -102,7 +100,6 @@ const require = __cjs_mod__.createRequire(import.meta.url)
|
||||
const __ocPluginModules = ${JSON.stringify({
|
||||
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
|
||||
"@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/plugin": "opencode:plugin-v2-effect-plugin",
|
||||
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||
@@ -110,7 +107,6 @@ const __ocPluginModules = ${JSON.stringify({
|
||||
const __ocPluginSources = ${JSON.stringify({
|
||||
"opencode:plugin-v2": promiseModule,
|
||||
"opencode:plugin-v2-plugin": promisePluginModule,
|
||||
"opencode:plugin-v2-tool": promiseToolModule,
|
||||
"opencode:plugin-v2-effect": effectModule,
|
||||
"opencode:plugin-v2-effect-plugin": effectPluginModule,
|
||||
"opencode:plugin-v2-effect-tool": effectToolModule,
|
||||
|
||||
@@ -32,8 +32,14 @@ export type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]
|
||||
export type Endpoint2_0Output = EffectValue<ReturnType<RawClient["server.location"]["location.get"]>>
|
||||
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
|
||||
|
||||
type Endpoint2_1Request = Parameters<RawClient["server.location"]["location.wait"]>[0]
|
||||
export type Endpoint2_1Input = { readonly location?: Endpoint2_1Request["query"]["location"] }
|
||||
export type Endpoint2_1Output = EffectValue<ReturnType<RawClient["server.location"]["location.wait"]>>
|
||||
export type LocationWaitOperation<E = never> = (input?: Endpoint2_1Input) => Effect.Effect<Endpoint2_1Output, E>
|
||||
|
||||
export interface LocationApi<E = never> {
|
||||
readonly get: LocationGetOperation<E>
|
||||
readonly wait: LocationWaitOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint3_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
|
||||
|
||||
@@ -33,7 +33,12 @@ type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["locat
|
||||
const Endpoint2_0 = (raw: RawClient["server.location"]) => (input?: Endpoint2_0Input) =>
|
||||
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw) })
|
||||
type Endpoint2_1Request = Parameters<RawClient["server.location"]["location.wait"]>[0]
|
||||
type Endpoint2_1Input = { readonly location?: Endpoint2_1Request["query"]["location"] }
|
||||
const Endpoint2_1 = (raw: RawClient["server.location"]) => (input?: Endpoint2_1Input) =>
|
||||
raw["location.wait"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw), wait: Endpoint2_1(raw) })
|
||||
|
||||
type Endpoint3_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
|
||||
type Endpoint3_0Input = { readonly location?: Endpoint3_0Request["query"]["location"] }
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
ServerGetOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationWaitInput,
|
||||
LocationWaitOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
PluginListInput,
|
||||
@@ -385,6 +387,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
wait: (input?: LocationWaitInput, requestOptions?: RequestOptions) =>
|
||||
request<LocationWaitOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/location/wait`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
agent: {
|
||||
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
export type ProviderSettings = { [x: string]: JsonValue }
|
||||
|
||||
export type AgentColor = string
|
||||
export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info"
|
||||
|
||||
export type PermissionV2Effect = "allow" | "deny" | "ask"
|
||||
|
||||
@@ -104,12 +104,6 @@ export type SessionMessageProviderState = { [x: string]: JsonValue }
|
||||
|
||||
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 ToolFileContent = { type: "file"; uri: string; mime: string; name?: string }
|
||||
@@ -924,15 +918,6 @@ export type SessionToolInputDelta = {
|
||||
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 = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1402,6 +1387,24 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionToolFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionStructuredError
|
||||
result?: any
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState7
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
|
||||
|
||||
export type ModelCost = {
|
||||
@@ -1824,19 +1827,44 @@ export type SessionPendingUserData1 = {
|
||||
metadata?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateRunning = {
|
||||
status: "running"
|
||||
input: { [x: string]: JsonValue }
|
||||
structured: { [x: string]: JsonValue }
|
||||
content: Array<LLMToolContent>
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateCompleted = {
|
||||
status: "completed"
|
||||
input: { [x: string]: JsonValue }
|
||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
content: Array<LLMToolContent>
|
||||
structured: { [x: string]: JsonValue }
|
||||
result?: JsonValue
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateError = {
|
||||
status: "error"
|
||||
input: { [x: string]: JsonValue }
|
||||
content: Array<LLMToolContent>
|
||||
structured: { [x: string]: JsonValue }
|
||||
error: SessionStructuredError
|
||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
result?: JsonValue
|
||||
}
|
||||
|
||||
export type SessionToolProgress = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.progress"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
structured: { [x: string]: any }
|
||||
content: Array<LLMToolContent>
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionToolSuccess = {
|
||||
@@ -1844,38 +1872,20 @@ export type SessionToolSuccess = {
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.success"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
structured: { [x: string]: any }
|
||||
content: Array<LLMToolContent>
|
||||
result?: any
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState6
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionToolFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionStructuredError
|
||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState7
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
@@ -2214,6 +2224,7 @@ export type SessionEventDurable =
|
||||
| SessionToolInputStarted
|
||||
| SessionToolInputEnded
|
||||
| SessionToolCalled
|
||||
| SessionToolProgress
|
||||
| SessionToolSuccess
|
||||
| SessionToolFailed
|
||||
| SessionRetryScheduled
|
||||
@@ -2379,6 +2390,14 @@ export type InvalidRequestError = {
|
||||
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
@@ -2432,14 +2451,6 @@ export type SkillNotFoundError = {
|
||||
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type SessionBusyError = {
|
||||
readonly _tag: "SessionBusyError"
|
||||
readonly sessionID: string
|
||||
@@ -2551,6 +2562,14 @@ export type LocationGetInput = {
|
||||
|
||||
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
|
||||
export type LocationWaitInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type LocationWaitOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
|
||||
export type AgentListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -48,6 +48,29 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("location.wait uses the explicit initial-settlement barrier", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({
|
||||
directory: "/tmp/project",
|
||||
workspaceID: "wrk_test",
|
||||
project: { id: "proj_test", directory: "/tmp/project" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.location.wait({ location: { directory: "/tmp/project", workspace: "wrk_test" } })
|
||||
|
||||
expect(result.workspaceID).toBe("wrk_test")
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe(
|
||||
"http://localhost:3000/api/location/wait?location%5Bdirectory%5D=%2Ftmp%2Fproject&location%5Bworkspace%5D=wrk_test",
|
||||
)
|
||||
})
|
||||
|
||||
test("server.get uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -33,7 +33,7 @@ const lookupOrder = Tool.make({
|
||||
description: "Look up an order by ID",
|
||||
input: Schema.Struct({ id: 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({
|
||||
@@ -55,10 +55,10 @@ const result = await Effect.runPromise(
|
||||
### `Tool.make`
|
||||
|
||||
`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
|
||||
only shape the model-visible signature. Without `output`, the signature uses `Promise<void>`.
|
||||
decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only
|
||||
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
|
||||
`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
|
||||
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
|
||||
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] 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
|
||||
|
||||
@@ -45,7 +45,7 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
|
||||
const program = parseProgram(options.code)
|
||||
const promises = new PromiseRuntime<Services<Provided>>(scope)
|
||||
const interpreter = new Interpreter<Services<Provided>>(
|
||||
tools.execute,
|
||||
tools.invoke,
|
||||
tools.search,
|
||||
tools.keys,
|
||||
promises,
|
||||
|
||||
@@ -271,10 +271,7 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution" }
|
||||
|
||||
export class Interpreter<R> {
|
||||
private scopes: ScopeStack
|
||||
private readonly executeTool: (
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
) => Effect.Effect<unknown, unknown, R>
|
||||
private readonly invokeTool: (path: ReadonlyArray<string>, 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 logs: Array<string>
|
||||
@@ -289,7 +286,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
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>,
|
||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||
promises: PromiseRuntime<R>,
|
||||
@@ -297,7 +294,7 @@ export class Interpreter<R> {
|
||||
) {
|
||||
const globalScope = new Map<string, Binding>()
|
||||
this.scopes = new ScopeStack([globalScope])
|
||||
this.executeTool = executeTool
|
||||
this.invokeTool = invokeTool
|
||||
this.invokeSearch = invokeSearch
|
||||
this.toolKeys = toolKeys
|
||||
this.logs = logs
|
||||
@@ -372,7 +369,7 @@ export class Interpreter<R> {
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): 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> {
|
||||
@@ -2082,7 +2079,7 @@ export class Interpreter<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()])
|
||||
const run = Effect.gen(function* () {
|
||||
// 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 { make, type Tool } from "../tool.js"
|
||||
import { make, type Definition } from "../tool.js"
|
||||
import { invoke } from "./runtime.js"
|
||||
import {
|
||||
componentDefinitions,
|
||||
@@ -108,7 +108,7 @@ export const fromSpec = (options: Options): Result => {
|
||||
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
||||
input: inputSchema(input.fields, requestDefinitions),
|
||||
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 }
|
||||
}
|
||||
|
||||
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
|
||||
if (head === undefined) return
|
||||
if (rest.length === 0) {
|
||||
tools[head] = tool
|
||||
tools[head] = definition
|
||||
return
|
||||
}
|
||||
const child = tools[head]
|
||||
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
|
||||
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 { 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. */
|
||||
export type Document = Record<string, unknown>
|
||||
@@ -58,7 +58,7 @@ export type Skipped = {
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export type Tools = { [name: string]: Tool<HttpClient.HttpClient> | Tools }
|
||||
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
|
||||
|
||||
export type Result = {
|
||||
/** Namespaced tools; the host places them under a key in its `tools` object. */
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
inputTypeScript,
|
||||
outputTypeScript,
|
||||
} 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 {
|
||||
CodeModeDate,
|
||||
@@ -28,7 +28,7 @@ type ServicesOf<T, Depth extends ReadonlyArray<unknown>> = Depth["length"] exten
|
||||
? never
|
||||
: T extends {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly execute: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
||||
}
|
||||
? R
|
||||
: T extends object
|
||||
@@ -118,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> =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
@@ -284,9 +286,9 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
|
||||
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> = {
|
||||
tool?: Tool<R>
|
||||
definition?: Definition<R>
|
||||
readonly children: Map<string, ToolNode<R>>
|
||||
}
|
||||
|
||||
@@ -301,7 +303,7 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
||||
current.children.set(segment, child)
|
||||
current = child
|
||||
}
|
||||
if (isTool<R>(value)) current.tool = value
|
||||
if (isDefinition(value)) current.definition = value
|
||||
else insert(current, value)
|
||||
}
|
||||
}
|
||||
@@ -312,25 +314,25 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
|
||||
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
|
||||
path.flatMap((segment) => segment.split("."))
|
||||
|
||||
const flattenTools = <R>(
|
||||
const definitions = <R>(
|
||||
node: ToolNode<R>,
|
||||
path: ReadonlyArray<string> = [],
|
||||
): Array<{ path: string; tool: Tool<R> }> => [
|
||||
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
|
||||
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
|
||||
): Array<{ path: string; definition: Definition<R> }> => [
|
||||
...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]),
|
||||
...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,
|
||||
description: tool.description,
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
|
||||
description: definition.description,
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
|
||||
})
|
||||
|
||||
const visibleTools = <R>(tools: Tools<R>) =>
|
||||
flattenTools(toolTrie(tools)).map(({ path, tool }) => ({
|
||||
const visibleDefinitions = <R>(tools: Tools<R>) =>
|
||||
definitions(toolTrie(tools)).map(({ path, definition }) => ({
|
||||
path,
|
||||
tool,
|
||||
description: describeTool(path, tool),
|
||||
definition,
|
||||
description: describeDefinition(path, definition),
|
||||
}))
|
||||
|
||||
export type DiscoveryPlan = {
|
||||
@@ -359,12 +361,12 @@ const termForms = (term: string): Array<string> => {
|
||||
return forms
|
||||
}
|
||||
|
||||
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
|
||||
_tag: "CodeModeTool",
|
||||
description: "Search available tools",
|
||||
input: SearchInput,
|
||||
output: SearchOutput,
|
||||
execute: (input) =>
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
const request = input as typeof SearchInput.Type
|
||||
const query = request.query ?? ""
|
||||
@@ -420,8 +422,8 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
})
|
||||
|
||||
const searchSignature = (() => {
|
||||
const tool = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
|
||||
const definition = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
|
||||
})()
|
||||
|
||||
const catalogLine = (tool: ToolDescription) => {
|
||||
@@ -430,13 +432,13 @@ const catalogLine = (tool: ToolDescription) => {
|
||||
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,
|
||||
namespace: path.split(".", 1)[0]!,
|
||||
searchText: [
|
||||
path,
|
||||
tool.description,
|
||||
...inputProperties(tool).flatMap(({ name, description: property }) =>
|
||||
definition.description,
|
||||
...inputProperties(definition).flatMap(({ name, description: property }) =>
|
||||
property === undefined ? [name] : [name, property],
|
||||
),
|
||||
]
|
||||
@@ -445,14 +447,14 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
|
||||
})
|
||||
|
||||
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.
|
||||
export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
|
||||
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
|
||||
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 namespaces = new Map<string, Array<ToolDescription>>()
|
||||
@@ -587,7 +589,7 @@ export const prepare = <R>(tools: Tools<R>, catalogBudget = defaultCatalogBudget
|
||||
return {
|
||||
catalog: described,
|
||||
instructions: lines.join("\n"),
|
||||
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
|
||||
searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +605,7 @@ const namespaceKeys = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Reado
|
||||
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 node = lookup(root, segments)
|
||||
if (node === undefined) {
|
||||
@@ -611,16 +613,16 @@ const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> =>
|
||||
"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.`)
|
||||
}
|
||||
return node.tool
|
||||
return node.definition
|
||||
}
|
||||
|
||||
export type ToolRuntime<R = never> = {
|
||||
readonly root: ToolReference
|
||||
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 keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
}
|
||||
@@ -674,7 +676,7 @@ export const make = <R>(
|
||||
return calls.length - 1
|
||||
}).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* () {
|
||||
if (externalArgs.length !== 1)
|
||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
@@ -686,7 +688,7 @@ export const make = <R>(
|
||||
const index = yield* recordAndObserve(name, input)
|
||||
return yield* observeEnd(
|
||||
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({
|
||||
try: () => decodeToolOutput(tool, raw),
|
||||
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
||||
@@ -703,18 +705,18 @@ export const make = <R>(
|
||||
keys: (path) => namespaceKeys(root, path),
|
||||
search: (args) =>
|
||||
Effect.suspend(() =>
|
||||
executeTool(
|
||||
invokeDefinition(
|
||||
"search",
|
||||
searchTool,
|
||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
|
||||
),
|
||||
),
|
||||
execute: (path, args) =>
|
||||
invoke: (path, args) =>
|
||||
Effect.gen(function* () {
|
||||
const name = canonicalSegments(path).join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
|
||||
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 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)
|
||||
|
||||
@@ -192,16 +192,16 @@ export type InputProperty = {
|
||||
readonly required: boolean
|
||||
}
|
||||
|
||||
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
|
||||
try {
|
||||
const document = isEffectSchema(tool.input)
|
||||
? (Schema.toJsonSchemaDocument(tool.input) as {
|
||||
const document = isEffectSchema(definition.input)
|
||||
? (Schema.toJsonSchemaDocument(definition.input) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
})
|
||||
: {
|
||||
schema: tool.input,
|
||||
definitions: { ...(tool.input.definitions ?? {}), ...(tool.input.$defs ?? {}) },
|
||||
schema: definition.input,
|
||||
definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
|
||||
}
|
||||
const definitions = document.definitions ?? {}
|
||||
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 =>
|
||||
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
|
||||
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
isEffectSchema(definition.input)
|
||||
? toTypeScript(definition.input, false, pretty)
|
||||
: jsonSchemaToTypeScript(definition.input, pretty)
|
||||
|
||||
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
tool.output === undefined
|
||||
? "void"
|
||||
: isEffectSchema(tool.output)
|
||||
? toTypeScript(tool.output, true, pretty)
|
||||
: jsonSchemaToTypeScript(tool.output, pretty)
|
||||
export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
definition.output === undefined
|
||||
? "unknown"
|
||||
: isEffectSchema(definition.output)
|
||||
? toTypeScript(definition.output, true, pretty)
|
||||
: jsonSchemaToTypeScript(definition.output, pretty)
|
||||
|
||||
export const decodeInput = <R>(tool: Tool<R>, value: unknown): unknown =>
|
||||
isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(value) : value
|
||||
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
|
||||
|
||||
export const decodeOutput = <R>(tool: Tool<R>, value: unknown): unknown =>
|
||||
tool.output === undefined
|
||||
? undefined
|
||||
: isEffectSchema(tool.output)
|
||||
? Schema.decodeUnknownSync(tool.output)(value)
|
||||
: value
|
||||
export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||
definition.output !== undefined && isEffectSchema(definition.output)
|
||||
? Schema.decodeUnknownSync(definition.output)(value)
|
||||
: value
|
||||
|
||||
@@ -29,29 +29,29 @@ export type JsonSchema = {
|
||||
/** Either a validating Effect Schema or a render-only JSON Schema document. */
|
||||
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
|
||||
|
||||
/** Executable tool exposed through CodeMode's `tools` object. */
|
||||
export type Tool<R = never> = {
|
||||
/** Schema-backed tool definition exposed through CodeMode's `tools` object. */
|
||||
export type Definition<R = never> = {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly description: string
|
||||
readonly input: SchemaType
|
||||
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 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> = {
|
||||
readonly description: string
|
||||
readonly input: I
|
||||
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.
|
||||
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
|
||||
// Object.hasOwn: an inherited _tag must not classify a namespace as a Definition.
|
||||
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"_tag" in value &&
|
||||
@@ -59,18 +59,18 @@ export const isTool = <R = never>(value: unknown): value is Tool<R> =>
|
||||
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.
|
||||
* 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.
|
||||
*/
|
||||
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
|
||||
options: Options<I, O, R>,
|
||||
): Tool<R> => ({
|
||||
): Definition<R> => ({
|
||||
_tag: "CodeModeTool",
|
||||
description: options.description,
|
||||
input: options.input,
|
||||
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> = {
|
||||
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",
|
||||
input: Schema.Struct({ id: 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 toolError = async (code: string) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
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({})"))
|
||||
|
||||
class UnsafeHostError extends Schema.TaggedErrorClass<UnsafeHostError>()("UnsafeHostError", {
|
||||
@@ -16,7 +16,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Fail safely",
|
||||
input: Schema.Struct({}),
|
||||
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",
|
||||
input: Schema.Struct({}),
|
||||
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",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => failure,
|
||||
run: () => failure,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Return invalid output",
|
||||
input: Schema.Struct({}),
|
||||
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",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Unknown,
|
||||
execute: () =>
|
||||
run: () =>
|
||||
Effect.succeed(
|
||||
new Proxy(
|
||||
{},
|
||||
@@ -118,7 +118,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Refuse",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.fail(toolError("Refused")),
|
||||
run: () => Effect.fail(toolError("Refused")),
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -145,7 +145,7 @@ describe("CodeMode host failure boundary", () => {
|
||||
description: "Interrupt",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.interrupt,
|
||||
run: () => Effect.interrupt,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -166,7 +166,7 @@ describe("CodeMode tool-call observation", () => {
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ query }) => Effect.succeed(query),
|
||||
run: ({ query }) => Effect.succeed(query),
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
@@ -189,7 +189,7 @@ describe("CodeMode tool-call observation", () => {
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: 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({
|
||||
@@ -430,7 +430,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
properties: { id: { type: "string" }, count: { type: "number" } },
|
||||
required: ["id"],
|
||||
},
|
||||
execute: (input) =>
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return { echoed: input }
|
||||
@@ -442,14 +442,14 @@ describe("CodeMode schema flexibility", () => {
|
||||
{
|
||||
path: "adapter.call",
|
||||
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.
|
||||
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
|
||||
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 }])
|
||||
})
|
||||
|
||||
@@ -458,7 +458,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
const call = Tool.make({
|
||||
description: "Observe raw input",
|
||||
input: { type: "object" },
|
||||
execute: (input) =>
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return "ok"
|
||||
@@ -483,7 +483,7 @@ describe("CodeMode schema flexibility", () => {
|
||||
const find = Tool.make({
|
||||
description: "Find things",
|
||||
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
|
||||
execute: (input) =>
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
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 } } })
|
||||
|
||||
@@ -534,18 +534,18 @@ describe("CodeMode schema flexibility", () => {
|
||||
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({
|
||||
description: "Ping",
|
||||
input: Schema.Struct({ host: Schema.String }),
|
||||
execute: () => Effect.succeed("pong"),
|
||||
run: () => Effect.succeed("pong"),
|
||||
})
|
||||
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" })`))
|
||||
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",
|
||||
input: Schema.Struct({ id: 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 source = `return await tools.orders.lookup({ id: "order_42" })`
|
||||
@@ -577,7 +577,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "echo",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Number,
|
||||
execute: () => Effect.succeed(1),
|
||||
run: () => Effect.succeed(1),
|
||||
})
|
||||
const effect = CodeMode.execute({
|
||||
tools: { host: { echo } },
|
||||
@@ -634,7 +634,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "Resolve a library ID",
|
||||
input: Schema.Struct({ libraryName: 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 } } })
|
||||
|
||||
@@ -760,18 +760,18 @@ describe("CodeMode public contract", () => {
|
||||
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({
|
||||
description: "Upload one readable local file to the current Discord thread",
|
||||
input: Schema.Struct({ path: Schema.String }),
|
||||
output: Schema.Struct({ sent: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ sent: true }),
|
||||
run: () => Effect.succeed({ sent: true }),
|
||||
})
|
||||
const generate = Tool.make({
|
||||
description: "Generate an image and upload it to the current Discord thread",
|
||||
input: Schema.Struct({ prompt: Schema.String }),
|
||||
output: Schema.Struct({ sent: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ sent: true }),
|
||||
run: () => Effect.succeed({ sent: true }),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
||||
@@ -865,7 +865,7 @@ describe("CodeMode public contract", () => {
|
||||
description: `Numbered tool ${index}`,
|
||||
input: Schema.Struct({ id: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
@@ -911,7 +911,7 @@ describe("CodeMode public contract", () => {
|
||||
description,
|
||||
input: Schema.Struct({ id: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
@@ -954,13 +954,13 @@ describe("CodeMode public contract", () => {
|
||||
properties: { attachment: { type: "string", description: "Local path of the payload to send" } },
|
||||
required: ["attachment"],
|
||||
},
|
||||
execute: () => Effect.succeed("ok"),
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
const other = Tool.make({
|
||||
description: "Rename the workspace",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { files: { upload, other } } })
|
||||
|
||||
@@ -990,7 +990,7 @@ describe("CodeMode public contract", () => {
|
||||
description,
|
||||
input: Schema.Struct({ id: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
@@ -1029,7 +1029,7 @@ describe("CodeMode public contract", () => {
|
||||
description,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
// Deliberately declared out of alphabetical order.
|
||||
const runtime = CodeMode.make({
|
||||
@@ -1071,7 +1071,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "Cheap",
|
||||
input: Schema.Struct({ q: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
const expensive = Tool.make({
|
||||
description:
|
||||
@@ -1081,7 +1081,7 @@ describe("CodeMode public contract", () => {
|
||||
anotherEvenLongerParameterName: Schema.Number,
|
||||
}),
|
||||
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
|
||||
// alpha.expensive does not fit, which marks only alpha done - it must NOT prevent
|
||||
@@ -1112,7 +1112,7 @@ describe("CodeMode public contract", () => {
|
||||
},
|
||||
required: ["id"],
|
||||
} as const,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { records: { lookup: documented } },
|
||||
@@ -1130,7 +1130,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "Double a number",
|
||||
input: Schema.Struct({ value: Schema.NumberFromString }),
|
||||
output: Schema.NumberFromString,
|
||||
execute: ({ value }) =>
|
||||
run: ({ value }) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(value)
|
||||
return String(value * 2)
|
||||
@@ -1226,7 +1226,7 @@ describe("CodeMode public contract", () => {
|
||||
description: "Count invocations",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Number,
|
||||
execute: () => Effect.succeed(1),
|
||||
run: () => Effect.succeed(1),
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
|
||||
@@ -13,7 +13,7 @@ const echo = (description: string) =>
|
||||
description,
|
||||
input: Schema.Struct({ value: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ value }) => Effect.succeed(value),
|
||||
run: ({ value }) => Effect.succeed(value),
|
||||
})
|
||||
|
||||
const tools = {
|
||||
|
||||
+19
-3
@@ -10561,10 +10561,26 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Agent.Color": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^#[0-9a-fA-F]{6}$"
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^#[0-9a-fA-F]{6}$"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"primary",
|
||||
"secondary",
|
||||
"accent",
|
||||
"success",
|
||||
"warning",
|
||||
"error",
|
||||
"info"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user