diff --git a/bun.lock b/bun.lock index 4e7e1f72e0..491dbd1679 100644 --- a/bun.lock +++ b/bun.lock @@ -873,6 +873,7 @@ "name": "@opencode-ai/simulation", "version": "1.17.13", "dependencies": { + "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opentui/core": "catalog:", @@ -1611,11 +1612,11 @@ "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], - "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@emotion/is-prop-valid": ["@emotion/is-prop-valid@0.8.8", "", { "dependencies": { "@emotion/memoize": "0.7.4" } }, "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA=="], @@ -1983,6 +1984,30 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.2", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.2", "@napi-rs/canvas-darwin-arm64": "1.0.2", "@napi-rs/canvas-darwin-x64": "1.0.2", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", "@napi-rs/canvas-linux-arm64-musl": "1.0.2", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-musl": "1.0.2", "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", "@napi-rs/canvas-win32-x64-msvc": "1.0.2" } }, "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], @@ -2277,7 +2302,7 @@ "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.127.0", "", { "os": "win32", "cpu": "x64" }, "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w=="], - "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="], @@ -2569,7 +2594,37 @@ "@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.4", "", { "os": "linux", "cpu": "arm" }, "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.4", "", { "os": "none", "cpu": "arm64" }, "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.4", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], @@ -3111,6 +3166,8 @@ "@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], @@ -3183,6 +3240,12 @@ "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], + "@vercel/cli-config": ["@vercel/cli-config@0.2.0", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ=="], + + "@vercel/cli-exec": ["@vercel/cli-exec@1.0.0", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug=="], + + "@vercel/functions": ["@vercel/functions@3.7.5", "", { "dependencies": { "@vercel/oidc": "3.8.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-ESf8BbeDebqRUyMi09JwRbQqpLn4g6fjcVVHPsHB56j2dSqRrSHO4h3X4aaxJf6iQQjzhAtDGI2xCWQ27JE8PA=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -4987,6 +5050,8 @@ "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], + "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="], @@ -5401,6 +5466,8 @@ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="], + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -6021,8 +6088,12 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], @@ -6619,6 +6690,10 @@ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -6639,6 +6714,8 @@ "@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], @@ -6747,6 +6824,14 @@ "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], + + "@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.0", "", { "dependencies": { "@vercel/cli-config": "0.2.0", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-r00laGW6Pv778RoR6M2NxX91ycSj+PBwVo+fOb9Bif+F0IyUKt25zrvBzfEzQpeAzbqOgPZyQibEWDdDFApd+A=="], + + "@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], "@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], @@ -7043,6 +7128,8 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "p-any/p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -7707,6 +7794,10 @@ "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -7715,6 +7806,8 @@ "@puppeteer/browsers/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], "@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], @@ -7785,6 +7878,22 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], @@ -8351,6 +8460,8 @@ "@stoplight/spectral-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts index e0f0d72233..0690f02706 100644 --- a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts @@ -20,7 +20,7 @@ const profiles = [ { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } }, { name: "multi patch", - tool: "apply_patch", + tool: "patch", input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] }, }, ] as const diff --git a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts index 798bf0df3b..f411339adb 100644 --- a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts @@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async ( userMessage(), assistantMessage( [ - toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), + toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), textPart(followingID, "Following incremental patch"), ], { completed: false }, @@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async ( partUpdated( toolPart( patchID, - "apply_patch", + "patch", "running", { files: [first.filePath, second.filePath] }, { metadata: { files: [first, second] } }, @@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async ( partUpdated( toolPart( patchID, - "apply_patch", + "patch", "completed", { files: [first.filePath, second.filePath, third.filePath] }, { metadata: { files: [first, second, third] } }, diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts index a86a55cff2..a22d5cc331 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -295,7 +295,7 @@ function performanceTurn(index: number) { messageID: assistantID, type: "tool", callID: `call_0000_${suffix}_patch`, - tool: "apply_patch", + tool: "patch", state: { status: "completed", input: { patchText: realisticPatch(index) }, diff --git a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts index 529081a1d9..e6fdd5d47b 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts @@ -131,7 +131,7 @@ function toolPart( ): MessagePart { const metadata = metadataOverride ?? - (tool === "apply_patch" + (tool === "patch" ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } : tool === "edit" || tool === "write" ? { @@ -219,7 +219,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] : []), ...(index % 8 === 0 - ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] + ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index f07da121c6..a591ff9470 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => { assistantMessage([ toolPart( id, - "apply_patch", + "patch", "completed", { files: ["src/a.ts"] }, { diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts index cb228c13c7..f0871a0da3 100644 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-state.spec.ts @@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn assistantMessage([ toolPart( patchID, - "apply_patch", + "patch", "completed", { files: files.map((file) => file.filePath) }, { metadata: { files } }, diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 9fd2ca8d0b..b6679ab4ed 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -246,7 +246,7 @@ function editPart(id: string) { function patchPart(id: string) { return toolPart( id, - "apply_patch", + "patch", "completed", { files: ["src/a.ts", "src/b.ts"] }, { diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index 99f1acf270..071b030078 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -8,7 +8,7 @@ import { } from "../performance/timeline-stability/fixture" test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { - const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] + const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] const parts = ordinary.map((tool, index) => toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), ) @@ -90,7 +90,7 @@ function questionInput() { function errorInput(tool: string) { if (tool === "bash") return { command: "exit 1" } if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } - if (tool === "apply_patch") return { files: ["src/error.ts"] } + if (tool === "patch") return { files: ["src/error.ts"] } if (tool === "webfetch") return { url: "https://example.com" } if (tool === "websearch") return { query: "failure" } if (tool === "task") return { description: "Fail task", subagent_type: "explore" } diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 3dce37cafd..ff857d8179 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -120,7 +120,7 @@ function toolPart( outputLength = 160, ): MessagePart { const metadata = - tool === "apply_patch" + tool === "patch" ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } : tool === "edit" || tool === "write" ? { @@ -199,7 +199,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] : []), ...(index % 8 === 0 - ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] + ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e5df40c7d2..53adfd155b 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -55,7 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const path = url.pathname 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: false }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 15d125df39..40b7acbbd3 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -8,7 +8,7 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" import type { State, VcsCache } from "./types" @@ -188,7 +188,7 @@ export function applyDirectoryEvent(input: { break } case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) break } diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 4b2be505ea..3dda5a5429 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -5,7 +5,7 @@ import type { PermissionRequest, QuestionRequest, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" @@ -33,7 +33,7 @@ describe("app session cache", () => { test("dropSessionCaches clears orphaned parts without message rows", () => { const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record @@ -67,7 +67,7 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 05cdc84643..39535abe2f 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -4,7 +4,7 @@ import type { PermissionRequest, QuestionRequest, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" @@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 86b489cd09..a117e4d007 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -13,7 +13,7 @@ import type { ReferenceInfo, Session, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" @@ -51,7 +51,7 @@ export type State = { } session_working(id: string): boolean session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } todo: { [sessionID: string]: Todo[] diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 6898cc2304..a46eb744d1 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -8,7 +8,7 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" import { batch } from "solid-js" @@ -139,7 +139,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const [data, setData] = createStore({ info: {} as Record, session_status: {} as Record, - session_diff: {} as Record, + session_diff: {} as Record, todo: {} as Record, permission: {} as Record, question: {} as Record, @@ -769,7 +769,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) return } diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 3854bf0276..586942399d 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,6 @@ import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SessionReview } from "@opencode-ai/session-ui/session-review" import type { SessionReviewCommentActions, @@ -14,7 +14,7 @@ import type { LineComment } from "@/context/comments" export type DiffStyle = "unified" | "split" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff export interface SessionReviewTabProps { title?: JSX.Element diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 3f44aba488..01384a6ee9 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -23,7 +23,6 @@ import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSettings } from "@/context/settings" -import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { @@ -36,15 +35,9 @@ import { import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" -type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff - -function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { - return typeof value.file === "string" -} - export function SessionSidePanel(props: { canReview: () => boolean - diffs: () => (SnapshotFileDiff | VcsFileDiff)[] + diffs: () => (FileDiffInfo | VcsFileDiff)[] diffsReady: () => boolean empty: () => string hasReview: () => boolean @@ -59,7 +52,6 @@ export function SessionSidePanel(props: { }) { const layout = useLayout() const settings = useSettings() - const sync = useSync() const file = useFile() const language = useLanguage() const command = useCommand() @@ -88,7 +80,7 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffs = createMemo(() => props.diffs().filter(renderDiff)) + const diffs = createMemo(() => props.diffs()) const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 2e2c65af3b..bb7950079e 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1245,7 +1245,7 @@ export function MessageTimeline(props: { const value = row() if (value._tag !== "AssistantPart" || value.group.type !== "part") return false const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID) - return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool) + return part?.type === "tool" && ["edit", "write", "patch", "apply_patch"].includes(part.tool) } const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile()) let contentMeasureFrame: number | undefined diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index c288c35706..760ace3311 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,17 +1,13 @@ -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { Kind } from "@/components/file-tree-v2" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" -export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +export type RenderDiff = FileDiffInfo | VcsFileDiff export function normalizePath(p: string) { return normalizeFileTreeV2Path(p) } -export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { - return typeof value.file === "string" -} - export function reviewDiffKinds(diffs: RenderDiff[]) { const merge = (a: Kind | undefined, b: Kind) => { if (!a) return b diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index fcd52756ab..41f75a081e 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,5 +1,5 @@ import { createMemo, createSignal, Show, type JSX } from "solid-js" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, @@ -21,16 +21,11 @@ import type { import FileTreeV2 from "@/components/file-tree-v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" -import { - filterRenderableDiff, - filterReviewFiles, - reviewDiffKinds, - type RenderDiff, -} from "@/pages/session/v2/review-diff-kinds" +import { filterReviewFiles, reviewDiffKinds, type RenderDiff } from "@/pages/session/v2/review-diff-kinds" import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff export type ReviewPanelV2Props = { title?: JSX.Element @@ -54,7 +49,7 @@ export type ReviewPanelV2Props = { export function ReviewPanelV2(props: ReviewPanelV2Props) { const sdk = useSDK() - const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff)) + const diffs = createMemo(() => props.diffs()) const filteredFiles = createMemo(() => filterReviewFiles( diffs().map((diff) => diff.file), diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index 5fbca469b7..f6d768e1de 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/sdk/v2" import type { Message } from "@opencode-ai/sdk/v2/client" import { diffs, message } from "./diffs" @@ -9,7 +9,7 @@ const item = { additions: 1, deletions: 1, status: "modified", -} satisfies SnapshotFileDiff +} satisfies FileDiffInfo describe("diffs", () => { test("keeps valid arrays", () => { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index 0cb2504fbe..60df039410 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,7 +1,7 @@ -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/sdk/v2" import type { Message } from "@opencode-ai/sdk/v2/client" -type Diff = SnapshotFileDiff | VcsFileDiff +type Diff = FileDiffInfo function diff(value: unknown): value is Diff { if (!value || typeof value !== "object" || Array.isArray(value)) return false diff --git a/packages/cli/bin/opencode2.cjs b/packages/cli/bin/opencode2.cjs old mode 100644 new mode 100755 diff --git a/packages/cli/src/mini/catalog.shared.ts b/packages/cli/src/mini/catalog.shared.ts index 535147e651..0868d3a94b 100644 --- a/packages/cli/src/mini/catalog.shared.ts +++ b/packages/cli/src/mini/catalog.shared.ts @@ -37,7 +37,8 @@ function defaultCost(model: CurrentModel) { export function runAgent(input: CurrentAgent): RunAgent { return { - name: input.id, + id: input.id, + name: input.name, description: input.description, mode: input.mode, hidden: input.hidden, @@ -53,7 +54,7 @@ export function runCommand(input: CurrentCommand): RunCommand { export function runSkill(input: CurrentSkill): RunCommand { return { - name: input.name, + name: input.id, description: input.description, source: "skill", } diff --git a/packages/cli/src/mini/demo.ts b/packages/cli/src/mini/demo.ts index b06b3c4537..ed0b6878f5 100644 --- a/packages/cli/src/mini/demo.ts +++ b/packages/cli/src/mini/demo.ts @@ -628,11 +628,11 @@ function emitEdit(state: State): void { function emitPatch(state: State): void { const file = path.join(process.cwd(), "src", "demo-format.ts") - const ref = make(state, "apply_patch", { + const ref = make(state, "patch", { patchText: "*** Begin Patch\n*** End Patch", }) doneTool(state, ref, { - title: "apply_patch", + title: "patch", output: "", metadata: { files: [ diff --git a/packages/cli/src/mini/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx index 6ba7940ab6..4c6214d9a8 100644 --- a/packages/cli/src/mini/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -333,10 +333,10 @@ export function createPromptState(input: PromptInput): PromptState { .map((item) => ({ kind: "mention", display: "@" + item.name, - value: item.name, + value: item.id, part: { type: "agent", - name: item.name, + name: item.id, source: { start: 0, end: 0, diff --git a/packages/cli/src/mini/footer.ts b/packages/cli/src/mini/footer.ts index ab53e9162e..0a2510f167 100644 --- a/packages/cli/src/mini/footer.ts +++ b/packages/cli/src/mini/footer.ts @@ -84,7 +84,6 @@ type RunFooterOptions = { theme: RunTheme keymap: Keymap tuiConfig: RunTuiConfig - backgroundSubagents: boolean diffStyle: RunDiffStyle onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise @@ -326,7 +325,6 @@ export class RunFooter implements FooterApi { theme: footer.theme, diffStyle: options.diffStyle, tuiConfig: options.tuiConfig, - backgroundSubagents: options.backgroundSubagents, history: footer.history, agent: options.agentLabel, onSubmit: footer.handlePrompt, diff --git a/packages/cli/src/mini/footer.view.tsx b/packages/cli/src/mini/footer.view.tsx index cabb4ee1c8..a7824dddcf 100644 --- a/packages/cli/src/mini/footer.view.tsx +++ b/packages/cli/src/mini/footer.view.tsx @@ -89,7 +89,6 @@ type RunFooterViewProps = { theme: () => RunTheme diffStyle?: RunDiffStyle tuiConfig: RunTuiConfig - backgroundSubagents: boolean history?: () => RunPrompt[] agent: string onSubmit: (input: RunPrompt) => boolean @@ -169,9 +168,7 @@ export function RunFooterView(props: RunFooterViewProps) { return tabs().findIndex((item) => item.sessionID === sessionID) + 1 }) - const foregroundSubagents = createMemo( - () => props.backgroundSubagents && activeTabs().some((item) => !item.background), - ) + const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background)) const model = createMemo(() => { const current = props.currentModel() return current ? modelInfo(props.providers(), current) : { model: props.state().model, provider: undefined } diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index 9259bd5dbe..8a47529816 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -1,6 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" -import { truthy } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Effect } from "effect" import path from "node:path" @@ -84,9 +83,6 @@ export async function runMini(input: MiniCommandInput) { files: [], initialInput, thinking: true, - backgroundSubagents: - truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") || - (process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")), replay: input.replay ?? true, replayLimit: input.replayLimit, demo: input.demo, diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index 8549b36d37..bc356eb743 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -281,7 +281,6 @@ export async function runNonInteractivePrompt(input: Input) { metadata: { structured: event.data.structured, content: event.data.content, - outputPaths: event.data.outputPaths, result: event.data.result, providerCall: current.provider, providerResult: { executed: event.data.executed, state: event.data.resultState }, diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index d8e3dcd228..a6aedbb97d 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -89,12 +89,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr !explicitModel && !sessionModel ? await client.model .default({ location: { directory: cwd, workspace } }) - .then((result) => - result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined, - ) + .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) : undefined const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel) - if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) + if (input.variant && !model) + return reportError(input, "Cannot select a variant before selecting a model", session?.id) if (model) { await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) const available = await client.model.list({ location: { directory: cwd, workspace } }) @@ -112,9 +111,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr if (!session && input.title !== undefined) { await client.session.rename({ sessionID: selected.id, - title: - input.title || - prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""), + title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""), }) } @@ -200,7 +197,7 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`) return } - const agent = agents.find((item) => item.name === name) + const agent = agents.find((item) => item.id === name) if (!agent) { warning(`agent "${name}" not found. Falling back to default agent`) return diff --git a/packages/cli/src/mini/runtime.lifecycle.ts b/packages/cli/src/mini/runtime.lifecycle.ts index 4700f7c479..e25c29f02e 100644 --- a/packages/cli/src/mini/runtime.lifecycle.ts +++ b/packages/cli/src/mini/runtime.lifecycle.ts @@ -64,7 +64,6 @@ export type LifecycleInput = { model: RunInput["model"] variant: string | undefined tuiConfig: RunTuiConfig | Promise - backgroundSubagents: boolean onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise onQuestionReject: (input: QuestionReject) => void | Promise @@ -236,7 +235,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { if (state.demo?.permission(next)) { return @@ -876,7 +873,6 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: files: input.files, initialInput: input.initialInput, thinking: input.thinking, - backgroundSubagents: input.backgroundSubagents, replay: input.replay, replayLimit: input.replayLimit, demo: input.demo, @@ -928,7 +924,6 @@ export async function runInteractiveMode( files: input.files, initialInput: input.initialInput, thinking: input.thinking, - backgroundSubagents: input.backgroundSubagents, replay: input.replay, replayLimit: input.replayLimit, demo: input.demo, diff --git a/packages/cli/src/mini/stream-v2.subagent.ts b/packages/cli/src/mini/stream-v2.subagent.ts index 4aae8e1278..091ff296ee 100644 --- a/packages/cli/src/mini/stream-v2.subagent.ts +++ b/packages/cli/src/mini/stream-v2.subagent.ts @@ -16,7 +16,7 @@ // backgrounding is intentionally absent: subagent jobs block the parent // session, so only whole-session `v2.session.background(parentID)` exists. import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" -import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2" import { Locale } from "@opencode-ai/tui/util/locale" import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" @@ -54,7 +54,7 @@ export function legacyTool(input: { callID: tool.id, tool: tool.name, } - if (tool.state.status === "pending") { + if (tool.state.status === "streaming") { return { ...base, state: { status: "pending", input: {}, raw: tool.state.input }, @@ -83,7 +83,6 @@ export function legacyTool(input: { metadata: { structured: tool.state.structured, content: tool.state.content, - outputPaths: tool.state.outputPaths, result: tool.state.result, providerCall, providerResult, @@ -179,7 +178,7 @@ export type SubagentTrackerInput = { export type SubagentTracker = { main(event: V2Event): void foreign(sessionID: string, event: V2Event): void - hydrate(next: { messages: SessionMessage[]; active: Record }): Promise + hydrate(next: { messages: SessionMessageInfo[]; active: Record }): Promise select(sessionID: string | undefined): void snapshot(): FooterSubagentState } @@ -316,7 +315,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac messageID, tool: item, }) - if (item.state.status === "pending") return + if (item.state.status === "streaming") return child.callIDs.add(item.id) if (item.state.status === "running") { setFrame(child, `tool:${item.id}`, toolCommit(part, "start")) @@ -327,7 +326,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac setFrame(child, `tool:${item.id}`, toolCommit(part, "final")) } - const rebuild = (child: ChildState, messages: SessionMessage[]) => { + const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => { child.frames = [] child.text.clear() child.projectedText.clear() @@ -412,7 +411,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac for (const [id, prompt] of pendingPrompts) { if (!child.prompts.has(id)) child.prompts.set(id, prompt) } - rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[]) + rebuild(child, structuredClone(response.data).toReversed() as SessionMessageInfo[]) for (const [id, tool] of pendingTools) { if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool) } @@ -622,7 +621,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac input: current?.input ?? {}, structured: event.data.structured, content: event.data.content, - outputPaths: event.data.outputPaths, result: event.data.result, }, time: { diff --git a/packages/cli/src/mini/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts index a82fea2e58..ae854ef2e3 100644 --- a/packages/cli/src/mini/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -2,7 +2,7 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p import type { PermissionRequest, QuestionRequest, - SessionMessage, + SessionMessageInfo, SessionMessageAssistantTool, } from "@opencode-ai/sdk/v2" import { Event } from "@opencode-ai/schema/event" @@ -389,7 +389,7 @@ export async function createSessionTransport(input: StreamInput): Promise { + const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => { if (message.type === "user") { const waiting = state.wait?.messageID === message.id if (waiting && state.wait) state.wait.promoted = true @@ -438,34 +438,34 @@ export async function createSessionTransport(input: StreamInput): Promise process.chdir(Global.Path.home)) return yield* Effect.scoped( Effect.gen(function* () { - if (options.mode === "service") { - const service = yield* ServiceConfig.options() - yield* Flock.effect(path.basename(service.file, ".json") + "-process", { - dir: path.dirname(service.file), - staleMs: 3_000, - timeoutMs: 15_000, - }) - } const environmentPassword = yield* Env.password // Keep the lease credential out of the environment inherited by tools. if (options.mode === "stdio") { diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 6a83d52b99..70f1dcc587 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -3,15 +3,14 @@ import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from import { ClientApi, effectOmitEndpoints, - endpointNames, groupNames, promiseOmitEndpoints, } from "@opencode-ai/protocol/client" import { Effect } from "effect" import { fileURLToPath } from "url" -const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) -const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints }) +const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints }) +const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints }) await Effect.runPromise( Effect.all( diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 319a9f72e2..8fd9994f5c 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -1,7 +1,6 @@ export { ClientApi, effectOmitEndpoints, - endpointNames, groupNames, promiseOmitEndpoints, } from "@opencode-ai/protocol/client" diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index cd9ced2979..bc1f183627 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -111,157 +111,167 @@ export type Endpoint4_8Input = { export type Endpoint4_8Output = EffectValue> export type SessionRenameOperation = (input: Endpoint4_8Input) => Effect.Effect -type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Request = Parameters[0] export type Endpoint4_9Input = { readonly sessionID: Endpoint4_9Request["params"]["sessionID"] - readonly id?: Endpoint4_9Request["payload"]["id"] - readonly prompt: Endpoint4_9Request["payload"]["prompt"] - readonly delivery?: Endpoint4_9Request["payload"]["delivery"] - readonly resume?: Endpoint4_9Request["payload"]["resume"] + readonly destination: Endpoint4_9Request["payload"]["destination"] + readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"] } -export type Endpoint4_9Output = EffectValue>["data"] -export type SessionPromptOperation = (input: Endpoint4_9Input) => Effect.Effect +export type Endpoint4_9Output = EffectValue> +export type SessionMoveOperation = (input: Endpoint4_9Input) => Effect.Effect -type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Request = Parameters[0] export type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] readonly id?: Endpoint4_10Request["payload"]["id"] - readonly command: Endpoint4_10Request["payload"]["command"] - readonly arguments?: Endpoint4_10Request["payload"]["arguments"] - readonly agent?: Endpoint4_10Request["payload"]["agent"] - readonly model?: Endpoint4_10Request["payload"]["model"] - readonly files?: Endpoint4_10Request["payload"]["files"] - readonly agents?: Endpoint4_10Request["payload"]["agents"] + readonly prompt: Endpoint4_10Request["payload"]["prompt"] readonly delivery?: Endpoint4_10Request["payload"]["delivery"] readonly resume?: Endpoint4_10Request["payload"]["resume"] } -export type Endpoint4_10Output = EffectValue>["data"] -export type SessionCommandOperation = (input: Endpoint4_10Input) => Effect.Effect +export type Endpoint4_10Output = EffectValue>["data"] +export type SessionPromptOperation = (input: Endpoint4_10Input) => Effect.Effect -type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Request = Parameters[0] export type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] readonly id?: Endpoint4_11Request["payload"]["id"] - readonly skill: Endpoint4_11Request["payload"]["skill"] + readonly command: Endpoint4_11Request["payload"]["command"] + readonly arguments?: Endpoint4_11Request["payload"]["arguments"] + readonly agent?: Endpoint4_11Request["payload"]["agent"] + readonly model?: Endpoint4_11Request["payload"]["model"] + readonly files?: Endpoint4_11Request["payload"]["files"] + readonly agents?: Endpoint4_11Request["payload"]["agents"] + readonly delivery?: Endpoint4_11Request["payload"]["delivery"] readonly resume?: Endpoint4_11Request["payload"]["resume"] } -export type Endpoint4_11Output = EffectValue> -export type SessionSkillOperation = (input: Endpoint4_11Input) => Effect.Effect +export type Endpoint4_11Output = EffectValue>["data"] +export type SessionCommandOperation = (input: Endpoint4_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Request = Parameters[0] export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] - readonly text: Endpoint4_12Request["payload"]["text"] - readonly description?: Endpoint4_12Request["payload"]["description"] - readonly metadata?: Endpoint4_12Request["payload"]["metadata"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly skill: Endpoint4_12Request["payload"]["skill"] + readonly resume?: Endpoint4_12Request["payload"]["resume"] } -export type Endpoint4_12Output = EffectValue> -export type SessionSyntheticOperation = (input: Endpoint4_12Input) => Effect.Effect +export type Endpoint4_12Output = EffectValue> +export type SessionSkillOperation = (input: Endpoint4_12Input) => Effect.Effect -type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Request = Parameters[0] export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly id?: Endpoint4_13Request["payload"]["id"] - readonly command: Endpoint4_13Request["payload"]["command"] + readonly text: Endpoint4_13Request["payload"]["text"] + readonly description?: Endpoint4_13Request["payload"]["description"] + readonly metadata?: Endpoint4_13Request["payload"]["metadata"] + readonly resume?: Endpoint4_13Request["payload"]["resume"] } -export type Endpoint4_13Output = EffectValue> -export type SessionShellOperation = (input: Endpoint4_13Input) => Effect.Effect +export type Endpoint4_13Output = EffectValue> +export type SessionSyntheticOperation = (input: Endpoint4_13Input) => Effect.Effect -type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Request = Parameters[0] export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] readonly id?: Endpoint4_14Request["payload"]["id"] + readonly command: Endpoint4_14Request["payload"]["command"] } -export type Endpoint4_14Output = EffectValue>["data"] -export type SessionCompactOperation = (input: Endpoint4_14Input) => Effect.Effect +export type Endpoint4_14Output = EffectValue> +export type SessionShellOperation = (input: Endpoint4_14Input) => Effect.Effect -type Endpoint4_15Request = Parameters[0] -export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -export type Endpoint4_15Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_15Input) => Effect.Effect - -type Endpoint4_16Request = Parameters[0] -export type Endpoint4_16Input = { - readonly sessionID: Endpoint4_16Request["params"]["sessionID"] - readonly messageID: Endpoint4_16Request["payload"]["messageID"] - readonly files?: Endpoint4_16Request["payload"]["files"] +type Endpoint4_15Request = Parameters[0] +export type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly id?: Endpoint4_15Request["payload"]["id"] } -export type Endpoint4_16Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_16Input) => Effect.Effect +export type Endpoint4_15Output = EffectValue>["data"] +export type SessionCompactOperation = (input: Endpoint4_15Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] -export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -export type Endpoint4_17Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_17Input) => Effect.Effect +type Endpoint4_16Request = Parameters[0] +export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } +export type Endpoint4_16Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint4_16Input) => Effect.Effect -type Endpoint4_18Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] +export type Endpoint4_17Input = { + readonly sessionID: Endpoint4_17Request["params"]["sessionID"] + readonly messageID: Endpoint4_17Request["payload"]["messageID"] + readonly files?: Endpoint4_17Request["payload"]["files"] +} +export type Endpoint4_17Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint4_17Input) => Effect.Effect + +type Endpoint4_18Request = Parameters[0] export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -export type Endpoint4_18Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_18Input) => Effect.Effect +export type Endpoint4_18Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint4_18Input) => Effect.Effect -type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Request = Parameters[0] export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } -export type Endpoint4_19Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_19Input) => Effect.Effect +export type Endpoint4_19Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint4_19Input) => Effect.Effect -type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Request = Parameters[0] export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } -export type Endpoint4_20Output = EffectValue< +export type Endpoint4_20Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint4_20Input) => Effect.Effect + +type Endpoint4_21Request = Parameters[0] +export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] } +export type Endpoint4_21Output = EffectValue< ReturnType >["data"] export type SessionInstructionsEntryListOperation = ( - input: Endpoint4_20Input, -) => Effect.Effect - -type Endpoint4_21Request = Parameters[0] -export type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly key: Endpoint4_21Request["params"]["key"] - readonly value: Endpoint4_21Request["payload"]["value"] -} -export type Endpoint4_21Output = EffectValue> -export type SessionInstructionsEntryPutOperation = ( input: Endpoint4_21Input, ) => Effect.Effect -type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Request = Parameters[0] export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] readonly key: Endpoint4_22Request["params"]["key"] + readonly value: Endpoint4_22Request["payload"]["value"] } -export type Endpoint4_22Output = EffectValue< - ReturnType -> -export type SessionInstructionsEntryRemoveOperation = ( +export type Endpoint4_22Output = EffectValue> +export type SessionInstructionsEntryPutOperation = ( input: Endpoint4_22Input, ) => Effect.Effect -type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Request = Parameters[0] export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] - readonly after?: Endpoint4_23Request["query"]["after"] - readonly follow?: Endpoint4_23Request["query"]["follow"] + readonly key: Endpoint4_23Request["params"]["key"] } -export type Endpoint4_23Output = StreamValue>> -export type SessionLogOperation = (input: Endpoint4_23Input) => Stream.Stream +export type Endpoint4_23Output = EffectValue< + ReturnType +> +export type SessionInstructionsEntryRemoveOperation = ( + input: Endpoint4_23Input, +) => Effect.Effect -type Endpoint4_24Request = Parameters[0] -export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } -export type Endpoint4_24Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_24Input) => Effect.Effect +type Endpoint4_24Request = Parameters[0] +export type Endpoint4_24Input = { + readonly sessionID: Endpoint4_24Request["params"]["sessionID"] + readonly after?: Endpoint4_24Request["query"]["after"] + readonly follow?: Endpoint4_24Request["query"]["follow"] +} +export type Endpoint4_24Output = StreamValue>> +export type SessionLogOperation = (input: Endpoint4_24Input) => Stream.Stream -type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Request = Parameters[0] export type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } -export type Endpoint4_25Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_25Input) => Effect.Effect +export type Endpoint4_25Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint4_25Input) => Effect.Effect -type Endpoint4_26Request = Parameters[0] -export type Endpoint4_26Input = { - readonly sessionID: Endpoint4_26Request["params"]["sessionID"] - readonly messageID: Endpoint4_26Request["params"]["messageID"] +type Endpoint4_26Request = Parameters[0] +export type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] } +export type Endpoint4_26Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_26Input) => Effect.Effect + +type Endpoint4_27Request = Parameters[0] +export type Endpoint4_27Input = { + readonly sessionID: Endpoint4_27Request["params"]["sessionID"] + readonly messageID: Endpoint4_27Request["params"]["messageID"] } -export type Endpoint4_26Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_26Input) => Effect.Effect +export type Endpoint4_27Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_27Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -273,6 +283,7 @@ export interface SessionApi { readonly switchAgent: SessionSwitchAgentOperation readonly switchModel: SessionSwitchModelOperation readonly rename: SessionRenameOperation + readonly move: SessionMoveOperation readonly prompt: SessionPromptOperation readonly command: SessionCommandOperation readonly skill: SessionSkillOperation @@ -280,9 +291,11 @@ export interface SessionApi { readonly shell: SessionShellOperation readonly compact: SessionCompactOperation readonly wait: SessionWaitOperation - readonly revertStage: SessionRevertStageOperation - readonly revertClear: SessionRevertClearOperation - readonly revertCommit: SessionRevertCommitOperation + readonly revert: { + readonly stage: SessionRevertStageOperation + readonly clear: SessionRevertClearOperation + readonly commit: SessionRevertCommitOperation + } readonly context: SessionContextOperation readonly instructions: { readonly entry: { @@ -427,11 +440,15 @@ export type IntegrationAttemptCancelOperation = ( export interface IntegrationApi { readonly list: IntegrationListOperation readonly get: IntegrationGetOperation - readonly connectKey: IntegrationConnectKeyOperation - readonly connectOauth: IntegrationConnectOauthOperation - readonly attemptStatus: IntegrationAttemptStatusOperation - readonly attemptComplete: IntegrationAttemptCompleteOperation - readonly attemptCancel: IntegrationAttemptCancelOperation + readonly connect: { + readonly key: IntegrationConnectKeyOperation + readonly oauth: IntegrationConnectOauthOperation + } + readonly attempt: { + readonly status: IntegrationAttemptStatusOperation + readonly complete: IntegrationAttemptCompleteOperation + readonly cancel: IntegrationAttemptCancelOperation + } } type Endpoint10_0Request = Parameters[0] @@ -439,8 +456,16 @@ export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query export type Endpoint10_0Output = EffectValue> export type ServerMcpListOperation = (input?: Endpoint10_0Input) => Effect.Effect +type Endpoint10_1Request = Parameters[0] +export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } +export type Endpoint10_1Output = EffectValue> +export type ServerMcpResourceCatalogOperation = ( + input?: Endpoint10_1Input, +) => Effect.Effect + export interface ServerMcpApi { readonly list: ServerMcpListOperation + readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation } } type Endpoint11_0Request = Parameters[0] @@ -490,7 +515,7 @@ export interface ProjectApi { type Endpoint13_0Request = Parameters[0] export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } export type Endpoint13_0Output = EffectValue> -export type FormListRequestsOperation = (input?: Endpoint13_0Input) => Effect.Effect +export type FormRequestListOperation = (input?: Endpoint13_0Input) => Effect.Effect type Endpoint13_1Request = Parameters[0] export type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] } @@ -544,7 +569,7 @@ export type Endpoint13_6Output = EffectValue = (input: Endpoint13_6Input) => Effect.Effect export interface FormApi { - readonly listRequests: FormListRequestsOperation + readonly request: { readonly list: FormRequestListOperation } readonly list: FormListOperation readonly create: FormCreateOperation readonly get: FormGetOperation @@ -556,7 +581,7 @@ export interface FormApi { type Endpoint14_0Request = Parameters[0] export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } export type Endpoint14_0Output = EffectValue> -export type PermissionListRequestsOperation = ( +export type PermissionRequestListOperation = ( input?: Endpoint14_0Input, ) => Effect.Effect @@ -565,14 +590,14 @@ export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["quer export type Endpoint14_1Output = EffectValue< ReturnType >["data"] -export type PermissionListSavedOperation = ( +export type PermissionSavedListOperation = ( input?: Endpoint14_1Input, ) => Effect.Effect type Endpoint14_2Request = Parameters[0] export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] } export type Endpoint14_2Output = EffectValue> -export type PermissionRemoveSavedOperation = ( +export type PermissionSavedRemoveOperation = ( input: Endpoint14_2Input, ) => Effect.Effect @@ -620,9 +645,8 @@ export type Endpoint14_6Output = EffectValue = (input: Endpoint14_6Input) => Effect.Effect export interface PermissionApi { - readonly listRequests: PermissionListRequestsOperation - readonly listSaved: PermissionListSavedOperation - readonly removeSaved: PermissionRemoveSavedOperation + readonly request: { readonly list: PermissionRequestListOperation } + readonly saved: { readonly list: PermissionSavedListOperation; readonly remove: PermissionSavedRemoveOperation } readonly create: PermissionCreateOperation readonly list: PermissionListOperation readonly get: PermissionGetOperation @@ -791,7 +815,7 @@ export interface ShellApi { type Endpoint21_0Request = Parameters[0] export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } export type Endpoint21_0Output = EffectValue> -export type QuestionListRequestsOperation = ( +export type QuestionRequestListOperation = ( input?: Endpoint21_0Input, ) => Effect.Effect @@ -818,7 +842,7 @@ export type Endpoint21_3Output = EffectValue = (input: Endpoint21_3Input) => Effect.Effect export interface QuestionApi { - readonly listRequests: QuestionListRequestsOperation + readonly request: { readonly list: QuestionRequestListOperation } readonly list: QuestionListOperation readonly reply: QuestionReplyOperation readonly reject: QuestionRejectOperation @@ -888,16 +912,21 @@ export interface VcsApi { } export type Endpoint25_0Output = EffectValue> -export type DebugLocationOperation = () => Effect.Effect +export type DebugLocationListOperation = () => Effect.Effect + +type Endpoint25_1Request = Parameters[0] +export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } +export type Endpoint25_1Output = EffectValue> +export type DebugLocationEvictOperation = (input?: Endpoint25_1Input) => Effect.Effect export interface DebugApi { - readonly location: DebugLocationOperation + readonly location: { readonly list: DebugLocationListOperation; readonly evict: DebugLocationEvictOperation } } type Endpoint26_0Request = Parameters[0] export type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] } export type Endpoint26_0Output = EffectValue> -export type SearchProviderOperation = (input?: Endpoint26_0Input) => Effect.Effect +export type SearchProviderGetOperation = (input?: Endpoint26_0Input) => Effect.Effect type Endpoint26_1Request = Parameters[0] export type Endpoint26_1Input = { @@ -905,7 +934,7 @@ export type Endpoint26_1Input = { readonly providerID: Endpoint26_1Request["payload"]["providerID"] } export type Endpoint26_1Output = EffectValue> -export type SearchSelectProviderOperation = ( +export type SearchProviderSelectOperation = ( input: Endpoint26_1Input, ) => Effect.Effect @@ -923,8 +952,7 @@ export type Endpoint26_2Output = EffectValue = (input: Endpoint26_2Input) => Effect.Effect export interface SearchApi { - readonly provider: SearchProviderOperation - readonly selectProvider: SearchSelectProviderOperation + readonly provider: { readonly get: SearchProviderGetOperation; readonly select: SearchProviderSelectOperation } readonly query: SearchQueryOperation } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 4b3856e203..145f67f411 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -141,15 +141,27 @@ const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Inp Effect.mapError(mapClientError), ) -type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Request = Parameters[0] type Endpoint4_9Input = { readonly sessionID: Endpoint4_9Request["params"]["sessionID"] - readonly id?: Endpoint4_9Request["payload"]["id"] - readonly prompt: Endpoint4_9Request["payload"]["prompt"] - readonly delivery?: Endpoint4_9Request["payload"]["delivery"] - readonly resume?: Endpoint4_9Request["payload"]["resume"] + readonly destination: Endpoint4_9Request["payload"]["destination"] + readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"] } const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => + raw["session.move"]({ + params: { sessionID: input["sessionID"] }, + payload: { destination: input["destination"], moveChanges: input["moveChanges"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Input = { + readonly sessionID: Endpoint4_10Request["params"]["sessionID"] + readonly id?: Endpoint4_10Request["payload"]["id"] + readonly prompt: Endpoint4_10Request["payload"]["prompt"] + readonly delivery?: Endpoint4_10Request["payload"]["delivery"] + readonly resume?: Endpoint4_10Request["payload"]["resume"] +} +const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, @@ -158,20 +170,20 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp Effect.map((value) => value.data), ) -type Endpoint4_10Request = Parameters[0] -type Endpoint4_10Input = { - readonly sessionID: Endpoint4_10Request["params"]["sessionID"] - readonly id?: Endpoint4_10Request["payload"]["id"] - readonly command: Endpoint4_10Request["payload"]["command"] - readonly arguments?: Endpoint4_10Request["payload"]["arguments"] - readonly agent?: Endpoint4_10Request["payload"]["agent"] - readonly model?: Endpoint4_10Request["payload"]["model"] - readonly files?: Endpoint4_10Request["payload"]["files"] - readonly agents?: Endpoint4_10Request["payload"]["agents"] - readonly delivery?: Endpoint4_10Request["payload"]["delivery"] - readonly resume?: Endpoint4_10Request["payload"]["resume"] +type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Input = { + readonly sessionID: Endpoint4_11Request["params"]["sessionID"] + readonly id?: Endpoint4_11Request["payload"]["id"] + readonly command: Endpoint4_11Request["payload"]["command"] + readonly arguments?: Endpoint4_11Request["payload"]["arguments"] + readonly agent?: Endpoint4_11Request["payload"]["agent"] + readonly model?: Endpoint4_11Request["payload"]["model"] + readonly files?: Endpoint4_11Request["payload"]["files"] + readonly agents?: Endpoint4_11Request["payload"]["agents"] + readonly delivery?: Endpoint4_11Request["payload"]["delivery"] + readonly resume?: Endpoint4_11Request["payload"]["resume"] } -const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => +const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => raw["session.command"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -190,67 +202,73 @@ const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10I Effect.map((value) => value.data), ) -type Endpoint4_11Request = Parameters[0] -type Endpoint4_11Input = { - readonly sessionID: Endpoint4_11Request["params"]["sessionID"] - readonly id?: Endpoint4_11Request["payload"]["id"] - readonly skill: Endpoint4_11Request["payload"]["skill"] - readonly resume?: Endpoint4_11Request["payload"]["resume"] +type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Input = { + readonly sessionID: Endpoint4_12Request["params"]["sessionID"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly skill: Endpoint4_12Request["payload"]["skill"] + readonly resume?: Endpoint4_12Request["payload"]["resume"] } -const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => +const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => raw["session.skill"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_12Request = Parameters[0] -type Endpoint4_12Input = { - readonly sessionID: Endpoint4_12Request["params"]["sessionID"] - readonly text: Endpoint4_12Request["payload"]["text"] - readonly description?: Endpoint4_12Request["payload"]["description"] - readonly metadata?: Endpoint4_12Request["payload"]["metadata"] -} -const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => - raw["session.synthetic"]({ - params: { sessionID: input["sessionID"] }, - payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, - }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Request = Parameters[0] type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly id?: Endpoint4_13Request["payload"]["id"] - readonly command: Endpoint4_13Request["payload"]["command"] + readonly text: Endpoint4_13Request["payload"]["text"] + readonly description?: Endpoint4_13Request["payload"]["description"] + readonly metadata?: Endpoint4_13Request["payload"]["metadata"] + readonly resume?: Endpoint4_13Request["payload"]["resume"] } const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => + raw["session.synthetic"]({ + params: { sessionID: input["sessionID"] }, + payload: { + text: input["text"], + description: input["description"], + metadata: input["metadata"], + resume: input["resume"], + }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Input = { + readonly sessionID: Endpoint4_14Request["params"]["sessionID"] + readonly id?: Endpoint4_14Request["payload"]["id"] + readonly command: Endpoint4_14Request["payload"]["command"] +} +const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => raw["session.shell"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], command: input["command"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_14Request = Parameters[0] -type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly id?: Endpoint4_14Request["payload"]["id"] +type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly id?: Endpoint4_15Request["payload"]["id"] } -const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => +const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_15Request = Parameters[0] -type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => +type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } +const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_16Request = Parameters[0] -type Endpoint4_16Input = { - readonly sessionID: Endpoint4_16Request["params"]["sessionID"] - readonly messageID: Endpoint4_16Request["payload"]["messageID"] - readonly files?: Endpoint4_16Request["payload"]["files"] +type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Input = { + readonly sessionID: Endpoint4_17Request["params"]["sessionID"] + readonly messageID: Endpoint4_17Request["payload"]["messageID"] + readonly files?: Endpoint4_17Request["payload"]["files"] } -const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => +const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -259,61 +277,61 @@ const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16I Effect.map((value) => value.data), ) -type Endpoint4_17Request = Parameters[0] -type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Request = Parameters[0] type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Request = Parameters[0] type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] } +const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_21Request = Parameters[0] -type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly key: Endpoint4_21Request["params"]["key"] - readonly value: Endpoint4_21Request["payload"]["value"] +type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly key: Endpoint4_22Request["params"]["key"] + readonly value: Endpoint4_22Request["payload"]["value"] } -const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => +const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => raw["session.instructions.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_22Request = Parameters[0] -type Endpoint4_22Input = { - readonly sessionID: Endpoint4_22Request["params"]["sessionID"] - readonly key: Endpoint4_22Request["params"]["key"] +type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Input = { + readonly sessionID: Endpoint4_23Request["params"]["sessionID"] + readonly key: Endpoint4_23Request["params"]["key"] } -const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => +const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_23Request = Parameters[0] -type Endpoint4_23Input = { - readonly sessionID: Endpoint4_23Request["params"]["sessionID"] - readonly after?: Endpoint4_23Request["query"]["after"] - readonly follow?: Endpoint4_23Request["query"]["follow"] +type Endpoint4_24Request = Parameters[0] +type Endpoint4_24Input = { + readonly sessionID: Endpoint4_24Request["params"]["sessionID"] + readonly after?: Endpoint4_24Request["query"]["after"] + readonly follow?: Endpoint4_24Request["query"]["follow"] } -const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => +const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -324,22 +342,22 @@ const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23I ), ) -type Endpoint4_24Request = Parameters[0] -type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } -const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => - raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Request = Parameters[0] type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_26Request = Parameters[0] +type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] } +const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_26Request = Parameters[0] -type Endpoint4_26Input = { - readonly sessionID: Endpoint4_26Request["params"]["sessionID"] - readonly messageID: Endpoint4_26Request["params"]["messageID"] +type Endpoint4_27Request = Parameters[0] +type Endpoint4_27Input = { + readonly sessionID: Endpoint4_27Request["params"]["sessionID"] + readonly messageID: Endpoint4_27Request["params"]["messageID"] } -const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) => +const Endpoint4_27 = (raw: RawClient["server.session"]) => (input: Endpoint4_27Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -355,22 +373,21 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ switchAgent: Endpoint4_6(raw), switchModel: Endpoint4_7(raw), rename: Endpoint4_8(raw), - prompt: Endpoint4_9(raw), - command: Endpoint4_10(raw), - skill: Endpoint4_11(raw), - synthetic: Endpoint4_12(raw), - shell: Endpoint4_13(raw), - compact: Endpoint4_14(raw), - wait: Endpoint4_15(raw), - revertStage: Endpoint4_16(raw), - revertClear: Endpoint4_17(raw), - revertCommit: Endpoint4_18(raw), - context: Endpoint4_19(raw), - instructions: { entry: { list: Endpoint4_20(raw), put: Endpoint4_21(raw), remove: Endpoint4_22(raw) } }, - log: Endpoint4_23(raw), - interrupt: Endpoint4_24(raw), - background: Endpoint4_25(raw), - message: Endpoint4_26(raw), + move: Endpoint4_9(raw), + prompt: Endpoint4_10(raw), + command: Endpoint4_11(raw), + skill: Endpoint4_12(raw), + synthetic: Endpoint4_13(raw), + shell: Endpoint4_14(raw), + compact: Endpoint4_15(raw), + wait: Endpoint4_16(raw), + revert: { stage: Endpoint4_17(raw), clear: Endpoint4_18(raw), commit: Endpoint4_19(raw) }, + context: Endpoint4_20(raw), + instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } }, + log: Endpoint4_24(raw), + interrupt: Endpoint4_25(raw), + background: Endpoint4_26(raw), + message: Endpoint4_27(raw), }) type Endpoint5_0Request = Parameters[0] @@ -517,11 +534,8 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_ const adaptGroup9 = (raw: RawClient["server.integration"]) => ({ list: Endpoint9_0(raw), get: Endpoint9_1(raw), - connectKey: Endpoint9_2(raw), - connectOauth: Endpoint9_3(raw), - attemptStatus: Endpoint9_4(raw), - attemptComplete: Endpoint9_5(raw), - attemptCancel: Endpoint9_6(raw), + connect: { key: Endpoint9_2(raw), oauth: Endpoint9_3(raw) }, + attempt: { status: Endpoint9_4(raw), complete: Endpoint9_5(raw), cancel: Endpoint9_6(raw) }, }) type Endpoint10_0Request = Parameters[0] @@ -529,7 +543,15 @@ type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["loc const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) => raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw) }) +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } +const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) => + raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ + list: Endpoint10_0(raw), + resource: { catalog: Endpoint10_1(raw) }, +}) type Endpoint11_0Request = Parameters[0] type Endpoint11_0Input = { @@ -666,7 +688,7 @@ const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Inpu ) const adaptGroup13 = (raw: RawClient["server.form"]) => ({ - listRequests: Endpoint13_0(raw), + request: { list: Endpoint13_0(raw) }, list: Endpoint13_1(raw), create: Endpoint13_2(raw), get: Endpoint13_3(raw), @@ -754,9 +776,8 @@ const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14 }).pipe(Effect.mapError(mapClientError)) const adaptGroup14 = (raw: RawClient["server.permission"]) => ({ - listRequests: Endpoint14_0(raw), - listSaved: Endpoint14_1(raw), - removeSaved: Endpoint14_2(raw), + request: { list: Endpoint14_0(raw) }, + saved: { list: Endpoint14_1(raw), remove: Endpoint14_2(raw) }, create: Endpoint14_3(raw), list: Endpoint14_4(raw), get: Endpoint14_5(raw), @@ -989,7 +1010,7 @@ const Endpoint21_3 = (raw: RawClient["server.question"]) => (input: Endpoint21_3 ) const adaptGroup21 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint21_0(raw), + request: { list: Endpoint21_0(raw) }, list: Endpoint21_1(raw), reply: Endpoint21_2(raw), reject: Endpoint21_3(raw), @@ -1069,7 +1090,14 @@ const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(r const Endpoint25_0 = (raw: RawClient["server.debug"]) => () => raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)) -const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) }) +type Endpoint25_1Request = Parameters[0] +type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } +const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) => + raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ + location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) }, +}) type Endpoint26_0Request = Parameters[0] type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] } @@ -1111,8 +1139,7 @@ const Endpoint26_2 = (raw: RawClient["server.search"]) => (input: Endpoint26_2In }).pipe(Effect.mapError(mapClientError)) const adaptGroup26 = (raw: RawClient["server.search"]) => ({ - provider: Endpoint26_0(raw), - selectProvider: Endpoint26_1(raw), + provider: { get: Endpoint26_0(raw), select: Endpoint26_1(raw) }, query: Endpoint26_2(raw), }) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 8a75679779..28433aeec3 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -23,6 +23,8 @@ import type { SessionSwitchModelOutput, SessionRenameInput, SessionRenameOutput, + SessionMoveInput, + SessionMoveOutput, SessionPromptInput, SessionPromptOutput, SessionCommandInput, @@ -87,6 +89,8 @@ import type { IntegrationAttemptCancelOutput, ServerMcpListInput, ServerMcpListOutput, + ServerMcpResourceCatalogInput, + ServerMcpResourceCatalogOutput, CredentialUpdateInput, CredentialUpdateOutput, CredentialRemoveInput, @@ -96,8 +100,8 @@ import type { ProjectCurrentOutput, ProjectDirectoriesInput, ProjectDirectoriesOutput, - FormListRequestsInput, - FormListRequestsOutput, + FormRequestListInput, + FormRequestListOutput, FormListInput, FormListOutput, FormCreateInput, @@ -110,12 +114,12 @@ import type { FormReplyOutput, FormCancelInput, FormCancelOutput, - PermissionListRequestsInput, - PermissionListRequestsOutput, - PermissionListSavedInput, - PermissionListSavedOutput, - PermissionRemoveSavedInput, - PermissionRemoveSavedOutput, + PermissionRequestListInput, + PermissionRequestListOutput, + PermissionSavedListInput, + PermissionSavedListOutput, + PermissionSavedRemoveInput, + PermissionSavedRemoveOutput, PermissionCreateInput, PermissionCreateOutput, PermissionListInput, @@ -157,8 +161,8 @@ import type { ShellOutputOutput, ShellRemoveInput, ShellRemoveOutput, - QuestionListRequestsInput, - QuestionListRequestsOutput, + QuestionRequestListInput, + QuestionRequestListOutput, QuestionListInput, QuestionListOutput, QuestionReplyInput, @@ -177,11 +181,13 @@ import type { VcsStatusOutput, VcsDiffInput, VcsDiffOutput, - DebugLocationOutput, - SearchProviderInput, - SearchProviderOutput, - SearchSelectProviderInput, - SearchSelectProviderOutput, + DebugLocationListOutput, + DebugLocationEvictInput, + DebugLocationEvictOutput, + SearchProviderGetInput, + SearchProviderGetOutput, + SearchProviderSelectInput, + SearchProviderSelectOutput, SearchQueryInput, SearchQueryOutput, } from "./types" @@ -491,6 +497,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + move: (input: SessionMoveInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/move`, + body: { destination: input["destination"], moveChanges: input["moveChanges"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), prompt: (input: SessionPromptInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionPromptOutput }>( { @@ -542,7 +560,12 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`, - body: { text: input["text"], description: input["description"], metadata: input["metadata"] }, + body: { + text: input["text"], + description: input["description"], + metadata: input["metadata"], + resume: input["resume"], + }, successStatus: 204, declaredStatuses: [404, 400, 401], empty: true, @@ -584,40 +607,42 @@ export function make(options: ClientOptions) { }, requestOptions, ), - revertStage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionRevertStageOutput }>( - { - method: "POST", - path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, - body: { messageID: input["messageID"], files: input["files"] }, - successStatus: 200, - declaredStatuses: [404, 409, 500, 400, 401], - empty: false, - }, - requestOptions, - ).then((value) => value.data), - revertClear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, - successStatus: 204, - declaredStatuses: [404, 409, 500, 400, 401], - empty: true, - }, - requestOptions, - ), - revertCommit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, - successStatus: 204, - declaredStatuses: [404, 409, 400, 401], - empty: true, - }, - requestOptions, - ), + revert: { + stage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionRevertStageOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, + body: { messageID: input["messageID"], files: input["files"] }, + successStatus: 200, + declaredStatuses: [404, 409, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + clear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, + successStatus: 204, + declaredStatuses: [404, 409, 500, 400, 401], + empty: true, + }, + requestOptions, + ), + commit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, + successStatus: 204, + declaredStatuses: [404, 409, 400, 401], + empty: true, + }, + requestOptions, + ), + }, context: (input: SessionContextInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionContextOutput }>( { @@ -819,69 +844,73 @@ export function make(options: ClientOptions) { }, requestOptions, ), - connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, - query: { location: input["location"] }, - body: { key: input["key"], label: input["label"] }, - successStatus: 204, - declaredStatuses: [400, 401], - empty: true, - }, - requestOptions, - ), - connectOauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, - query: { location: input["location"] }, - body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, - successStatus: 200, - declaredStatuses: [400, 401], - empty: false, - }, - requestOptions, - ), - attemptStatus: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, - query: { location: input["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), - attemptComplete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, - query: { location: input["location"] }, - body: { code: input["code"] }, - successStatus: 204, - declaredStatuses: [400, 401], - empty: true, - }, - requestOptions, - ), - attemptCancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => - request( - { - method: "DELETE", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, - query: { location: input["location"] }, - successStatus: 204, - declaredStatuses: [401, 400], - empty: true, - }, - requestOptions, - ), + connect: { + key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, + query: { location: input["location"] }, + body: { key: input["key"], label: input["label"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, + query: { location: input["location"] }, + body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + }, + attempt: { + status: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + complete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, + query: { location: input["location"] }, + body: { code: input["code"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + cancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, }, "server.mcp": { list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) => @@ -896,6 +925,20 @@ export function make(options: ClientOptions) { }, requestOptions, ), + resource: { + catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/mcp/resource`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, }, credential: { update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => @@ -956,18 +999,20 @@ export function make(options: ClientOptions) { ), }, form: { - listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/form/request`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), + request: { + list: (input?: FormRequestListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/form/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, list: (input: FormListInput, requestOptions?: RequestOptions) => request<{ readonly data: FormListOutput }>( { @@ -1045,41 +1090,45 @@ export function make(options: ClientOptions) { ), }, permission: { - listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/permission/request`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), - listSaved: (input?: PermissionListSavedInput, requestOptions?: RequestOptions) => - request<{ readonly data: PermissionListSavedOutput }>( - { - method: "GET", - path: `/api/permission/saved`, - query: { projectID: input?.["projectID"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ).then((value) => value.data), - removeSaved: (input: PermissionRemoveSavedInput, requestOptions?: RequestOptions) => - request( - { - method: "DELETE", - path: `/api/permission/saved/${encodeURIComponent(input.id)}`, - successStatus: 204, - declaredStatuses: [401, 400], - empty: true, - }, - requestOptions, - ), + request: { + list: (input?: PermissionRequestListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/permission/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + saved: { + list: (input?: PermissionSavedListInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionSavedListOutput }>( + { + method: "GET", + path: `/api/permission/saved`, + query: { projectID: input?.["projectID"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + remove: (input: PermissionSavedRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/permission/saved/${encodeURIComponent(input.id)}`, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, create: (input: PermissionCreateInput, requestOptions?: RequestOptions) => request<{ readonly data: PermissionCreateOutput }>( { @@ -1361,18 +1410,20 @@ export function make(options: ClientOptions) { ), }, question: { - listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/question/request`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), + request: { + list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/question/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, list: (input: QuestionListInput, requestOptions?: RequestOptions) => request<{ readonly data: QuestionListOutput }>( { @@ -1489,44 +1540,60 @@ export function make(options: ClientOptions) { ), }, debug: { - location: (requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/debug/location`, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), + location: { + list: (requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/debug/location`, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + evict: (input?: DebugLocationEvictInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/debug/location`, + query: { location: input?.["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, }, search: { - provider: (input?: SearchProviderInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/search/provider`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), - selectProvider: (input: SearchSelectProviderInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/search/provider`, - query: { location: input["location"] }, - body: { providerID: input["providerID"] }, - successStatus: 204, - declaredStatuses: [400, 503, 401], - empty: true, - }, - requestOptions, - ), + provider: { + get: (input?: SearchProviderGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/search/provider`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + select: (input: SearchProviderSelectInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/search/provider`, + query: { location: input["location"] }, + body: { providerID: input["providerID"] }, + successStatus: 204, + declaredStatuses: [400, 503, 401], + empty: true, + }, + requestOptions, + ), + }, query: (input: SearchQueryInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 3e78b520be..836d45cbdb 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -185,6 +185,7 @@ export type AgentListOutput = { } readonly data: ReadonlyArray<{ readonly id: string + readonly name: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly request: { readonly settings: { readonly [x: string]: JsonValue } @@ -345,13 +346,12 @@ export type SessionListOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } }> @@ -408,13 +408,12 @@ export type SessionCreateOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -447,13 +446,12 @@ export type SessionGetOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -491,13 +489,12 @@ export type SessionForkOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -526,6 +523,20 @@ export type SessionRenameInput = { export type SessionRenameOutput = void +export type SessionMoveInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly destination: { + readonly destination: { readonly directory: string } + readonly moveChanges?: boolean | undefined + }["destination"] + readonly moveChanges?: { + readonly destination: { readonly directory: string } + readonly moveChanges?: boolean | undefined + }["moveChanges"] +} + +export type SessionMoveOutput = void + export type SessionPromptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { @@ -856,17 +867,26 @@ export type SessionSyntheticInput = { readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null }["text"] readonly description?: { readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null }["description"] readonly metadata?: { readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null }["metadata"] + readonly resume?: { + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null + }["resume"] } export type SessionSyntheticOutput = void @@ -910,13 +930,12 @@ export type SessionRevertStageOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } }["data"] @@ -971,7 +990,6 @@ export type SessionContextOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } - readonly sessionID: string readonly text: string readonly description?: string readonly type: "synthetic" @@ -988,6 +1006,7 @@ export type SessionContextOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "skill" + readonly skill: string readonly name: string readonly text: string } @@ -996,21 +1015,10 @@ export type SessionContextOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" - readonly shell: { - readonly id: string - readonly status: "running" | "exited" | "timeout" | "killed" - readonly command: string - readonly cwd: string - readonly shell: string - readonly file: string - readonly pid?: number - readonly exit?: number | "Infinity" | "-Infinity" | "NaN" - readonly metadata: { readonly [x: string]: JsonValue } - readonly time: { - readonly started: number | "Infinity" | "-Infinity" | "NaN" - readonly completed?: number | "Infinity" | "-Infinity" | "NaN" - } - } + readonly shellID: string + readonly command: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" readonly output?: { readonly output: string readonly cursor: number @@ -1041,7 +1049,7 @@ export type SessionContextOutput = { readonly providerState?: { readonly [x: string]: JsonValue } readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: - | { readonly status: "pending"; readonly input: string } + | { readonly status: "streaming"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } @@ -1054,19 +1062,10 @@ export type SessionContextOutput = { | { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } - readonly attachments?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly structured: { readonly [x: string]: JsonValue } readonly result?: JsonValue } @@ -1081,12 +1080,7 @@ export type SessionContextOutput = { readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } - readonly time: { - readonly created: number - readonly ran?: number - readonly completed?: number - readonly pruned?: number - } + readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number } } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } @@ -1105,16 +1099,37 @@ export type SessionContextOutput = { readonly error: { readonly type: string; readonly message: string } } } - | { - readonly type: "compaction" - readonly status: "queued" | "running" | "completed" | "failed" - readonly reason: "auto" | "manual" - readonly summary: string - readonly recent: string - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } - } + | ( + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "running" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "failed" + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + } + ) > }["data"] @@ -1152,7 +1167,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.agent.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly agent: string } } @@ -1161,7 +1176,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.model.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1173,7 +1188,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.moved" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1186,7 +1201,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.renamed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly title: string } } @@ -1195,7 +1210,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.deleted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 2 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -1204,7 +1219,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.forked" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } @@ -1213,7 +1228,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.promoted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -1222,7 +1237,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1250,7 +1265,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -1259,7 +1274,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.succeeded" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -1268,7 +1283,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1280,7 +1295,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.interrupted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" } } @@ -1289,7 +1304,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.instructions.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly text: string } } @@ -1298,7 +1313,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.synthetic" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1312,16 +1327,21 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.skill.activated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } + readonly data: { + readonly sessionID: string + readonly id: string + readonly name: string + readonly text: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1344,7 +1364,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1373,7 +1393,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1388,7 +1408,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1410,12 +1430,19 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string readonly error: { readonly type: string; readonly message: string } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } } | { @@ -1423,7 +1450,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number } } @@ -1432,7 +1459,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1446,7 +1473,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1460,7 +1487,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1475,7 +1502,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1489,7 +1516,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1503,7 +1530,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.called" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1519,7 +1546,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.progress" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1537,7 +1564,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.success" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1548,7 +1575,6 @@ export type SessionLogOutput = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly result?: unknown readonly executed: boolean readonly resultState?: { readonly [x: string]: unknown } @@ -1559,7 +1585,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1576,7 +1602,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.retry.scheduled" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1591,7 +1617,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -1600,16 +1626,21 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly recent: string + readonly inputID?: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1623,16 +1654,21 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + readonly inputID?: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.staged" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1640,13 +1676,12 @@ export type SessionLogOutput = readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -1656,7 +1691,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.cleared" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -1665,7 +1700,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.committed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly to: string } } @@ -1725,7 +1760,6 @@ export type SessionMessageOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } - readonly sessionID: string readonly text: string readonly description?: string readonly type: "synthetic" @@ -1742,6 +1776,7 @@ export type SessionMessageOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "skill" + readonly skill: string readonly name: string readonly text: string } @@ -1750,21 +1785,10 @@ export type SessionMessageOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" - readonly shell: { - readonly id: string - readonly status: "running" | "exited" | "timeout" | "killed" - readonly command: string - readonly cwd: string - readonly shell: string - readonly file: string - readonly pid?: number - readonly exit?: number | "Infinity" | "-Infinity" | "NaN" - readonly metadata: { readonly [x: string]: JsonValue } - readonly time: { - readonly started: number | "Infinity" | "-Infinity" | "NaN" - readonly completed?: number | "Infinity" | "-Infinity" | "NaN" - } - } + readonly shellID: string + readonly command: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" readonly output?: { readonly output: string readonly cursor: number @@ -1795,7 +1819,7 @@ export type SessionMessageOutput = { readonly providerState?: { readonly [x: string]: JsonValue } readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: - | { readonly status: "pending"; readonly input: string } + | { readonly status: "streaming"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } @@ -1808,19 +1832,10 @@ export type SessionMessageOutput = { | { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } - readonly attachments?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly structured: { readonly [x: string]: JsonValue } readonly result?: JsonValue } @@ -1835,12 +1850,7 @@ export type SessionMessageOutput = { readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } - readonly time: { - readonly created: number - readonly ran?: number - readonly completed?: number - readonly pruned?: number - } + readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number } } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } @@ -1859,16 +1869,37 @@ export type SessionMessageOutput = { readonly error: { readonly type: string; readonly message: string } } } - | { - readonly type: "compaction" - readonly status: "queued" | "running" | "completed" | "failed" - readonly reason: "auto" | "manual" - readonly summary: string - readonly recent: string - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } - } + | ( + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "running" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "failed" + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + } + ) }["data"] export type MessageListInput = { @@ -1930,7 +1961,6 @@ export type MessageListOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } - readonly sessionID: string readonly text: string readonly description?: string readonly type: "synthetic" @@ -1947,6 +1977,7 @@ export type MessageListOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "skill" + readonly skill: string readonly name: string readonly text: string } @@ -1955,21 +1986,10 @@ export type MessageListOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" - readonly shell: { - readonly id: string - readonly status: "running" | "exited" | "timeout" | "killed" - readonly command: string - readonly cwd: string - readonly shell: string - readonly file: string - readonly pid?: number - readonly exit?: number | "Infinity" | "-Infinity" | "NaN" - readonly metadata: { readonly [x: string]: JsonValue } - readonly time: { - readonly started: number | "Infinity" | "-Infinity" | "NaN" - readonly completed?: number | "Infinity" | "-Infinity" | "NaN" - } - } + readonly shellID: string + readonly command: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" readonly output?: { readonly output: string readonly cursor: number @@ -2000,7 +2020,7 @@ export type MessageListOutput = { readonly providerState?: { readonly [x: string]: JsonValue } readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: - | { readonly status: "pending"; readonly input: string } + | { readonly status: "streaming"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } @@ -2013,19 +2033,10 @@ export type MessageListOutput = { | { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } - readonly attachments?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly structured: { readonly [x: string]: JsonValue } readonly result?: JsonValue } @@ -2040,12 +2051,7 @@ export type MessageListOutput = { readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } - readonly time: { - readonly created: number - readonly ran?: number - readonly completed?: number - readonly pruned?: number - } + readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number } } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } @@ -2064,16 +2070,37 @@ export type MessageListOutput = { readonly error: { readonly type: string; readonly message: string } } } - | { - readonly type: "compaction" - readonly status: "queued" | "running" | "completed" | "failed" - readonly reason: "auto" | "manual" - readonly summary: string - readonly recent: string - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } - } + | ( + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "running" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "failed" + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + } + ) > readonly cursor: { readonly previous?: string | null; readonly next?: string | null } } @@ -2480,6 +2507,36 @@ export type ServerMcpListOutput = { }> } +export type ServerMcpResourceCatalogInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ServerMcpResourceCatalogOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly resources: ReadonlyArray<{ + readonly server: string + readonly name: string + readonly uri: string + readonly description?: string + readonly mimeType?: string + }> + readonly templates: ReadonlyArray<{ + readonly server: string + readonly name: string + readonly uriTemplate: string + readonly description?: string + readonly mimeType?: string + }> + } +} + export type CredentialUpdateInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] readonly location?: { @@ -2527,13 +2584,13 @@ export type ProjectDirectoriesInput = { export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }> -export type FormListRequestsInput = { +export type FormRequestListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type FormListRequestsOutput = { +export type FormRequestListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -3633,13 +3690,13 @@ export type FormCancelInput = { export type FormCancelOutput = void -export type PermissionListRequestsInput = { +export type PermissionRequestListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type PermissionListRequestsOutput = { +export type PermissionRequestListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -3656,9 +3713,9 @@ export type PermissionListRequestsOutput = { }> } -export type PermissionListSavedInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] } +export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] } -export type PermissionListSavedOutput = { +export type PermissionSavedListOutput = { readonly data: ReadonlyArray<{ readonly id: string readonly projectID: string @@ -3667,9 +3724,9 @@ export type PermissionListSavedOutput = { }> }["data"] -export type PermissionRemoveSavedInput = { readonly id: { readonly id: string }["id"] } +export type PermissionSavedRemoveInput = { readonly id: { readonly id: string }["id"] } -export type PermissionRemoveSavedOutput = void +export type PermissionSavedRemoveOutput = void export type PermissionCreateInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -3882,6 +3939,7 @@ export type SkillListOutput = { readonly project: { readonly id: string; readonly directory: string } } readonly data: ReadonlyArray<{ + readonly id: string readonly name: string readonly description?: string readonly slash?: boolean @@ -3937,7 +3995,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.created" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -3999,7 +4057,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4061,7 +4119,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.deleted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4123,7 +4181,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4228,7 +4286,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.removed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly messageID: string } } @@ -4237,7 +4295,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4476,7 +4534,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.removed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly messageID: string; readonly partID: string } } @@ -4485,7 +4543,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.agent.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly agent: string } } @@ -4494,7 +4552,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.model.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4506,7 +4564,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.moved" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4519,16 +4577,33 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.renamed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly title: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.usage.updated" + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + } + } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.deleted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 2 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -4537,7 +4612,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.forked" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } @@ -4546,7 +4621,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.promoted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -4555,7 +4630,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4583,7 +4658,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -4592,7 +4667,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.succeeded" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -4601,7 +4676,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly error: { readonly type: string; readonly message: string } } } @@ -4610,7 +4685,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.interrupted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" } } @@ -4619,7 +4694,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.instructions.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly text: string } } @@ -4628,7 +4703,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.synthetic" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4642,16 +4717,16 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.skill.activated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } + readonly data: { readonly sessionID: string; readonly id: string; readonly name: string; readonly text: string } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4674,7 +4749,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4703,7 +4778,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4718,7 +4793,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4740,12 +4815,19 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string readonly assistantMessageID: string readonly error: { readonly type: string; readonly message: string } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } } | { @@ -4753,7 +4835,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number } } @@ -4775,7 +4857,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4789,7 +4871,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4816,7 +4898,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4831,7 +4913,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4858,7 +4940,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4872,7 +4954,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.called" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4888,7 +4970,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.progress" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4906,7 +4988,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.success" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4917,7 +4999,6 @@ export type EventSubscribeOutput = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly result?: unknown readonly executed: boolean readonly resultState?: { readonly [x: string]: unknown } @@ -4928,7 +5009,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4945,7 +5026,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.retry.scheduled" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4960,7 +5041,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -4969,9 +5050,14 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly recent: string + readonly inputID?: string + } } | { readonly id: string @@ -4986,7 +5072,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5000,16 +5086,21 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + readonly inputID?: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.staged" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5017,13 +5108,12 @@ export type EventSubscribeOutput = readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -5033,7 +5123,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.cleared" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -5042,7 +5132,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.committed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly to: string } } @@ -5550,6 +5640,14 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly server: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "mcp.resources.changed" + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly server: string } + } | { readonly id: string readonly created: number @@ -5999,13 +6097,13 @@ export type ShellRemoveInput = { export type ShellRemoveOutput = void -export type QuestionListRequestsInput = { +export type QuestionRequestListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type QuestionListRequestsOutput = { +export type QuestionRequestListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -6163,23 +6261,31 @@ export type VcsDiffOutput = { readonly project: { readonly id: string; readonly directory: string } } readonly data: ReadonlyArray<{ - readonly file?: string - readonly patch?: string + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly status?: "added" | "deleted" | "modified" + readonly status: "added" | "deleted" | "modified" }> } -export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> +export type DebugLocationListOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> -export type SearchProviderInput = { +export type DebugLocationEvictInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type SearchProviderOutput = { +export type DebugLocationEvictOutput = void + +export type SearchProviderGetInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type SearchProviderGetOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -6188,14 +6294,14 @@ export type SearchProviderOutput = { readonly data: string | null } -export type SearchSelectProviderInput = { +export type SearchProviderSelectInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] readonly providerID: { readonly providerID: string }["providerID"] } -export type SearchSelectProviderOutput = void +export type SearchProviderSelectOutput = void export type SearchQueryInput = { readonly location?: { diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index a2afc9a002..553c120f7a 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -19,7 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message" import { Workspace } from "@opencode-ai/schema/workspace" import { Api } from "@opencode-ai/server/api" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" -import { ClientApi, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract" +import { ClientApi, groupNames, promiseOmitEndpoints } from "../src/contract" const Client = await import("../src/effect") @@ -38,7 +38,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(ProjectV2.Directory).toBe(Project.Directory) expect(ProjectV2.Directories).toBe(Project.Directories) expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) - expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) + expect(CoreSessionMessage.Info).toBe(SessionMessage.Info) expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.project"].identifier).toBe("server.project") expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups)) @@ -49,8 +49,8 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => }) test("client and Server contracts generate identically", () => { - const server = compile(Api, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) - const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) + const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints }) + const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints }) expect(emitPromise(client)).toEqual(emitPromise(server)) }) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 3a7efe570f..72963073b2 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -34,17 +34,13 @@ test("exposes every standard HTTP API group", () => { "search", ]) expect(Object.keys(client.debug)).toEqual(["location"]) + expect(Object.keys(client.debug.location)).toEqual(["list", "evict"]) expect(Object.keys(client.message)).toEqual(["list"]) - expect(Object.keys(client.integration)).toEqual([ - "list", - "get", - "connectKey", - "connectOauth", - "attemptStatus", - "attemptComplete", - "attemptCancel", - ]) - expect(Object.keys(client.search)).toEqual(["provider", "selectProvider", "query"]) + expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"]) + expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"]) + expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"]) + expect(Object.keys(client.search)).toEqual(["provider", "query"]) + expect(Object.keys(client.search.provider)).toEqual(["get", "select"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) @@ -93,8 +89,8 @@ test("search provider methods use the public HTTP contract", async () => { }, }) - expect(await client.search.provider({ location: { directory: "/tmp/project" } })).toMatchObject({ data: "exa" }) - await client.search.selectProvider({ providerID: "parallel", location: { directory: "/tmp/project" } }) + expect(await client.search.provider.get({ location: { directory: "/tmp/project" } })).toMatchObject({ data: "exa" }) + await client.search.provider.select({ providerID: "parallel", location: { directory: "/tmp/project" } }) expect(requests.map((request) => [request.method, request.url])).toEqual([ ["GET", "http://localhost:3000/api/search/provider?location%5Bdirectory%5D=%2Ftmp%2Fproject"], @@ -103,6 +99,29 @@ test("search provider methods use the public HTTP contract", async () => { expect(await requests[1]?.json()).toEqual({ providerID: "parallel" }) }) +test("MCP resource catalog uses the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + request = input instanceof Request ? input : new Request(input) + return Response.json({ + location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } }, + data: { + resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], + templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }], + }, + }) + }, + }) + + const result = await client["server.mcp"].resource.catalog({ location: { directory: "/tmp/project" } }) + + expect(result.data.resources[0]?.uri).toBe("docs://readme") + expect(request?.method).toBe("GET") + expect(request?.url).toBe("http://localhost:3000/api/mcp/resource?location%5Bdirectory%5D=%2Ftmp%2Fproject") +}) + test("file.read returns binary content from the public HTTP contract", async () => { let request: Request | undefined const client = OpenCode.make({ diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 2cc67fe36a..e298ccfc57 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -62,7 +62,7 @@ const result = `result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. -Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well. +Successful result values are JSON-safe data. An explicit `return` produces the program result; when it is omitted, the final executable top-level expression is returned as a model-friendly REPL convenience. Otherwise reaching the end produces `null`. Returned `undefined` and nested `undefined` values are normalized to `null` as well. ## API @@ -241,7 +241,7 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports: - `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets, including assignment-form destructuring such as `for ([key, value] of entries)`), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. - Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. -- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. +- Common array, string, number, `Object`, `Math`, and `JSON` operations, including primitive-number `valueOf`, the standard non-finite `Number` constants, and host-backed `Math.random`. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. - `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index f7f798f2e1..c388cca1bd 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -158,7 +158,7 @@ current omissions to implement, not intentional product boundaries. - [ ] Add `Object.is` after runtime method and tool references have stable identity semantics. - [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set composition methods, and `Array.prototype.toSpliced`. -- [ ] Decide whether nondeterministic `Math.random` and iterable `Math.sumPrecise` belong in the runtime. +- [ ] Decide whether iterable `Math.sumPrecise` belongs in the runtime. - [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter defects are distinguishable without leaking private causes. diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 08d47cd37e..e26538550c 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -30,7 +30,6 @@ export type Binding = { export type StatementResult = | { kind: "none" } - | { kind: "value"; value: unknown } | { kind: "return"; value: unknown } | { kind: "break" } | { kind: "continue" } diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 2825e744b5..7f5d015d18 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -530,17 +530,29 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u if (args[0] instanceof SandboxURLSearchParams) { return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) } - const source = boundedData(args[0], "Array.from input") + const source = args[0] + if (source instanceof SandboxPromise) { + throw new InterpreterRuntimeError( + "Array.from received an un-awaited Promise; await it before creating the array.", + node, + "InvalidDataValue", + ) + } if (typeof source === "string") return Array.from(source) if (Array.isArray(source)) return [...source] if ( source !== null && typeof source === "object" && + (Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) && typeof (source as { length?: unknown }).length === "number" ) { return Array.from(source as ArrayLike) } - throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node) + throw new InterpreterRuntimeError( + "Array.from expects an array, string, Map, Set, or array-like value.", + node, + "InvalidDataValue", + ) } default: throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) @@ -606,7 +618,6 @@ class Interpreter { // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array - private lastValue: unknown // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). private readonly callPermits: Semaphore.Semaphore // Fiber-backed promises whose settlement no program construct has observed yet. Successful @@ -625,7 +636,6 @@ class Interpreter { this.invokeTool = invokeTool this.toolKeys = toolKeys this.logs = logs - this.lastValue = undefined this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) this.pendingSettlements = shared?.pendingSettlements ?? new Set() globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) @@ -671,13 +681,15 @@ class Interpreter { return Effect.gen(function* () { self.hoistFunctions(program.body) let value: unknown = undefined - let returned = false - for (const statement of program.body) { + for (const [index, statement] of program.body.entries()) { + if (index === program.body.length - 1 && statement.type === "ExpressionStatement") { + value = yield* self.evaluateExpression(getNode(statement, "expression")) + break + } const result = yield* self.evaluateStatement(statement) if (result.kind === "return") { value = result.value - returned = true break } @@ -685,11 +697,7 @@ class Interpreter { throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) } - if (result.kind === "value") { - self.lastValue = result.value - } } - if (!returned) value = self.lastValue // The program body runs inside an implicit async function, so a returned promise // resolves before crossing the data boundary - `return tools.ns.tool(...)` works @@ -780,7 +788,7 @@ class Interpreter { private evaluateStatement(node: AstNode): Effect.Effect { switch (node.type) { case "ExpressionStatement": - return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) + return Effect.as(this.evaluateExpression(getNode(node, "expression")), { kind: "none" }) case "VariableDeclaration": return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) case "ReturnStatement": { @@ -833,11 +841,6 @@ class Interpreter { const statement = asNode(statementValue, "body") const result = yield* self.evaluateStatement(statement) - if (result.kind === "value") { - self.lastValue = result.value - continue - } - if (result.kind !== "none") { return result } @@ -929,7 +932,6 @@ class Interpreter { const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) if (result.kind === "break") return { kind: "none" } satisfies StatementResult if (result.kind === "return" || result.kind === "continue") return result - if (result.kind === "value") self.lastValue = result.value } } return { kind: "none" } satisfies StatementResult @@ -957,9 +959,6 @@ class Interpreter { return result } - if (result.kind === "value") { - self.lastValue = result.value - } } return { kind: "none" } satisfies StatementResult @@ -987,9 +986,6 @@ class Interpreter { return result } - if (result.kind === "value") { - self.lastValue = result.value - } } while (yield* self.evaluateExpression(testNode)) return { kind: "none" } satisfies StatementResult @@ -1045,10 +1041,6 @@ class Interpreter { return { kind: "none" } satisfies StatementResult } - if (result.kind === "value") { - self.lastValue = result.value - } - if (iterationScope) { const loopScope = self.currentScope() for (const name of perIterationBindings) { @@ -1133,10 +1125,6 @@ class Interpreter { return { kind: "none" } } - if (result.kind === "value") { - self.lastValue = result.value - } - if (result.kind === "continue") { continue } @@ -1226,10 +1214,6 @@ class Interpreter { return { kind: "none" } } - if (result.kind === "value") { - self.lastValue = result.value - } - if (result.kind === "continue") { continue } @@ -2033,6 +2017,9 @@ class Interpreter { if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) { return invokeGlobalMethod(callable, args, node) } + if (callable.namespace === "Array" && (callable.name === "from" || callable.name === "of")) { + return invokeGlobalMethod(callable, args, node) + } return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) } if (callable instanceof CoercionFunction) { @@ -2372,7 +2359,7 @@ class Interpreter { if (fn.body.type === "BlockStatement") { const result = yield* invocation.evaluateStatement(fn.body) - return result.kind === "return" || result.kind === "value" ? result.value : undefined + return result.kind === "return" ? result.value : undefined } return yield* invocation.evaluateExpression(fn.body) diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index edb7e3d94b..7720f69775 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -1,6 +1,7 @@ export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) export const mathMethods = new Set([ + "random", "max", "min", "abs", @@ -40,6 +41,7 @@ export const mathMethods = new Set([ export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) + if (name === "random") return Math.random() const nums = args.map((arg) => { if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) return arg diff --git a/packages/codemode/src/stdlib/number.ts b/packages/codemode/src/stdlib/number.ts index 79710e1ad2..02dfa953b4 100644 --- a/packages/codemode/src/stdlib/number.ts +++ b/packages/codemode/src/stdlib/number.ts @@ -1,6 +1,15 @@ -export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) +export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString", "valueOf"]) -export const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) +export const numberConstants = new Set([ + "MAX_SAFE_INTEGER", + "MIN_SAFE_INTEGER", + "MAX_VALUE", + "MIN_VALUE", + "EPSILON", + "NaN", + "POSITIVE_INFINITY", + "NEGATIVE_INFINITY", +]) export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) @@ -32,6 +41,9 @@ export const invokeNumberMethod = (value: number, name: string, args: Array, node: Ast if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) const requireObject = (): Record => { const input = args[0] - const value = boundedData(args[0], `Object.${name} input`) if (Array.isArray(input)) return input as unknown as Record - if (isSandboxValue(value)) return {} - if (value === null || typeof value !== "object") { - throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node) + if (isSandboxValue(input)) return {} + if (input instanceof SandboxPromise) { + throw new InterpreterRuntimeError( + `Object.${name} received an un-awaited Promise; await it before inspecting the result.`, + node, + "InvalidDataValue", + ) } - return value as Record + if (input === null || typeof input !== "object") { + throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue") + } + const prototype = Object.getPrototypeOf(input) + if (prototype !== null && prototype !== Object.prototype) { + throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue") + } + return input as Record } const guardedSet = (out: Record, key: string, item: unknown): void => { if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) @@ -28,15 +38,8 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast guardedSet(out, coerceToString(key), item) } switch (name) { - case "keys": { - const value = boundedData(args[0], "Object.keys input") - if (isSandboxValue(value)) return [] - if (Array.isArray(value)) return Object.keys(value) - if (value === null || typeof value !== "object") { - throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) - } - return Object.keys(value) - } + case "keys": + return Object.keys(requireObject()) case "values": return Object.values(requireObject()) case "entries": diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 7d67ba6c79..80ce828414 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -617,6 +617,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "", "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", + "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 221b5e07df..16c17ca0c2 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -658,6 +658,9 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") expect(instructions).not.toContain("host globals") expect(instructions).toContain("Use Code Mode tools for external operations") + expect(instructions).toContain( + "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", + ) expect(instructions).toContain( "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ) @@ -1081,6 +1084,24 @@ describe("CodeMode public contract", () => { expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) }) + test("returns the final top-level expression when return is omitted", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `1; 2` })) + + expect(result).toStrictEqual({ ok: true, value: 2, toolCalls: [] }) + }) + + test("does not implicitly return expressions nested in control flow", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `if (true) { 2 }` })) + + expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] }) + }) + + test("returns null when the final top-level statement is not an expression", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `1; const value = 2` })) + + expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] }) + }) + test("rejects invalid configuration and discovery limits", async () => { expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow( diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index b20a25b25e..c2cd5a1d21 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -9,7 +9,7 @@ "/api/health": { "get": { "tags": [ - "server.health" + "health" ], "operationId": "v2.health.get", "parameters": [], @@ -27,10 +27,23 @@ "enum": [ true ] + }, + "version": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] } }, "required": [ - "healthy" + "healthy", + "version", + "pid" ], "additionalProperties": false } @@ -65,7 +78,7 @@ "/api/location": { "get": { "tags": [ - "server.location" + "location" ], "operationId": "v2.location.get", "parameters": [ @@ -150,7 +163,7 @@ "/api/agent": { "get": { "tags": [ - "server.agent" + "agent" ], "operationId": "v2.agent.list", "parameters": [ @@ -210,7 +223,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/AgentV2.Info" + "$ref": "#/components/schemas/Agent.Info" } } }, @@ -251,7 +264,7 @@ "/api/plugin": { "get": { "tags": [ - "plugins" + "plugin" ], "operationId": "v2.plugin.list", "parameters": [ @@ -352,7 +365,7 @@ "/api/session": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.list", "parameters": [ @@ -568,7 +581,7 @@ }, "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.create", "parameters": [], @@ -582,7 +595,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -679,7 +692,7 @@ "/api/session/active": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.active", "parameters": [], @@ -699,14 +712,10 @@ "$ref": "#/components/schemas/SessionActive" } } - }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" } }, "required": [ - "data", - "watermarks" + "data" ], "additionalProperties": false } @@ -734,14 +743,14 @@ } } }, - "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.", + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.", "summary": "List active sessions" } }, "/api/session/{sessionID}": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.get", "parameters": [ @@ -769,7 +778,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -820,12 +829,78 @@ }, "description": "Retrieve a session by ID.", "summary": "Get session" + }, + "delete": { + "tags": [ + "session" + ], + "operationId": "v2.session.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Delete a session and its child sessions.", + "summary": "Delete session" } }, "/api/session/{sessionID}/fork": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.fork", "parameters": [ @@ -853,7 +928,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -940,7 +1015,7 @@ "/api/session/{sessionID}/agent": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.switchAgent", "parameters": [ @@ -1027,7 +1102,7 @@ "/api/session/{sessionID}/model": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.switchModel", "parameters": [ @@ -1114,7 +1189,7 @@ "/api/session/{sessionID}/rename": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.rename", "parameters": [ @@ -1198,10 +1273,123 @@ } } }, + "/api/session/{sessionID}/move": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.move", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Move a session to another project directory, optionally transferring local changes.", + "summary": "Move session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destination": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "moveChanges": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "destination" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.prompt", "parameters": [ @@ -1245,7 +1433,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1353,7 +1548,7 @@ "/api/session/{sessionID}/command": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.command", "parameters": [ @@ -1397,7 +1592,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1560,7 +1762,7 @@ "/api/session/{sessionID}/skill": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.skill", "parameters": [ @@ -1675,7 +1877,7 @@ "/api/session/{sessionID}/synthetic": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.synthetic", "parameters": [ @@ -1759,6 +1961,16 @@ }, "metadata": { "type": "object" + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1772,12 +1984,12 @@ } } }, - "/api/session/{sessionID}/compact": { + "/api/session/{sessionID}/shell": { "post": { "tags": [ - "sessions" + "session" ], - "operationId": "v2.session.compact", + "operationId": "v2.session.shell", "parameters": [ { "name": "sessionID", @@ -1818,6 +2030,124 @@ } } }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + { + "type": "null" + } + ] + }, + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/compact": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.compact", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Compaction" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "404": { "description": "SessionNotFoundError", "content": { @@ -1836,44 +2166,52 @@ } }, "409": { - "description": "SessionBusyError", + "description": "ConflictError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionBusyError" - } - } - } - }, - "500": { - "description": "UnknownError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnknownError" - } - } - } - }, - "503": { - "description": "ServiceUnavailableError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceUnavailableError" + "$ref": "#/components/schemas/ConflictError" } } } } }, - "description": "Compact a session conversation.", - "summary": "Compact session" + "description": "Queue a durable session compaction request.", + "summary": "Compact session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } } }, "/api/session/{sessionID}/wait": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.wait", "parameters": [ @@ -1951,7 +2289,7 @@ "/api/session/{sessionID}/revert/stage": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.revert.stage", "parameters": [ @@ -1979,7 +2317,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -2092,7 +2430,7 @@ "/api/session/{sessionID}/revert/clear": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.revert.clear", "parameters": [ @@ -2179,7 +2517,7 @@ "/api/session/{sessionID}/revert/commit": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.revert.commit", "parameters": [ @@ -2256,7 +2594,7 @@ "/api/session/{sessionID}/context": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.context", "parameters": [ @@ -2286,7 +2624,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } } }, @@ -2353,7 +2691,7 @@ "/api/session/{sessionID}/instructions/entries": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.instructions.entry.list", "parameters": [ @@ -2440,7 +2778,7 @@ "/api/session/{sessionID}/instructions/entries/{key}": { "put": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.instructions.entry.put", "parameters": [ @@ -2531,7 +2869,7 @@ }, "delete": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.instructions.entry.remove", "parameters": [ @@ -2604,10 +2942,10 @@ "summary": "Remove instruction entry" } }, - "/api/session/{sessionID}/log": { + "/api/experimental/session/{sessionID}/log": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.log", "parameters": [ @@ -2809,14 +3147,14 @@ } } }, - "description": "Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.", + "description": "Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.", "summary": "Read the session log" } }, "/api/session/{sessionID}/interrupt": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.interrupt", "parameters": [ @@ -2884,7 +3222,7 @@ "/api/session/{sessionID}/background": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.background", "parameters": [ @@ -2952,7 +3290,7 @@ "/api/session/{sessionID}/message/{messageID}": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.message", "parameters": [ @@ -2993,7 +3331,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, "required": [ @@ -3052,9 +3390,9 @@ "/api/session/{sessionID}/message": { "get": { "tags": [ - "messages" + "session" ], - "operationId": "v2.session.messages", + "operationId": "v2.message.list", "parameters": [ { "name": "sessionID", @@ -3196,7 +3534,7 @@ "/api/model": { "get": { "tags": [ - "models" + "model" ], "operationId": "v2.model.list", "parameters": [ @@ -3256,7 +3594,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" } } }, @@ -3307,7 +3645,7 @@ "/api/model/default": { "get": { "tags": [ - "models" + "model" ], "operationId": "v2.model.default", "parameters": [ @@ -3367,7 +3705,7 @@ "data": { "anyOf": [ { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" }, { "type": "null" @@ -3553,7 +3891,7 @@ "/api/provider": { "get": { "tags": [ - "providers" + "provider" ], "operationId": "v2.provider.list", "parameters": [ @@ -3664,7 +4002,7 @@ "/api/provider/{providerID}": { "get": { "tags": [ - "providers" + "provider" ], "operationId": "v2.provider.get", "parameters": [ @@ -3790,7 +4128,7 @@ "/api/integration": { "get": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.list", "parameters": [ @@ -3891,7 +4229,7 @@ "/api/integration/{integrationID}": { "get": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.get", "parameters": [ @@ -4004,7 +4342,7 @@ "/api/integration/{integrationID}/connect/key": { "post": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.connect.key", "parameters": [ @@ -4126,7 +4464,7 @@ "/api/integration/{integrationID}/connect/oauth": { "post": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.connect.oauth", "parameters": [ @@ -4275,7 +4613,7 @@ "/api/integration/attempt/{attemptID}": { "get": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.attempt.status", "parameters": [ @@ -4379,7 +4717,7 @@ }, "delete": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.attempt.cancel", "parameters": [ @@ -4465,7 +4803,7 @@ "/api/integration/attempt/{attemptID}/complete": { "post": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.attempt.complete", "parameters": [ @@ -4679,10 +5017,108 @@ "summary": "List MCP servers" } }, + "/api/mcp/resource": { + "get": { + "tags": [ + "mcp" + ], + "operationId": "v2.mcp.resource.catalog", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Mcp.ResourceCatalog" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve resources and resource templates from connected MCP servers.", + "summary": "List MCP resources" + } + }, "/api/credential/{credentialID}": { "patch": { "tags": [ - "server.credential" + "credential" ], "operationId": "v2.credential.update", "parameters": [ @@ -4785,7 +5221,7 @@ }, "delete": { "tags": [ - "server.credential" + "credential" ], "operationId": "v2.credential.remove", "parameters": [ @@ -4868,10 +5304,57 @@ "summary": "Remove credential" } }, + "/api/project": { + "get": { + "tags": [ + "project" + ], + "operationId": "v2.project.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List known projects.", + "summary": "List projects" + } + }, "/api/project/current": { "get": { "tags": [ - "projects" + "project" ], "operationId": "v2.project.current", "parameters": [ @@ -4956,7 +5439,7 @@ "/api/project/{projectID}/directories": { "get": { "tags": [ - "projects" + "project" ], "operationId": "v2.project.directories", "parameters": [ @@ -5049,7 +5532,7 @@ "/api/form/request": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.form.request.list", "parameters": [ @@ -5157,7 +5640,7 @@ "/api/session/{sessionID}/form": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.list", "parameters": [ @@ -5244,7 +5727,7 @@ }, "post": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.create", "parameters": [ @@ -5357,7 +5840,7 @@ "/api/session/{sessionID}/form/{formID}": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.get", "parameters": [ @@ -5459,7 +5942,7 @@ "/api/session/{sessionID}/form/{formID}/state": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.state", "parameters": [ @@ -5554,7 +6037,7 @@ "/api/session/{sessionID}/form/{formID}/reply": { "post": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.reply", "parameters": [ @@ -5660,7 +6143,7 @@ "/api/session/{sessionID}/form/{formID}/cancel": { "post": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.cancel", "parameters": [ @@ -5749,7 +6232,7 @@ "/api/permission/request": { "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.permission.request.list", "parameters": [ @@ -5850,7 +6333,7 @@ "/api/permission/saved": { "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.permission.saved.list", "parameters": [ @@ -5922,7 +6405,7 @@ "/api/permission/saved/{id}": { "delete": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.permission.saved.remove", "parameters": [ @@ -5968,7 +6451,7 @@ "/api/session/{sessionID}/permission": { "post": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.create", "parameters": [ @@ -6131,7 +6614,7 @@ }, "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.list", "parameters": [ @@ -6218,7 +6701,7 @@ "/api/session/{sessionID}/permission/{requestID}": { "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.get", "parameters": [ @@ -6318,7 +6801,7 @@ "/api/session/{sessionID}/permission/{requestID}/reply": { "post": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.reply", "parameters": [ @@ -6769,7 +7252,7 @@ "/api/command": { "get": { "tags": [ - "commands" + "command" ], "operationId": "v2.command.list", "parameters": [ @@ -6829,7 +7312,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/CommandV2.Info" + "$ref": "#/components/schemas/Command.Info" } } }, @@ -6870,7 +7353,7 @@ "/api/skill": { "get": { "tags": [ - "skills" + "skill" ], "operationId": "v2.skill.list", "parameters": [ @@ -6930,7 +7413,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SkillV2.Info" + "$ref": "#/components/schemas/Skill.Info" } } }, @@ -6971,7 +7454,7 @@ "/api/event": { "get": { "tags": [ - "events" + "event" ], "operationId": "v2.event.subscribe", "parameters": [], @@ -7108,154 +7591,10 @@ } } }, - "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.", + "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.", "summary": "Subscribe to events" } }, - "/api/event/changes": { - "get": { - "tags": [ - "events" - ], - "operationId": "v2.event.changes", - "parameters": [], - "security": [], - "responses": { - "200": { - "description": "Success", - "content": { - "text/event-stream": { - "schema": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "event": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/EventLog.ChangeStream" - } - }, - "required": [ - "id", - "event", - "data" - ], - "additionalProperties": false - }, - "x-effect-stream": { - "encoding": "sse", - "causeSchema": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Fail" - ] - }, - "error": { - "not": {} - } - }, - "required": [ - "_tag", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Die" - ] - }, - "defect": {} - }, - "required": [ - "_tag", - "defect" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Interrupt" - ] - }, - "fiberId": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "fiberId" - ], - "additionalProperties": false - } - ] - } - }, - "errorSchema": { - "not": {} - }, - "failureEvent": "effect/httpapi/stream/failure" - } - } - } - }, - "400": { - "description": "InvalidRequestError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InvalidRequestError" - } - } - } - }, - "401": { - "description": "UnauthorizedError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedError" - } - } - } - } - }, - "description": "Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.", - "summary": "Subscribe to change hints" - } - }, "/api/pty": { "get": { "tags": [ @@ -7873,7 +8212,7 @@ "tags": [ "pty" ], - "operationId": "v2.pty.connectToken", + "operationId": "v2.pty.connect.token", "parameters": [ { "name": "ptyID", @@ -8005,7 +8344,6 @@ "pty" ], "operationId": "v2.pty.connect", - "x-websocket": true, "parameters": [ { "name": "ptyID", @@ -8103,7 +8441,8 @@ } }, "description": "Establish a WebSocket connection streaming PTY output and accepting terminal input.", - "summary": "Connect to PTY session" + "summary": "Connect to PTY session", + "x-websocket": true } }, "/api/shell": { @@ -8326,7 +8665,8 @@ } }, "required": [ - "command" + "command", + "timeout" ], "additionalProperties": false } @@ -8556,6 +8896,151 @@ "summary": "Remove shell command" } }, + "/api/shell/{id}/timeout": { + "patch": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.timeout", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Replace a running shell command's timeout from now, or clear it with zero.", + "summary": "Update shell timeout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "timeout" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/shell/{id}/output": { "get": { "tags": [ @@ -8737,7 +9222,7 @@ "/api/question/request": { "get": { "tags": [ - "session questions" + "question" ], "operationId": "v2.question.request.list", "parameters": [ @@ -8838,7 +9323,7 @@ "/api/session/{sessionID}/question": { "get": { "tags": [ - "session questions" + "question" ], "operationId": "v2.session.question.list", "parameters": [ @@ -8925,7 +9410,7 @@ "/api/session/{sessionID}/question/{requestID}/reply": { "post": { "tags": [ - "session questions" + "question" ], "operationId": "v2.session.question.reply", "parameters": [ @@ -9019,7 +9504,7 @@ "/api/session/{sessionID}/question/{requestID}/reject": { "post": { "tags": [ - "session questions" + "question" ], "operationId": "v2.session.question.reject", "parameters": [ @@ -9715,7 +10200,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -9752,6 +10237,129 @@ "description": "Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.", "summary": "VCS diff" } + }, + "/api/debug/location": { + "get": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Location.Ref" + } + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List locations currently loaded by the server.", + "summary": "List loaded locations" + }, + "delete": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location.evict", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Dispose the requested location's cached services so its next use boots them fresh.", + "summary": "Evict a loaded location" + } } }, "components": { @@ -9954,12 +10562,15 @@ "$ref": "#/components/schemas/PermissionV2.Rule" } }, - "AgentV2.Info": { + "Agent.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "name": { + "type": "string" + }, "model": { "$ref": "#/components/schemas/Model.Ref" }, @@ -10000,6 +10611,7 @@ }, "required": [ "id", + "name", "request", "mode", "hidden", @@ -10019,6 +10631,46 @@ ], "additionalProperties": false }, + "Money.USD": { + "type": "number" + }, + "TokenUsage.Info": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, "Location.Ref": { "type": "object", "properties": { @@ -10039,19 +10691,14 @@ ], "additionalProperties": false }, - "File.Diff": { + "FileDiff.Info": { "type": "object", "properties": { - "path": { + "file": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "added", - "modified", - "deleted" - ] + "patch": { + "type": "string" }, "additions": { "type": "integer", @@ -10069,20 +10716,25 @@ } ] }, - "patch": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] } }, "required": [ - "path", - "status", + "file", + "patch", "additions", "deletions", - "patch" + "status" ], "additionalProperties": false }, - "Revert.State": { + "Session.Revert": { "type": "object", "properties": { "messageID": { @@ -10099,13 +10751,10 @@ "snapshot": { "type": "string" }, - "diff": { - "type": "string" - }, "files": { "type": "array", "items": { - "$ref": "#/components/schemas/File.Diff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -10114,7 +10763,7 @@ ], "additionalProperties": false }, - "SessionV2.Info": { + "Session.Info": { "type": "object", "properties": { "id": { @@ -10133,6 +10782,31 @@ } ] }, + "fork": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + }, "projectID": { "type": "string" }, @@ -10143,44 +10817,10 @@ "$ref": "#/components/schemas/Model.Ref" }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "time": { "type": "object", @@ -10211,7 +10851,7 @@ "type": "string" }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -10225,32 +10865,15 @@ ], "additionalProperties": false }, - "SessionWatermarks": { - "type": "object", - "patternProperties": { - "^ses": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "description": "Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent." - }, "SessionsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" - }, "cursor": { "type": "object", "properties": { @@ -10280,7 +10903,6 @@ }, "required": [ "data", - "watermarks", "cursor" ], "additionalProperties": false @@ -10408,7 +11030,7 @@ ], "additionalProperties": false }, - "Prompt.Source": { + "Prompt.Mention": { "type": "object", "properties": { "start": { @@ -10440,8 +11062,8 @@ "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10455,8 +11077,8 @@ "name": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10488,28 +11110,78 @@ ], "additionalProperties": false }, + "Prompt.Base64": { + "type": "string", + "allOf": [ + { + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + } + ] + }, + "Prompt.FileSource": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inline" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "uri" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "additionalProperties": false + } + ] + }, "Prompt.FileAttachment": { "type": "object", "properties": { - "uri": { - "type": "string" + "data": { + "$ref": "#/components/schemas/Prompt.Base64" }, "mime": { "type": "string" }, + "source": { + "$ref": "#/components/schemas/Prompt.FileSource" + }, "name": { "type": "string" }, "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ - "uri", - "mime" + "data", + "mime", + "source" ], "additionalProperties": false }, @@ -10694,26 +11366,57 @@ ], "additionalProperties": false }, - "SessionBusyError": { + "SessionInput.Compaction": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", "enum": [ - "SessionBusyError" + "compaction" + ] + }, + "admittedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } ] }, "sessionID": { - "type": "string" + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, - "message": { - "type": "string" + "timeCreated": { + "type": "number" + }, + "handledSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ - "_tag", + "type", + "admittedSeq", + "id", "sessionID", - "message" + "timeCreated" ], "additionalProperties": false }, @@ -10746,6 +11449,29 @@ ], "additionalProperties": false }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionBusyError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, "UnknownError": { "type": "object", "properties": { @@ -10775,7 +11501,7 @@ ], "additionalProperties": false }, - "Session.Message.AgentSwitched": { + "Session.Message.AgentSelected": { "type": "object", "properties": { "id": { @@ -10819,7 +11545,7 @@ ], "additionalProperties": false }, - "Session.Message.ModelSwitched": { + "Session.Message.ModelSelected": { "type": "object", "properties": { "id": { @@ -10853,6 +11579,9 @@ }, "model": { "$ref": "#/components/schemas/Model.Ref" + }, + "previous": { + "$ref": "#/components/schemas/Model.Ref" } }, "required": [ @@ -10945,14 +11674,6 @@ ], "additionalProperties": false }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, "text": { "type": "string" }, @@ -10969,7 +11690,6 @@ "required": [ "id", "time", - "sessionID", "text", "type" ], @@ -11051,6 +11771,9 @@ "skill" ] }, + "skill": { + "type": "string" + }, "name": { "type": "string" }, @@ -11062,6 +11785,7 @@ "id", "time", "type", + "skill", "name", "text" ], @@ -11102,23 +11826,105 @@ "shell" ] }, - "callID": { - "type": "string" + "shellID": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] }, "command": { "type": "string" }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, "output": { - "type": "string" + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false } }, "required": [ "id", "time", "type", - "callID", + "shellID", "command", - "output" + "status" ], "additionalProperties": false }, @@ -11131,25 +11937,18 @@ "text" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" } }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "LLM.ProviderMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState": { + "type": "object" }, "Session.Message.Assistant.Reasoning": { "type": "object", @@ -11160,14 +11959,11 @@ "reasoning" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "time": { "type": "object", @@ -11187,18 +11983,17 @@ }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "Session.Message.ToolState.Pending": { + "Session.Message.ToolState.Streaming": { "type": "object", "properties": { "status": { "type": "string", "enum": [ - "pending" + "streaming" ] }, "input": { @@ -11308,24 +12103,12 @@ "input": { "type": "object" }, - "attachments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prompt.FileAttachment" - } - }, "content": { "type": "array", "items": { "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "structured": { "type": "object" }, @@ -11339,14 +12122,11 @@ ], "additionalProperties": false }, - "Session.Error.Unknown": { + "Session.StructuredError": { "type": "object", "properties": { "type": { - "type": "string", - "enum": [ - "unknown" - ] + "type": "string" }, "message": { "type": "string" @@ -11380,7 +12160,7 @@ "type": "object" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {} }, @@ -11408,28 +12188,19 @@ "name": { "type": "string" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - }, - "resultMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" + }, + "providerResultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "state": { "anyOf": [ { - "$ref": "#/components/schemas/Session.Message.ToolState.Pending" + "$ref": "#/components/schemas/Session.Message.ToolState.Streaming" }, { "$ref": "#/components/schemas/Session.Message.ToolState.Running" @@ -11453,9 +12224,6 @@ }, "completed": { "type": "number" - }, - "pruned": { - "type": "number" } }, "required": [ @@ -11473,6 +12241,31 @@ ], "additionalProperties": false }, + "Session.Message.Assistant.Retry": { + "type": "object", + "properties": { + "attempt": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "attempt", + "at", + "error" + ], + "additionalProperties": false + }, "Session.Message.Assistant": { "type": "object", "properties": { @@ -11549,50 +12342,27 @@ "additionalProperties": false }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "retry": { + "$ref": "#/components/schemas/Session.Message.Assistant.Retry" } }, "required": [ @@ -11605,7 +12375,7 @@ ], "additionalProperties": false }, - "Session.Message.Compaction": { + "Session.Message.Compaction.Running": { "type": "object", "properties": { "type": { @@ -11614,19 +12384,6 @@ "compaction" ] }, - "reason": { - "type": "string", - "enum": [ - "auto", - "manual" - ] - }, - "summary": { - "type": "string" - }, - "recent": { - "type": "string" - }, "id": { "type": "string", "allOf": [ @@ -11649,25 +12406,180 @@ "created" ], "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" } }, "required": [ "type", + "id", + "time", + "status", "reason", "summary", - "recent", - "id", - "time" + "recent" ], "additionalProperties": false }, - "Session.Message": { + "Session.Message.Compaction.Completed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "summary", + "recent" + ], + "additionalProperties": false + }, + "Session.Message.Compaction.Failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "error" + ], + "additionalProperties": false + }, + "Session.Message.Compaction": { "anyOf": [ { - "$ref": "#/components/schemas/Session.Message.AgentSwitched" + "$ref": "#/components/schemas/Session.Message.Compaction.Running" }, { - "$ref": "#/components/schemas/Session.Message.ModelSwitched" + "$ref": "#/components/schemas/Session.Message.Compaction.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Failed" + } + ] + }, + "Session.Message.Info": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.AgentSelected" + }, + { + "$ref": "#/components/schemas/Session.Message.ModelSelected" }, { "$ref": "#/components/schemas/Session.Message.User" @@ -11697,7 +12609,7 @@ "allOf": [ { "pattern": "^[a-z0-9][a-z0-9._-]*$", - "description": "Context entry key (lowercase alphanumerics plus . _ -)" + "description": "Instruction entry key (lowercase alphanumerics plus . _ -)" } ] }, @@ -11715,7 +12627,7 @@ ], "additionalProperties": false }, - "session.next.agent.switched": { + "session.agent.selected": { "type": "object", "properties": { "id": { @@ -11726,13 +12638,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.agent.switched" + "session.agent.selected" ] }, "durable": { @@ -11750,11 +12665,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -11771,9 +12684,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -11782,22 +12692,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "agent": { "type": "string" } }, "required": [ - "timestamp", "sessionID", - "messageID", "agent" ], "additionalProperties": false @@ -11805,12 +12705,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.model.switched": { + "session.model.selected": { "type": "object", "properties": { "id": { @@ -11821,13 +12723,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.model.switched" + "session.model.selected" ] }, "durable": { @@ -11845,11 +12750,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -11866,9 +12769,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -11877,22 +12777,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "model": { "$ref": "#/components/schemas/Model.Ref" } }, "required": [ - "timestamp", "sessionID", - "messageID", "model" ], "additionalProperties": false @@ -11900,12 +12790,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.moved": { + "session.moved": { "type": "object", "properties": { "id": { @@ -11916,13 +12808,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.moved" + "session.moved" ] }, "durable": { @@ -11940,11 +12835,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -11961,9 +12854,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -11975,12 +12865,11 @@ "location": { "$ref": "#/components/schemas/Location.Ref" }, - "subdirectory": { + "subpath": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "location" ], @@ -11989,12 +12878,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.renamed": { + "session.renamed": { "type": "object", "properties": { "id": { @@ -12005,13 +12896,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.renamed" + "session.renamed" ] }, "durable": { @@ -12029,11 +12923,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12050,9 +12942,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12066,7 +12955,6 @@ } }, "required": [ - "timestamp", "sessionID", "title" ], @@ -12075,12 +12963,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.forked": { + "session.deleted": { "type": "object", "properties": { "id": { @@ -12091,13 +12981,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.forked" + "session.deleted" ] }, "durable": { @@ -12115,11 +13008,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 2 ] } }, @@ -12136,9 +13027,87 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.forked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.forked" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -12155,7 +13124,7 @@ } ] }, - "messageID": { + "from": { "type": "string", "allOf": [ { @@ -12165,7 +13134,6 @@ } }, "required": [ - "timestamp", "sessionID", "parentID" ], @@ -12174,12 +13142,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.prompted": { + "session.prompt.promoted": { "type": "object", "properties": { "id": { @@ -12190,13 +13160,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.prompted" + "session.prompt.promoted" ] }, "durable": { @@ -12214,11 +13187,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12235,9 +13206,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12246,7 +13214,97 @@ } ] }, - "messageID": { + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.prompt.admitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.prompt.admitted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "inputID": { "type": "string", "allOf": [ { @@ -12266,9 +13324,8 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", + "inputID", "prompt", "delivery" ], @@ -12277,12 +13334,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.prompt.admitted": { + "session.execution.started": { "type": "object", "properties": { "id": { @@ -12293,13 +13352,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.prompt.admitted" + "session.execution.started" ] }, "durable": { @@ -12317,11 +13379,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12338,9 +13398,168 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.succeeded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.succeeded" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -12349,43 +13568,117 @@ } ] }, - "messageID": { - "type": "string", + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "sessionID", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.interrupted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.interrupted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", "allOf": [ { - "pattern": "^msg_" + "minimum": 0 } ] }, - "prompt": { - "$ref": "#/components/schemas/Prompt" + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, - "delivery": { + "reason": { "type": "string", "enum": [ - "steer", - "queue" + "user", + "shutdown", + "superseded" ] } }, "required": [ - "timestamp", "sessionID", - "messageID", - "prompt", - "delivery" + "reason" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.instructions.updated": { + "session.instructions.updated": { "type": "object", "properties": { "id": { @@ -12396,13 +13689,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.instructions.updated" + "session.instructions.updated" ] }, "durable": { @@ -12420,11 +13716,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12441,9 +13735,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12452,22 +13743,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "text": { "type": "string" } }, "required": [ - "timestamp", "sessionID", - "messageID", "text" ], "additionalProperties": false @@ -12475,12 +13756,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.synthetic": { + "session.synthetic": { "type": "object", "properties": { "id": { @@ -12491,13 +13774,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.synthetic" + "session.synthetic" ] }, "durable": { @@ -12515,11 +13801,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12536,9 +13820,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12547,14 +13828,6 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "text": { "type": "string" }, @@ -12566,9 +13839,7 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", "text" ], "additionalProperties": false @@ -12576,12 +13847,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.skill.activated": { + "session.skill.activated": { "type": "object", "properties": { "id": { @@ -12592,13 +13865,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.skill.activated" + "session.skill.activated" ] }, "durable": { @@ -12616,11 +13892,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12637,9 +13911,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12648,13 +13919,8 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] + "id": { + "type": "string" }, "name": { "type": "string" @@ -12664,9 +13930,8 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", + "id", "name", "text" ], @@ -12675,111 +13940,154 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.shell.started": { + "Shell": { "type": "object", "properties": { "id": { "type": "string", "allOf": [ { - "pattern": "^evt_" + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] } ] }, "metadata": { "type": "object" }, - "type": { - "type": "string", - "enum": [ - "session.next.shell.started" - ] - }, - "durable": { + "time": { "type": "object", "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ + "started": { + "anyOf": [ { - "minimum": 0 + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] } ] }, - "version": { - "type": "integer", - "allOf": [ + "completed": { + "anyOf": [ { - "minimum": 1 + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] } ] } }, "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - "callID": { - "type": "string" - }, - "command": { - "type": "string" - } - }, - "required": [ - "timestamp", - "sessionID", - "messageID", - "callID", - "command" + "started" ], "additionalProperties": false } }, "required": [ "id", - "type", - "data" + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" ], "additionalProperties": false }, - "session.next.shell.ended": { + "session.shell.started": { "type": "object", "properties": { "id": { @@ -12790,13 +14098,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.shell.ended" + "session.shell.started" ] }, "durable": { @@ -12814,11 +14125,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12835,9 +14144,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12846,17 +14152,132 @@ } ] }, - "callID": { - "type": "string" - }, - "output": { - "type": "string" + "shell": { + "$ref": "#/components/schemas/Shell" } }, "required": [ - "timestamp", "sessionID", - "callID", + "shell" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.shell.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.shell.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "shell": { + "$ref": "#/components/schemas/Shell" + }, + "output": { + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false + } + }, + "required": [ + "sessionID", + "shell", "output" ], "additionalProperties": false @@ -12864,12 +14285,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.step.started": { + "session.step.started": { "type": "object", "properties": { "id": { @@ -12880,13 +14303,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.step.started" + "session.step.started" ] }, "durable": { @@ -12904,11 +14330,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12925,9 +14349,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12955,7 +14376,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "agent", @@ -12966,12 +14386,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.step.ended": { + "session.step.ended": { "type": "object", "properties": { "id": { @@ -12982,13 +14404,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.step.ended" + "session.step.ended" ] }, "durable": { @@ -13006,11 +14431,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13027,9 +14450,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13047,47 +14467,21 @@ ] }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "snapshot": { "type": "string" @@ -13100,7 +14494,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "finish", @@ -13112,12 +14505,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.step.failed": { + "session.step.failed": { "type": "object", "properties": { "id": { @@ -13128,13 +14523,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.step.failed" + "session.step.failed" ] }, "durable": { @@ -13152,11 +14550,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13173,9 +14569,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13193,11 +14586,16 @@ ] }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "error" @@ -13207,12 +14605,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.text.started": { + "session.text.started": { "type": "object", "properties": { "id": { @@ -13223,13 +14623,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.text.started" + "session.text.started" ] }, "durable": { @@ -13247,11 +14650,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13268,9 +14669,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13287,27 +14685,33 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "textID" + "ordinal" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.text.ended": { + "session.text.ended": { "type": "object", "properties": { "id": { @@ -13318,13 +14722,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.text.ended" + "session.text.ended" ] }, "durable": { @@ -13342,11 +14749,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13363,9 +14768,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13382,18 +14784,22 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "text": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "textID", + "ordinal", "text" ], "additionalProperties": false @@ -13401,12 +14807,17 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.tool.input.started": { + "Session.Message.ProviderState3": { + "type": "object" + }, + "session.reasoning.started": { "type": "object", "properties": { "id": { @@ -13417,13 +14828,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.input.started" + "session.reasoning.started" ] }, "durable": { @@ -13441,11 +14855,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13462,9 +14874,217 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState3" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "ordinal" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "Session.Message.ProviderState4": { + "type": "object" + }, + "session.reasoning.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.reasoning.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "text": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState4" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "ordinal", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.tool.input.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.input.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -13489,7 +15109,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -13500,12 +15119,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.tool.input.ended": { + "session.tool.input.ended": { "type": "object", "properties": { "id": { @@ -13516,13 +15137,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.input.ended" + "session.tool.input.ended" ] }, "durable": { @@ -13540,11 +15164,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13561,9 +15183,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13588,7 +15207,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -13599,18 +15217,17 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata3": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState5": { + "type": "object" }, - "session.next.tool.called": { + "session.tool.called": { "type": "object", "properties": { "id": { @@ -13621,13 +15238,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.called" + "session.tool.called" ] }, "durable": { @@ -13645,11 +15265,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13666,9 +15284,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13688,48 +15303,36 @@ "callID": { "type": "string" }, - "tool": { - "type": "string" - }, "input": { "type": "object" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata3" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState5" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", - "tool", "input", - "provider" + "executed" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.tool.progress": { + "session.tool.progress": { "type": "object", "properties": { "id": { @@ -13740,13 +15343,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.progress" + "session.tool.progress" ] }, "durable": { @@ -13764,11 +15370,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13785,9 +15389,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13818,7 +15419,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -13830,18 +15430,17 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata4": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState6": { + "type": "object" }, - "session.next.tool.success": { + "session.tool.success": { "type": "object", "properties": { "id": { @@ -13852,13 +15451,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.success" + "session.tool.success" ] }, "durable": { @@ -13876,11 +15478,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13897,9 +15497,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13928,55 +15525,38 @@ "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata4" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState6" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", - "provider" + "executed" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata5": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState7": { + "type": "object" }, - "session.next.tool.failed": { + "session.tool.failed": { "type": "object", "properties": { "id": { @@ -13987,13 +15567,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.failed" + "session.tool.failed" ] }, "durable": { @@ -14011,11 +15594,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14032,9 +15613,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14055,50 +15633,36 @@ "type": "string" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata5" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState7" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", "error", - "provider" + "executed" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata6": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "session.next.reasoning.started": { + "session.retry.scheduled": { "type": "object", "properties": { "id": { @@ -14109,253 +15673,16 @@ } ] }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.reasoning.started" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "assistantMessageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - "reasoningID": { - "type": "string" - }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata6" - } - }, - "required": [ - "timestamp", - "sessionID", - "assistantMessageID", - "reasoningID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "LLM.ProviderMetadata7": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "session.next.reasoning.ended": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.reasoning.ended" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "assistantMessageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - "reasoningID": { - "type": "string" - }, - "text": { - "type": "string" - }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata7" - } - }, - "required": [ - "timestamp", - "sessionID", - "assistantMessageID", - "reasoningID", - "text" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "session.next.retry_error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { + "created": { "type": "number" }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "message", - "isRetryable" - ], - "additionalProperties": false - }, - "session.next.retried": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.retried" + "session.retry.scheduled" ] }, "durable": { @@ -14373,11 +15700,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14394,9 +15719,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14405,17 +15727,39 @@ } ] }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, "attempt": { - "type": "number" + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "error": { - "$ref": "#/components/schemas/session.next.retry_error" + "$ref": "#/components/schemas/Session.StructuredError" } }, "required": [ - "timestamp", "sessionID", + "assistantMessageID", "attempt", + "at", "error" ], "additionalProperties": false @@ -14423,12 +15767,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.compaction.started": { + "session.compaction.admitted": { "type": "object", "properties": { "id": { @@ -14439,13 +15785,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.compaction.started" + "session.compaction.admitted" ] }, "durable": { @@ -14463,11 +15812,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14484,9 +15831,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14495,13 +15839,95 @@ } ] }, - "messageID": { + "inputID": { "type": "string", "allOf": [ { "pattern": "^msg_" } ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.compaction.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, "reason": { "type": "string", @@ -14509,25 +15935,37 @@ "auto", "manual" ] + }, + "recent": { + "type": "string" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] } }, "required": [ - "timestamp", "sessionID", - "messageID", - "reason" + "reason", + "recent" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.compaction.ended": { + "session.compaction.ended": { "type": "object", "properties": { "id": { @@ -14538,13 +15976,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.compaction.ended" + "session.compaction.ended" ] }, "durable": { @@ -14562,11 +16003,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14583,9 +16022,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14594,14 +16030,6 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "reason": { "type": "string", "enum": [ @@ -14617,9 +16045,7 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", "reason", "text", "recent" @@ -14629,12 +16055,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.revert.staged": { + "session.compaction.failed": { "type": "object", "properties": { "id": { @@ -14645,13 +16073,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.revert.staged" + "session.compaction.failed" ] }, "durable": { @@ -14669,11 +16100,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14690,9 +16119,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14701,175 +16127,17 @@ } ] }, - "revert": { - "$ref": "#/components/schemas/Revert.State" - } - }, - "required": [ - "timestamp", - "sessionID", - "revert" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "session.next.revert.cleared": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.revert.cleared" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { + "reason": { "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - } - }, - "required": [ - "timestamp", - "sessionID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "session.next.revert.committed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.revert.committed" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } + "enum": [ + "auto", + "manual" ] }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + "error": { + "$ref": "#/components/schemas/Session.StructuredError" }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { + "inputID": { "type": "string", "allOf": [ { @@ -14879,114 +16147,393 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID" + "reason", + "error" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "SessionDurableEvent": { + "session.revert.staged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.staged" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "revert": { + "$ref": "#/components/schemas/Session.Revert" + } + }, + "required": [ + "sessionID", + "revert" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.revert.cleared": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.cleared" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.revert.committed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.committed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "to": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "to" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "Session.Event.Durable": { "oneOf": [ { - "$ref": "#/components/schemas/session.next.agent.switched" + "$ref": "#/components/schemas/session.agent.selected" }, { - "$ref": "#/components/schemas/session.next.model.switched" + "$ref": "#/components/schemas/session.model.selected" }, { - "$ref": "#/components/schemas/session.next.moved" + "$ref": "#/components/schemas/session.moved" }, { - "$ref": "#/components/schemas/session.next.renamed" + "$ref": "#/components/schemas/session.renamed" }, { - "$ref": "#/components/schemas/session.next.forked" + "$ref": "#/components/schemas/session.deleted" }, { - "$ref": "#/components/schemas/session.next.prompted" + "$ref": "#/components/schemas/session.forked" }, { - "$ref": "#/components/schemas/session.next.prompt.admitted" + "$ref": "#/components/schemas/session.prompt.promoted" }, { - "$ref": "#/components/schemas/session.next.instructions.updated" + "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.next.synthetic" + "$ref": "#/components/schemas/session.execution.started" }, { - "$ref": "#/components/schemas/session.next.skill.activated" + "$ref": "#/components/schemas/session.execution.succeeded" }, { - "$ref": "#/components/schemas/session.next.shell.started" + "$ref": "#/components/schemas/session.execution.failed" }, { - "$ref": "#/components/schemas/session.next.shell.ended" + "$ref": "#/components/schemas/session.execution.interrupted" }, { - "$ref": "#/components/schemas/session.next.step.started" + "$ref": "#/components/schemas/session.instructions.updated" }, { - "$ref": "#/components/schemas/session.next.step.ended" + "$ref": "#/components/schemas/session.synthetic" }, { - "$ref": "#/components/schemas/session.next.step.failed" + "$ref": "#/components/schemas/session.skill.activated" }, { - "$ref": "#/components/schemas/session.next.text.started" + "$ref": "#/components/schemas/session.shell.started" }, { - "$ref": "#/components/schemas/session.next.text.ended" + "$ref": "#/components/schemas/session.shell.ended" }, { - "$ref": "#/components/schemas/session.next.tool.input.started" + "$ref": "#/components/schemas/session.step.started" }, { - "$ref": "#/components/schemas/session.next.tool.input.ended" + "$ref": "#/components/schemas/session.step.ended" }, { - "$ref": "#/components/schemas/session.next.tool.called" + "$ref": "#/components/schemas/session.step.failed" }, { - "$ref": "#/components/schemas/session.next.tool.progress" + "$ref": "#/components/schemas/session.text.started" }, { - "$ref": "#/components/schemas/session.next.tool.success" + "$ref": "#/components/schemas/session.text.ended" }, { - "$ref": "#/components/schemas/session.next.tool.failed" + "$ref": "#/components/schemas/session.reasoning.started" }, { - "$ref": "#/components/schemas/session.next.reasoning.started" + "$ref": "#/components/schemas/session.reasoning.ended" }, { - "$ref": "#/components/schemas/session.next.reasoning.ended" + "$ref": "#/components/schemas/session.tool.input.started" }, { - "$ref": "#/components/schemas/session.next.retried" + "$ref": "#/components/schemas/session.tool.input.ended" }, { - "$ref": "#/components/schemas/session.next.compaction.started" + "$ref": "#/components/schemas/session.tool.called" }, { - "$ref": "#/components/schemas/session.next.compaction.ended" + "$ref": "#/components/schemas/session.tool.progress" }, { - "$ref": "#/components/schemas/session.next.revert.staged" + "$ref": "#/components/schemas/session.tool.success" }, { - "$ref": "#/components/schemas/session.next.revert.cleared" + "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.next.revert.committed" + "$ref": "#/components/schemas/session.retry.scheduled" + }, + { + "$ref": "#/components/schemas/session.compaction.admitted" + }, + { + "$ref": "#/components/schemas/session.compaction.started" + }, + { + "$ref": "#/components/schemas/session.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, + { + "$ref": "#/components/schemas/session.revert.staged" + }, + { + "$ref": "#/components/schemas/session.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.revert.committed" } ] }, @@ -15021,7 +16568,7 @@ "SessionLogItem": { "anyOf": [ { - "$ref": "#/components/schemas/SessionDurableEvent" + "$ref": "#/components/schemas/Session.Event.Durable" }, { "$ref": "#/components/schemas/EventLog.Synced" @@ -15041,17 +16588,9 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, - "watermark": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "cursor": { "type": "object", "properties": { @@ -15085,65 +16624,6 @@ ], "additionalProperties": false }, - "Model.Api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "package" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "settings" - ], - "additionalProperties": false - } - ] - }, "Model.Capabilities": { "type": "object", "properties": { @@ -15170,6 +16650,33 @@ ], "additionalProperties": false }, + "Model.Variant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "Money.USDPerMillionTokens": { + "type": "number" + }, "Model.Cost": { "type": "object", "properties": { @@ -15193,19 +16700,19 @@ "additionalProperties": false }, "input": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "output": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "cache": { "type": "object", "properties": { "read": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "write": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" } }, "required": [ @@ -15222,12 +16729,15 @@ ], "additionalProperties": false }, - "ModelV2.Info": { + "Model.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "modelID": { + "type": "string" + }, "providerID": { "type": "string" }, @@ -15237,66 +16747,28 @@ "name": { "type": "string" }, - "api": { - "$ref": "#/components/schemas/Model.Api" + "package": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" }, "capabilities": { "$ref": "#/components/schemas/Model.Capabilities" }, - "request": { - "type": "object", - "properties": { - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "settings", - "headers", - "body" - ], - "additionalProperties": false - }, "variants": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": [ - "id", - "settings", - "headers", - "body" - ], - "additionalProperties": false + "$ref": "#/components/schemas/Model.Variant" } }, "time": { @@ -15351,11 +16823,10 @@ }, "required": [ "id", + "modelID", "providerID", "name", - "api", "capabilities", - "request", "variants", "time", "cost", @@ -15386,63 +16857,6 @@ ], "additionalProperties": false }, - "Provider.AISDK": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "package" - ], - "additionalProperties": false - }, - "Provider.Native": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "settings" - ], - "additionalProperties": false - }, - "Provider.Api": { - "anyOf": [ - { - "$ref": "#/components/schemas/Provider.AISDK" - }, - { - "$ref": "#/components/schemas/Provider.Native" - } - ] - }, "ProviderV2.Info": { "type": "object", "properties": { @@ -15458,18 +16872,26 @@ "disabled": { "type": "boolean" }, - "api": { - "$ref": "#/components/schemas/Provider.Api" + "package": { + "type": "string" }, - "request": { - "$ref": "#/components/schemas/Provider.Request" + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" } }, "required": [ "id", "name", - "api", - "request" + "package" ], "additionalProperties": false }, @@ -16305,13 +17727,13 @@ ], "additionalProperties": false }, - "Mcp.Status.Disconnected": { + "Mcp.Status.Pending": { "type": "object", "properties": { "status": { "type": "string", "enum": [ - "disconnected" + "pending" ] } }, @@ -16400,7 +17822,7 @@ "$ref": "#/components/schemas/Mcp.Status.Connected" }, { - "$ref": "#/components/schemas/Mcp.Status.Disconnected" + "$ref": "#/components/schemas/Mcp.Status.Pending" }, { "$ref": "#/components/schemas/Mcp.Status.Disabled" @@ -16426,6 +17848,185 @@ ], "additionalProperties": false }, + "Mcp.Resource": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uri" + ], + "additionalProperties": false + }, + "Mcp.ResourceTemplate": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uriTemplate": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uriTemplate" + ], + "additionalProperties": false + }, + "Mcp.ResourceCatalog": { + "type": "object", + "properties": { + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.Resource" + } + }, + "templates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.ResourceTemplate" + } + } + }, + "required": [ + "resources", + "templates" + ], + "additionalProperties": false + }, + "Project.Vcs": { + "type": "string", + "enum": [ + "git", + "hg" + ] + }, + "Project.Icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Project.Commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "Project.Time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updated": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "initialized": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/Project.Vcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + }, + "time": { + "$ref": "#/components/schemas/Project.Time" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "worktree", + "time", + "sandboxes" + ], + "additionalProperties": false + }, "Project.Current": { "type": "object", "properties": { @@ -17538,7 +19139,7 @@ ], "additionalProperties": false }, - "CommandV2.Info": { + "Command.Info": { "type": "object", "properties": { "name": { @@ -17566,9 +19167,12 @@ ], "additionalProperties": false }, - "SkillV2.Info": { + "Skill.Info": { "type": "object", "properties": { + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -17589,6 +19193,7 @@ } }, "required": [ + "id", "name", "location", "content" @@ -17606,6 +19211,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17615,36 +19223,6 @@ "models-dev.refreshed" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17661,6 +19239,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17677,6 +19256,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17686,36 +19268,6 @@ "integration.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17732,6 +19284,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17748,6 +19301,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17757,36 +19313,6 @@ "integration.connection.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17805,6 +19331,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17821,6 +19348,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17830,36 +19360,6 @@ "catalog.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17876,6 +19376,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17892,6 +19393,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17901,36 +19405,6 @@ "agent.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17947,12 +19421,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "SnapshotFileDiff": { + "FileDiff.LegacyInfo": { "type": "object", "properties": { "file": { @@ -18016,7 +19491,7 @@ "$ref": "#/components/schemas/PermissionRule" } }, - "Session": { + "SessionV1.Info": { "type": "object", "properties": { "id": { @@ -18070,7 +19545,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -18258,6 +19733,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -18282,11 +19760,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18312,7 +19788,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18324,7 +19800,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -18340,6 +19818,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -18364,11 +19845,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18394,7 +19873,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18406,12 +19885,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.deleted": { + "session.deleted1": { "type": "object", "properties": { "id": { @@ -18422,6 +19903,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -18446,11 +19930,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18476,7 +19958,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18488,7 +19970,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -18636,7 +20120,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -19277,6 +20761,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -19301,11 +20788,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -19343,7 +20828,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -19359,6 +20846,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -19383,11 +20873,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -19430,7 +20918,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -20824,6 +22314,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -20848,11 +22341,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -20894,7 +22385,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -20910,6 +22403,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -20934,11 +22430,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -20990,12 +22484,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.execution.settled": { + "session.usage.updated": { "type": "object", "properties": { "id": { @@ -21006,54 +22502,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.execution.settled" + "session.usage.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21062,34 +22528,30 @@ } ] }, - "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "interrupted" - ] + "cost": { + "$ref": "#/components/schemas/Money.USD" }, - "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ - "timestamp", "sessionID", - "outcome" + "cost", + "tokens" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.text.delta": { + "session.text.delta": { "type": "object", "properties": { "id": { @@ -21100,54 +22562,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.text.delta" + "session.text.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21164,18 +22596,22 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "textID", + "ordinal", "delta" ], "additionalProperties": false @@ -21183,12 +22619,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.reasoning.delta": { + "session.reasoning.delta": { "type": "object", "properties": { "id": { @@ -21199,54 +22636,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.reasoning.delta" + "session.reasoning.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21263,18 +22670,22 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "reasoningID", + "ordinal", "delta" ], "additionalProperties": false @@ -21282,12 +22693,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.tool.input.delta": { + "session.tool.input.delta": { "type": "object", "properties": { "id": { @@ -21298,54 +22710,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.input.delta" + "session.tool.input.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21370,7 +22752,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -21381,12 +22762,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.compaction.delta": { + "session.compaction.delta": { "type": "object", "properties": { "id": { @@ -21397,54 +22779,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.compaction.delta" + "session.compaction.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21453,22 +22805,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "text": { "type": "string" } }, "required": [ - "timestamp", "sessionID", - "messageID", "text" ], "additionalProperties": false @@ -21476,12 +22818,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "file.edited": { + "filesystem.changed": { "type": "object", "properties": { "id": { @@ -21492,45 +22835,18 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "file.edited" + "filesystem.changed" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21539,16 +22855,26 @@ "properties": { "file": { "type": "string" + }, + "event": { + "type": "string", + "enum": [ + "add", + "change", + "unlink" + ] } }, "required": [ - "file" + "file", + "event" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", "data" ], @@ -21565,6 +22891,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21574,36 +22903,6 @@ "reference.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21620,6 +22919,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21636,6 +22936,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21645,36 +22948,6 @@ "permission.v2.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21730,6 +23003,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21746,6 +23020,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21755,36 +23032,6 @@ "permission.v2.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21821,6 +23068,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21837,6 +23085,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21846,36 +23097,6 @@ "plugin.added" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21894,6 +23115,52 @@ }, "required": [ "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "plugin.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "plugin.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", "type", "data" ], @@ -21910,6 +23177,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21919,36 +23189,6 @@ "project.directories.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21967,6 +23207,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21983,6 +23224,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21992,36 +23236,6 @@ "command.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22038,6 +23252,52 @@ }, "required": [ "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "config.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "config.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", "type", "data" ], @@ -22054,6 +23314,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22063,36 +23326,6 @@ "skill.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22109,88 +23342,7 @@ }, "required": [ "id", - "type", - "data" - ], - "additionalProperties": false - }, - "file.watcher.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "file.watcher.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": [ - "add", - "change", - "unlink" - ] - } - }, - "required": [ - "file", - "event" - ], - "additionalProperties": false - } - }, - "required": [ - "id", + "created", "type", "data" ], @@ -22268,6 +23420,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22277,36 +23432,6 @@ "pty.created" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22325,6 +23450,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22341,6 +23467,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22350,36 +23479,6 @@ "pty.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22398,6 +23497,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22414,6 +23514,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22423,36 +23526,6 @@ "pty.exited" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22485,6 +23558,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22501,6 +23575,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22510,36 +23587,6 @@ "pty.deleted" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22563,151 +23610,12 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "Shell": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^sh_" - } - ] - }, - "status": { - "type": "string", - "enum": [ - "running", - "exited", - "timeout", - "killed" - ] - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string" - }, - "shell": { - "type": "string" - }, - "file": { - "type": "string" - }, - "pid": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "exit": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "started": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - "completed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - } - }, - "required": [ - "started" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "status", - "command", - "cwd", - "shell", - "file", - "metadata", - "time" - ], - "additionalProperties": false - }, "shell.created": { "type": "object", "properties": { @@ -22719,6 +23627,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22728,36 +23639,6 @@ "shell.created" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22776,6 +23657,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22792,6 +23674,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22801,36 +23686,6 @@ "shell.exited" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22889,6 +23744,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22905,6 +23761,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22914,36 +23773,6 @@ "shell.deleted" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22967,6 +23796,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23049,6 +23879,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23058,36 +23891,6 @@ "question.v2.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23131,6 +23934,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23153,6 +23957,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23162,36 +23969,6 @@ "question.v2.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23231,6 +24008,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23247,6 +24025,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23256,36 +24037,6 @@ "question.v2.rejected" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23318,6 +24069,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23886,6 +24638,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23895,36 +24650,6 @@ "form.created" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23950,6 +24675,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24013,6 +24739,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24022,36 +24751,6 @@ "form.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24083,6 +24782,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24099,6 +24799,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24108,36 +24811,6 @@ "form.cancelled" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24165,6 +24838,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24204,6 +24878,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24213,36 +24890,6 @@ "todo.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24273,6 +24920,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24391,6 +25039,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24400,36 +25051,6 @@ "session.status" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24457,6 +25078,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24473,6 +25095,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24482,36 +25107,6 @@ "session.idle" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24535,6 +25130,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24551,6 +25147,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24560,36 +25159,6 @@ "tui.prompt.append" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24608,6 +25177,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24624,6 +25194,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24633,36 +25206,6 @@ "tui.command.execute" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24707,6 +25250,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24723,6 +25267,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24732,36 +25279,6 @@ "tui.toast.show" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24808,6 +25325,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24824,6 +25342,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24833,36 +25354,6 @@ "tui.session.select" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24887,6 +25378,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24903,6 +25395,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24912,36 +25407,6 @@ "installation.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24960,6 +25425,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24976,6 +25442,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24985,36 +25454,6 @@ "installation.update-available" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25033,6 +25472,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25049,6 +25489,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25058,36 +25501,6 @@ "vcs.branch.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25103,6 +25516,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25119,6 +25533,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25128,36 +25545,6 @@ "mcp.status.changed" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25176,6 +25563,54 @@ }, "required": [ "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "mcp.resources.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "mcp.resources.changed" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", "type", "data" ], @@ -25192,6 +25627,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25201,36 +25639,6 @@ "permission.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25308,6 +25716,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25324,6 +25733,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25333,36 +25745,6 @@ "permission.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25404,6 +25786,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25507,6 +25890,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25516,36 +25902,6 @@ "question.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25596,6 +25952,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25618,6 +25975,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25627,36 +25987,6 @@ "question.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25696,6 +26026,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25712,6 +26043,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25721,36 +26055,6 @@ "question.rejected" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25783,6 +26087,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25799,6 +26104,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25808,36 +26116,6 @@ "session.error" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25900,6 +26178,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25926,43 +26205,6 @@ } ] }, - "durable": { - "anyOf": [ - { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, "location": { "anyOf": [ { @@ -26021,7 +26263,7 @@ "$ref": "#/components/schemas/session.updated" }, { - "$ref": "#/components/schemas/session.deleted" + "$ref": "#/components/schemas/session.deleted1" }, { "$ref": "#/components/schemas/message.updated" @@ -26036,115 +26278,136 @@ "$ref": "#/components/schemas/message.part.removed" }, { - "$ref": "#/components/schemas/session.next.agent.switched" + "$ref": "#/components/schemas/session.agent.selected" }, { - "$ref": "#/components/schemas/session.next.model.switched" + "$ref": "#/components/schemas/session.model.selected" }, { - "$ref": "#/components/schemas/session.next.moved" + "$ref": "#/components/schemas/session.moved" }, { - "$ref": "#/components/schemas/session.next.renamed" + "$ref": "#/components/schemas/session.renamed" }, { - "$ref": "#/components/schemas/session.next.forked" + "$ref": "#/components/schemas/session.usage.updated" }, { - "$ref": "#/components/schemas/session.next.prompted" + "$ref": "#/components/schemas/session.deleted" }, { - "$ref": "#/components/schemas/session.next.prompt.admitted" + "$ref": "#/components/schemas/session.forked" }, { - "$ref": "#/components/schemas/session.next.execution.settled" + "$ref": "#/components/schemas/session.prompt.promoted" }, { - "$ref": "#/components/schemas/session.next.instructions.updated" + "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.next.synthetic" + "$ref": "#/components/schemas/session.execution.started" }, { - "$ref": "#/components/schemas/session.next.skill.activated" + "$ref": "#/components/schemas/session.execution.succeeded" }, { - "$ref": "#/components/schemas/session.next.shell.started" + "$ref": "#/components/schemas/session.execution.failed" }, { - "$ref": "#/components/schemas/session.next.shell.ended" + "$ref": "#/components/schemas/session.execution.interrupted" }, { - "$ref": "#/components/schemas/session.next.step.started" + "$ref": "#/components/schemas/session.instructions.updated" }, { - "$ref": "#/components/schemas/session.next.step.ended" + "$ref": "#/components/schemas/session.synthetic" }, { - "$ref": "#/components/schemas/session.next.step.failed" + "$ref": "#/components/schemas/session.skill.activated" }, { - "$ref": "#/components/schemas/session.next.text.started" + "$ref": "#/components/schemas/session.shell.started" }, { - "$ref": "#/components/schemas/session.next.text.delta" + "$ref": "#/components/schemas/session.shell.ended" }, { - "$ref": "#/components/schemas/session.next.text.ended" + "$ref": "#/components/schemas/session.step.started" }, { - "$ref": "#/components/schemas/session.next.reasoning.started" + "$ref": "#/components/schemas/session.step.ended" }, { - "$ref": "#/components/schemas/session.next.reasoning.delta" + "$ref": "#/components/schemas/session.step.failed" }, { - "$ref": "#/components/schemas/session.next.reasoning.ended" + "$ref": "#/components/schemas/session.text.started" }, { - "$ref": "#/components/schemas/session.next.tool.input.started" + "$ref": "#/components/schemas/session.text.delta" }, { - "$ref": "#/components/schemas/session.next.tool.input.delta" + "$ref": "#/components/schemas/session.text.ended" }, { - "$ref": "#/components/schemas/session.next.tool.input.ended" + "$ref": "#/components/schemas/session.reasoning.started" }, { - "$ref": "#/components/schemas/session.next.tool.called" + "$ref": "#/components/schemas/session.reasoning.delta" }, { - "$ref": "#/components/schemas/session.next.tool.progress" + "$ref": "#/components/schemas/session.reasoning.ended" }, { - "$ref": "#/components/schemas/session.next.tool.success" + "$ref": "#/components/schemas/session.tool.input.started" }, { - "$ref": "#/components/schemas/session.next.tool.failed" + "$ref": "#/components/schemas/session.tool.input.delta" }, { - "$ref": "#/components/schemas/session.next.retried" + "$ref": "#/components/schemas/session.tool.input.ended" }, { - "$ref": "#/components/schemas/session.next.compaction.started" + "$ref": "#/components/schemas/session.tool.called" }, { - "$ref": "#/components/schemas/session.next.compaction.delta" + "$ref": "#/components/schemas/session.tool.progress" }, { - "$ref": "#/components/schemas/session.next.compaction.ended" + "$ref": "#/components/schemas/session.tool.success" }, { - "$ref": "#/components/schemas/session.next.revert.staged" + "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.next.revert.cleared" + "$ref": "#/components/schemas/session.retry.scheduled" }, { - "$ref": "#/components/schemas/session.next.revert.committed" + "$ref": "#/components/schemas/session.compaction.admitted" }, { - "$ref": "#/components/schemas/file.edited" + "$ref": "#/components/schemas/session.compaction.started" + }, + { + "$ref": "#/components/schemas/session.compaction.delta" + }, + { + "$ref": "#/components/schemas/session.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, + { + "$ref": "#/components/schemas/session.revert.staged" + }, + { + "$ref": "#/components/schemas/session.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.revert.committed" + }, + { + "$ref": "#/components/schemas/filesystem.changed" }, { "$ref": "#/components/schemas/reference.updated" @@ -26158,6 +26421,9 @@ { "$ref": "#/components/schemas/plugin.added" }, + { + "$ref": "#/components/schemas/plugin.updated" + }, { "$ref": "#/components/schemas/project.directories.updated" }, @@ -26165,10 +26431,10 @@ "$ref": "#/components/schemas/command.updated" }, { - "$ref": "#/components/schemas/skill.updated" + "$ref": "#/components/schemas/config.updated" }, { - "$ref": "#/components/schemas/file.watcher.updated" + "$ref": "#/components/schemas/skill.updated" }, { "$ref": "#/components/schemas/pty.created" @@ -26242,6 +26508,9 @@ { "$ref": "#/components/schemas/mcp.status.changed" }, + { + "$ref": "#/components/schemas/mcp.resources.changed" + }, { "$ref": "#/components/schemas/permission.asked" }, @@ -26272,68 +26541,6 @@ }, "contentMediaType": "application/json" }, - "EventLog.Hint": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.hint" - ] - }, - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "type", - "aggregateID", - "seq" - ], - "additionalProperties": false, - "description": "Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee." - }, - "EventLog.SweepRequired": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.sweep_required" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false, - "description": "Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe." - }, - "EventLog.Change": { - "anyOf": [ - { - "$ref": "#/components/schemas/EventLog.Hint" - }, - { - "$ref": "#/components/schemas/EventLog.SweepRequired" - } - ] - }, - "EventLog.ChangeStream": { - "type": "string", - "contentSchema": { - "$ref": "#/components/schemas/EventLog.Change" - }, - "contentMediaType": "application/json" - }, "PtyNotFoundError": { "type": "object", "properties": { @@ -26863,28 +27070,28 @@ "security": [], "tags": [ { - "name": "server.health" + "name": "health" }, { - "name": "server.location" + "name": "location" }, { - "name": "server.agent" + "name": "agent" }, { - "name": "plugins", + "name": "plugin", "description": "Experimental plugin routes." }, { - "name": "sessions", + "name": "session", "description": "Experimental session routes." }, { - "name": "messages", + "name": "session", "description": "Experimental message routes." }, { - "name": "models", + "name": "model", "description": "Experimental model routes." }, { @@ -26892,30 +27099,30 @@ "description": "Experimental one-shot generation routes." }, { - "name": "providers", + "name": "provider", "description": "Experimental provider routes." }, { - "name": "integrations", + "name": "integration", "description": "Integration discovery and authentication routes." }, { "name": "mcp", - "description": "MCP server status routes." + "description": "MCP server and resource routes." }, { - "name": "server.credential" + "name": "credential" }, { - "name": "projects", + "name": "project", "description": "Location-scoped project routes." }, { - "name": "forms", + "name": "form", "description": "Session form routes." }, { - "name": "permissions", + "name": "permission", "description": "Experimental permission routes." }, { @@ -26923,15 +27130,15 @@ "description": "Experimental location-scoped filesystem routes." }, { - "name": "commands", + "name": "command", "description": "Experimental command routes." }, { - "name": "skills", + "name": "skill", "description": "Experimental skill routes." }, { - "name": "events", + "name": "event", "description": "Experimental event stream routes." }, { @@ -26943,7 +27150,7 @@ "description": "Experimental location-scoped shell command routes." }, { - "name": "session questions", + "name": "question", "description": "Experimental session question routes." }, { @@ -26957,6 +27164,9 @@ { "name": "vcs", "description": "Location-scoped version control routes." + }, + { + "name": "debug" } ] } diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index db799e5894..608019da46 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -177,13 +177,13 @@ describe("OpenAPI.fromSpec", () => { const spec = await opencodeSpec() const result = OpenAPI.fromSpec({ spec, baseUrl }) - expect(result.skipped).toHaveLength(5) + expect(result.skipped).toHaveLength(4) expect(result.skipped).toContainEqual({ method: "GET", path: "/api/pty/{ptyID}/connect", reason: "WebSocket operations are not supported", }) - expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3) + expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2) expect(result.skipped).toContainEqual({ method: "GET", path: "/api/fs/read/*", @@ -210,11 +210,11 @@ describe("OpenAPI.fromSpec", () => { if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated") expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }") expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined() - expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined() + expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false) expect(toolAt(result.tools, "v2.session.log")).toBeUndefined() expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined() - expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined() + expect(toolAt(result.tools, "v2.pty.connect.token")).not.toBeUndefined() }) test("preserves operation path sanitization and collision handling", () => { diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 313b9c22cc..ce5f1e03a4 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -245,6 +245,12 @@ describe("promises at data boundaries", () => { expect(diagnostic.message).toContain("await tools.ns.tool(...)") }) + test("collection helpers do not let un-awaited promises cross the result boundary", async () => { + const diagnostic = await error(`return Array.from([Promise.resolve(1)])`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + }) + test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => { const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`) expect(diagnostic.kind).toBe("InvalidDataValue") diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 59262f3197..d5870dab8d 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -19,6 +19,28 @@ const error = async (code: string) => { return result.error } +describe("Number and Math", () => { + test("Math.random returns a number in [0, 1)", async () => { + expect(await value(`const n = Math.random(); return typeof n === "number" && n >= 0 && n < 1`)).toBe(true) + }) + + test("Number exposes native non-finite constants", async () => { + expect( + await value( + `return [Number.isNaN(Number.NaN), Number.POSITIVE_INFINITY === Infinity, Number.NEGATIVE_INFINITY === -Infinity]`, + ), + ).toEqual([true, true, true]) + }) + + test("Number valueOf returns its primitive receiver", async () => { + expect(await value(`return (42).valueOf()`)).toBe(42) + }) + + test("Number valueOf does not enable boxed numbers", async () => { + expect((await error(`return new Number(42)`)).kind).toBe("UnsupportedSyntax") + }) +}) + describe("Date", () => { test("Date.now() returns a number", async () => { expect(await value(`return typeof Date.now()`)).toBe("number") @@ -751,6 +773,43 @@ describe("sandbox values at intra-sandbox checkpoints", () => { ) }) + test("Object.values/entries preserve nested object identity", async () => { + expect( + await value(` + const child = { selected: false } + const rows = { a: child } + Object.values(rows)[0].selected = true + return child.selected + `), + ).toBe(true) + expect( + await value(` + const child = { selected: false } + const rows = { a: child } + Object.entries(rows)[0][1].selected = true + return child.selected + `), + ).toBe(true) + }) + + test("Object enumeration preserves promises and callable references", async () => { + expect( + await value(` + const pending = Promise.resolve(1) + const source = { pending } + return [Object.keys(source), Object.hasOwn(source, "pending"), await Object.values(source)[0], await Object.entries(source)[0][1]] + `), + ).toEqual([["pending"], true, 1, 1]) + expect(await value(`return Object.values({ max: Math.max })[0](1, 2)`)).toBe(2) + }) + + test("Object enumeration rejects invalid receivers and gives promises an await hint", async () => { + const diagnostic = await error(`return Object.keys(Promise.resolve({ a: 1 }))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("await") + expect((await error(`return Object.keys(Math)`)).kind).toBe("InvalidDataValue") + }) + test("Object.assign keeps Maps usable", async () => { expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe( 1, @@ -773,6 +832,53 @@ describe("sandbox values at intra-sandbox checkpoints", () => { expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5) }) + test("Array.from and Array.of preserve nested object identity", async () => { + expect( + await value(` + const child = { selected: false } + Array.from([child])[0].selected = true + return child.selected + `), + ).toBe(true) + expect( + await value(` + const child = { selected: false } + Array.of(child)[0].selected = true + return child.selected + `), + ).toBe(true) + }) + + test("Array.from and Array.of preserve promises and callable references", async () => { + expect( + await value(` + const pending = Promise.resolve(1) + return [await Array.from([pending])[0], await Array.of(pending)[0]] + `), + ).toEqual([1, 1]) + expect(await value(`return [Array.from([Math.max])[0](1, 2), Array.of(Math.max)[0](3, 4)]`)).toEqual([2, 4]) + }) + + test("Array.from preserves identity across supported collection shapes", async () => { + expect( + await value(` + const child = { selected: false } + const fromArrayLike = Array.from({ 0: child, length: 1 }) + const fromMap = Array.from(new Map([["child", child]])) + const fromSet = Array.from(new Set([child])) + fromArrayLike[0].selected = true + return [fromMap[0][1] === child, fromSet[0] === child, child.selected] + `), + ).toEqual([true, true, true]) + }) + + test("Array.from rejects invalid receivers and gives promises an await hint", async () => { + const diagnostic = await error(`return Array.from(Promise.resolve([1]))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("await") + expect((await error(`return Array.from(() => 1)`)).kind).toBe("InvalidDataValue") + }) + test("regexes stay callable through Object.values", async () => { expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true) }) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 2dd54123ed..8ba976399d 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -8,6 +8,8 @@ import { State } from "./state" export const ID = Agent.ID export type ID = typeof ID.Type +export const Name = Agent.Name +export type Name = Agent.Name export const defaultID = ID.make("build") export const Color = Agent.Color diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index f7474b244a..3330c77f4a 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -6,6 +6,7 @@ import type { JSONValue, LanguageModelV3, LanguageModelV3CallOptions, + LanguageModelV3FinishReason, LanguageModelV3FunctionTool, LanguageModelV3Message, LanguageModelV3Prompt, @@ -304,6 +305,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { const route: AnyRoute = { id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`, provider: ProviderID.make(info.providerID), + providerMetadataKey: optionKey, protocol: "ai-sdk", endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }), auth: Auth.none, @@ -416,7 +418,7 @@ function assistantPart(part: ContentPart): AssistantContent { case "media": return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }] case "reasoning": - return [{ type: "reasoning", text: part.text }] + return [{ type: "reasoning", text: part.text, providerOptions: providerOptions(part.providerMetadata) }] case "tool-call": return [ { @@ -425,6 +427,7 @@ function assistantPart(part: ContentPart): AssistantContent { toolName: part.name, input: part.input, providerExecuted: part.providerExecuted, + providerOptions: providerOptions(part.providerMetadata), }, ] case "tool-result": @@ -440,6 +443,7 @@ function toolResultPart(part: ContentPart): ToolResultContent[] { toolCallId: part.id, toolName: part.name, output: toolOutput(part.result), + providerOptions: providerOptions(part.providerMetadata), }, ] } @@ -624,8 +628,8 @@ function usage(input: Extract["us return Object.values(output).some((value) => value !== undefined) ? output : undefined } -function finishReason(value: unknown): FinishReason { - return Schema.is(FinishReason)(value) ? value : "unknown" +function finishReason(value: LanguageModelV3FinishReason): FinishReason { + return value.unified === "other" ? "unknown" : value.unified } function providerMetadata(value: unknown) { diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 3cfc799d72..1bb05e0fa1 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,6 +1,7 @@ export * as ConfigProviderPlugin from "./provider" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Money } from "@opencode-ai/schema/money" import { Effect, Stream } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" @@ -91,8 +92,8 @@ export const Plugin = define({ input: cost.input, output: cost.output, cache: { - read: cost.cache?.read ?? 0, - write: cost.cache?.write ?? 0, + read: cost.cache?.read ?? Money.USDPerMillionTokens.zero, + write: cost.cache?.write ?? Money.USDPerMillionTokens.zero, }, })) } diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index ec37182395..e2fa972806 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -1,6 +1,7 @@ export * as ConfigProvider from "./provider" import { Schema } from "effect" +import { Money } from "@opencode-ai/schema/money" import { ModelV2 } from "../model" const JsonRecord = Schema.Record(Schema.String, Schema.Json) @@ -17,8 +18,8 @@ export class Request extends Schema.Class("ConfigV2.Provider.Request")( }) {} class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ - read: Schema.Finite.pipe(Schema.optional), - write: Schema.Finite.pipe(Schema.optional), + read: Money.USDPerMillionTokens.pipe(Schema.optional), + write: Money.USDPerMillionTokens.pipe(Schema.optional), }) {} class Cost extends Schema.Class("ConfigV2.Model.Cost")({ @@ -26,8 +27,8 @@ class Cost extends Schema.Class("ConfigV2.Model.Cost")({ type: Schema.Literal("context"), size: Schema.Int, }).pipe(Schema.optional), - input: Schema.Finite, - output: Schema.Finite, + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, cache: Cache.pipe(Schema.optional), }) {} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 99ca206c1f..5b1735573d 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -48,5 +48,6 @@ export const migrations = ( import("./migration/20260705180000_rename_instructions"), import("./migration/20260706223930_add-session-fork"), import("./migration/20260707010146_durable_session_inbox"), + import("./migration/20260707120000_migrate_prelaunch_v2_state"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts b/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts new file mode 100644 index 0000000000..e921d40904 --- /dev/null +++ b/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts @@ -0,0 +1,227 @@ +import { sql } from "drizzle-orm" +import { Effect, Schema } from "effect" +import type { DatabaseMigration } from "../migration" + +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString) +const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown)) + +export default { + id: "20260707120000_migrate_prelaunch_v2_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run( + sql`DELETE FROM session_message WHERE type = 'compaction' AND json_extract(data, '$.status') = 'queued'`, + ) + const messages = yield* tx.all<{ id: string; type: string; data: string }>( + sql`SELECT id, type, data FROM session_message WHERE type IN ('skill', 'shell', 'assistant', 'compaction', 'synthetic')`, + ) + for (const row of messages) { + const data = object(decodeJson(row.data)) + yield* tx.run( + sql`UPDATE session_message SET data = ${JSON.stringify(messageData(row.type, data))} WHERE id = ${row.id}`, + ) + } + + yield* tx.run(sql`DELETE FROM event WHERE type = 'session.compaction.delta.1'`) + const events = yield* tx.all<{ id: string; aggregateID: string; seq: number; type: string; data: string }>(sql` + SELECT id, aggregate_id as aggregateID, seq, type, data + FROM event + WHERE type IN ( + 'session.skill.activated.1', + 'session.skill.activated.2', + 'session.compaction.started.1', + 'session.compaction.started.2', + 'session.compaction.ended.1', + 'session.compaction.failed.1', + 'session.compaction.failed.2', + 'session.revert.staged.1', + 'session.revert.staged.2' + ) + ORDER BY aggregate_id, seq + `) + const compactionReasons = new Map() + for (const row of events) { + const data = object(decodeJson(row.data)) + if (row.type.startsWith("session.compaction.ended.")) { + compactionReasons.delete(row.aggregateID) + continue + } + const event = eventData(row.type, data, compactionReasons.get(row.aggregateID)) + if (row.type.startsWith("session.compaction.started.")) + compactionReasons.set(row.aggregateID, event.data.reason === "auto" ? "auto" : "manual") + if (row.type.startsWith("session.compaction.failed.")) compactionReasons.delete(row.aggregateID) + yield* tx.run( + sql`UPDATE event SET type = ${event.type}, data = ${JSON.stringify(event.data)} WHERE id = ${row.id}`, + ) + } + }) + }, +} satisfies DatabaseMigration.Migration + +function messageData(type: string, data: Record) { + if (type === "skill") + return defined({ + metadata: data.metadata, + time: data.time, + skill: data.skill ?? data.id ?? data.name, + name: data.name, + text: data.text, + }) + if (type === "shell") { + const shell = object(data.shell) + return defined({ + metadata: data.metadata, + time: data.time, + shellID: data.shellID ?? shell.id, + command: data.command ?? shell.command, + status: data.status ?? shell.status, + exit: data.exit ?? shell.exit, + output: data.output, + }) + } + if (type === "assistant") + return defined({ + metadata: data.metadata, + time: data.time, + agent: data.agent, + model: data.model, + content: Array.isArray(data.content) ? data.content.map(assistantContent) : data.content, + snapshot: data.snapshot, + finish: data.finish, + cost: data.cost, + tokens: data.tokens, + error: data.error, + retry: data.retry, + }) + if (type === "compaction") { + if (data.status === "failed") + return defined({ + metadata: data.metadata, + time: data.time, + status: data.status, + reason: data.reason, + error: data.error ?? genericCompactionError, + }) + return defined({ + metadata: data.metadata, + time: data.time, + status: data.status, + reason: data.reason, + summary: data.summary, + recent: data.recent, + }) + } + if (type === "synthetic") + return defined({ metadata: data.metadata, time: data.time, text: data.text, description: data.description }) + const { sessionID: _, ...current } = data + return current +} + +function assistantContent(value: unknown) { + const content = object(value) + if (content.type === "text") return defined({ type: content.type, text: content.text }) + if (content.type === "reasoning") + return defined({ type: content.type, text: content.text, state: content.state, time: content.time }) + if (content.type !== "tool") return content + return defined({ + type: content.type, + id: content.id, + name: content.name, + executed: content.executed, + providerState: content.providerState, + providerResultState: content.providerResultState, + state: toolState(content.state), + time: content.time, + }) +} + +function toolState(value: unknown) { + const state = object(value) + if (state.status === "pending" || state.status === "streaming") + return defined({ status: "streaming", input: state.input }) + if (state.status === "running") + return defined({ status: state.status, input: state.input, structured: state.structured, content: state.content }) + if (state.status === "completed") + return defined({ + status: state.status, + input: state.input, + structured: state.structured, + content: state.content, + result: state.result, + }) + if (state.status === "error") + return defined({ + status: state.status, + input: state.input, + structured: state.structured, + content: state.content, + error: state.error, + result: state.result, + }) + return state +} + +function eventData(type: string, data: Record, compactionReason?: "auto" | "manual") { + if (type.startsWith("session.skill.activated.")) + return { + type: "session.skill.activated.1", + data: defined({ sessionID: data.sessionID, id: data.id ?? data.name, name: data.name, text: data.text }), + } + if (type.startsWith("session.compaction.started.")) + return { + type: "session.compaction.started.1", + data: defined({ + sessionID: data.sessionID, + reason: data.reason, + recent: data.recent ?? "", + inputID: data.inputID, + }), + } + if (type.startsWith("session.compaction.failed.")) + return { + type: "session.compaction.failed.1", + data: defined({ + sessionID: data.sessionID, + reason: data.reason ?? compactionReason ?? "manual", + error: data.error ?? genericCompactionError, + inputID: data.inputID, + }), + } + const revert = object(data.revert) + return { + type: "session.revert.staged.1", + data: defined({ + sessionID: data.sessionID, + revert: defined({ + messageID: revert.messageID, + partID: revert.partID, + snapshot: revert.snapshot, + files: Array.isArray(revert.files) + ? revert.files.map((value) => { + const file = object(value) + return defined({ + file: file.file ?? file.path, + patch: file.patch, + additions: file.additions, + deletions: file.deletions, + status: file.status, + }) + }) + : undefined, + }), + }), + } +} + +const genericCompactionError = { + type: "compaction.failed", + message: "Compaction failed before recording an error", +} + +function object(value: unknown): Record { + return isObject(value) ? value : {} +} + +function defined(value: Record) { + return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined)) +} diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index baac20d200..7981bb26de 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -200,6 +200,6 @@ export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.no // TODO: Publish watcher/file-edit events after V2 watcher integration exists. // TODO: Add snapshots / undo after V2 snapshot design exists. // TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists. -// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits. +// TODO: Design multi-file transactions / rollback if patch needs atomic edits. // Until then, edits are sequential and report partial application. // TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement. diff --git a/packages/core/src/file.ts b/packages/core/src/file.ts index 87745c01ee..0f48d9a04c 100644 --- a/packages/core/src/file.ts +++ b/packages/core/src/file.ts @@ -1,6 +1,6 @@ export * as File from "./file" -import { Revert } from "@opencode-ai/schema/revert" +import { FileDiff } from "@opencode-ai/schema/file-diff" -export const Diff = Revert.FileDiff +export const Diff = FileDiff.Info export type Diff = typeof Diff.Type diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts index 613b43b5f8..7765a396e8 100644 --- a/packages/core/src/filesystem/location-watcher.ts +++ b/packages/core/src/filesystem/location-watcher.ts @@ -46,7 +46,7 @@ const layer = Layer.effect( .flatMap((item) => item.info.watcher?.ignore ?? []) const home = path.resolve(location.directory) === path.resolve(os.homedir()) - if (!home) { + if (!home && location.vcs) { yield* watcher .subscribe({ path: location.directory, diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 19dcde8961..eea11c95f7 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -606,7 +606,7 @@ const layer = Layer.effect( file, ])).text return { - path: file, + file, status, additions: binary ? 0 : Number(stats[0] ?? 0), deletions: binary ? 0 : Number(stats[1] ?? 0), diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index bedeb04976..943b0b34cf 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -151,31 +151,44 @@ export type LocationError = LayerNode.Error export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], ): Layer.Layer { + // Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded + // payloads omit optional keys) and `{ directory, workspaceID: undefined }` are + // different RcMap keys. The RcMap caches by the raw key before the build + // callback runs, so canonicalize at the map boundary to the key-present shape. + const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }) return Layer.effect( LocationServiceMap.Service, - LayerMap.make( - (ref: Location.Ref) => { - const startedAt = performance.now() - const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) - // Apply replacements during hoist, not afterward: replacements can - // introduce new tagged dependencies (Location.boundNode depends on - // Project), and the hoist walk is the only pass that can still slice - // those back out. - const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) + Effect.map( + LayerMap.make( + (ref: Location.Ref) => { + const startedAt = performance.now() + const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) + // Apply replacements during hoist, not afterward: replacements can + // introduce new tagged dependencies (Location.boundNode depends on + // Project), and the hoist walk is the only pass that can still slice + // those back out. + const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) - return LayerNode.compile(location.node).pipe( - Layer.fresh, - Layer.tap(() => - Effect.logInfo("location services booted", { - directory: ref.directory, - workspaceID: ref.workspaceID, - durationMs: Math.round(performance.now() - startedAt), - }), - ), - Layer.provide(LayerNode.compile(location.hoisted)), - ) - }, - { idleTimeToLive: "60 minutes" }, + return LayerNode.compile(location.node).pipe( + Layer.fresh, + Layer.tap(() => + Effect.logInfo("location services booted", { + directory: ref.directory, + workspaceID: ref.workspaceID, + durationMs: Math.round(performance.now() - startedAt), + }), + ), + Layer.provide(LayerNode.compile(location.hoisted)), + ) + }, + { idleTimeToLive: "60 minutes" }, + ), + (inner) => ({ + ...inner, + get: (ref: Location.Ref) => inner.get(canonical(ref)), + contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)), + invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)), + }), ), ) } diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 0b10ecd7e4..5f48cba649 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -3,7 +3,7 @@ export * as MCPClient from "./client" import path from "node:path" import { execFile } from "node:child_process" import { pathToFileURL } from "node:url" -import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" @@ -21,6 +21,7 @@ import { ListToolsResultSchema, PromptListChangedNotificationSchema, PromptSchema, + ResourceListChangedNotificationSchema, type LoggingMessageNotification, LoggingMessageNotificationSchema, ToolListChangedNotificationSchema, @@ -68,11 +69,13 @@ export interface ToolDefinition { export interface PromptDefinition { readonly name: string readonly description: string | undefined - readonly arguments: ReadonlyArray<{ - readonly name: string - readonly description: string | undefined - readonly required: boolean | undefined - }> | undefined + readonly arguments: + | ReadonlyArray<{ + readonly name: string + readonly description: string | undefined + readonly required: boolean | undefined + }> + | undefined } export interface PromptMessage { @@ -84,6 +87,28 @@ export interface PromptResult { readonly messages: ReadonlyArray } +export interface ResourceDefinition { + readonly name: string + readonly uri: string + readonly description: string | undefined + readonly mimeType: string | undefined +} + +export interface ResourceTemplateDefinition { + readonly name: string + readonly uriTemplate: string + readonly description: string | undefined + readonly mimeType: string | undefined +} + +export type ResourceContentPart = + | { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType: string | undefined } + | { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType: string | undefined } + +export interface ReadResourceResult { + readonly contents: ReadonlyArray +} + export type CallToolContent = | { readonly type: "text"; readonly text: string } | { readonly type: "media"; readonly data: string; readonly mimeType: string } @@ -124,6 +149,12 @@ export interface Connection { readonly tools: () => Effect.Effect /** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */ readonly prompts: () => Effect.Effect + /** Lists the server's resources; returns [] when the server doesn't advertise resource support. */ + readonly resources: () => Effect.Effect + /** Lists the server's resource templates; returns [] when the server doesn't advertise resource support. */ + readonly resourceTemplates: () => Effect.Effect + /** Reads one resource; returns undefined when the server doesn't advertise resource support. */ + readonly readResource: (input: { readonly uri: string }) => Effect.Effect /** Invokes a prompt on the server. Interruption aborts the in-flight request. */ readonly prompt: (input: { readonly name: string @@ -141,6 +172,8 @@ export interface Connection { readonly onToolsChanged: (callback: () => void) => void /** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */ readonly onPromptsChanged: (callback: () => void) => void + /** Registers a callback fired when the server announces its resource catalog changed. */ + readonly onResourcesChanged: (callback: () => void) => void } /** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */ @@ -168,7 +201,8 @@ export const connect = Effect.fnUntraced(function* ( }, }) } - if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) + if (!URL.canParse(config.url)) + return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) return new StreamableHTTPClientTransport(new URL(config.url), { requestInit: config.headers ? { headers: config.headers } : undefined, authProvider, @@ -202,10 +236,7 @@ export const connect = Effect.fnUntraced(function* ( }).pipe(Effect.exit) if (Exit.isSuccess(exit)) { yield* Effect.addFinalizer(() => - cleanupStdioDescendants(transport).pipe( - Effect.andThen(Effect.promise(() => client.close())), - Effect.ignore, - ), + cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore), ) const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT @@ -257,7 +288,9 @@ export const connect = Effect.fnUntraced(function* ( ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( - Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })), + Effect.tapError((error) => + Effect.logWarning("failed to list MCP prompts", { server, error: error.message }), + ), ) return prompts.map((prompt) => ({ name: prompt.name, @@ -269,6 +302,74 @@ export const connect = Effect.fnUntraced(function* ( })), })) }), + resources: () => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.resources) return [] + const resources = yield* Effect.tryPromise({ + try: () => + paginate( + (cursor) => + client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }), + (result) => result.resources, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to list MCP resources", { server, error: error.message }), + ), + ) + return resources.map((resource) => ({ + name: resource.name, + uri: resource.uri, + description: resource.description, + mimeType: resource.mimeType, + })) + }), + resourceTemplates: () => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.resources) return [] + const templates = yield* Effect.tryPromise({ + try: () => + paginate( + (cursor) => + client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { + timeout: catalogTimeout, + }), + (result) => result.resourceTemplates, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to list MCP resource templates", { server, error: error.message }), + ), + ) + return templates.map((template) => ({ + name: template.name, + uriTemplate: template.uriTemplate, + description: template.description, + mimeType: template.mimeType, + })) + }), + readResource: (input) => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.resources) return undefined + const result = yield* Effect.tryPromise({ + try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to read MCP resource", { server, uri: input.uri, error: error.message }), + ), + ) + return { + contents: result.contents.map( + (part): ResourceContentPart => + "text" in part + ? { type: "text", uri: part.uri, text: part.text, mimeType: part.mimeType } + : { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType }, + ), + } + }), prompt: (input) => Effect.tryPromise({ try: (signal) => @@ -328,13 +429,14 @@ export const connect = Effect.fnUntraced(function* ( if (!client.getServerCapabilities()?.prompts?.listChanged) return client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback()) }, + onResourcesChanged: (callback) => { + if (!client.getServerCapabilities()?.resources?.listChanged) return + client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => callback()) + }, } satisfies Connection } - yield* cleanupStdioDescendants(transport).pipe( - Effect.andThen(Effect.promise(() => transport.close())), - Effect.ignore, - ) + yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore) const error = Cause.squash(exit.cause) if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server }) return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) }) diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 1ce8c3d3ec..7a99c15982 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -83,48 +83,16 @@ export class PromptResult extends Schema.Class("MCP.PromptResult") messages: Schema.Array(PromptMessage), }) {} -export class Resource extends Schema.Class("MCP.Resource")({ - server: ServerName, - name: Schema.String, - uri: Schema.String, - description: Schema.String.pipe(Schema.optional), - mimeType: Schema.String.pipe(Schema.optional), -}) {} - -export class ResourceTemplate extends Schema.Class("MCP.ResourceTemplate")({ - server: ServerName, - name: Schema.String, - uriTemplate: Schema.String, - description: Schema.String.pipe(Schema.optional), - mimeType: Schema.String.pipe(Schema.optional), -}) {} - -export class ResourceCatalog extends Schema.Class("MCP.ResourceCatalog")({ - resources: Schema.Array(Resource), - templates: Schema.Array(ResourceTemplate), -}) {} - -export const ResourceContentPart = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("text"), - uri: Schema.String, - text: Schema.String, - mimeType: Schema.String.pipe(Schema.optional), - }), - Schema.Struct({ - type: Schema.Literal("blob"), - uri: Schema.String, - blob: Schema.String, - mimeType: Schema.String.pipe(Schema.optional), - }), -]).pipe(Schema.toTaggedUnion("type")) -export type ResourceContentPart = typeof ResourceContentPart.Type - -export class ResourceContent extends Schema.Class("MCP.ResourceContent")({ - server: ServerName, - uri: Schema.String, - contents: Schema.Array(ResourceContentPart), -}) {} +export const Resource = Mcp.Resource +export type Resource = Mcp.Resource +export const ResourceTemplate = Mcp.ResourceTemplate +export type ResourceTemplate = Mcp.ResourceTemplate +export const ResourceCatalog = Mcp.ResourceCatalog +export type ResourceCatalog = Mcp.ResourceCatalog +export const ResourceContentPart = Mcp.ResourceContentPart +export type ResourceContentPart = Mcp.ResourceContentPart +export const ResourceContent = Mcp.ResourceContent +export type ResourceContent = Mcp.ResourceContent export class NotFoundError extends Schema.TaggedErrorClass()("MCP.NotFoundError", { server: ServerName, @@ -415,6 +383,24 @@ export const layer = Layer.effect( ), }) + const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) => + Resource.make({ + server, + name: def.name, + uri: def.uri, + description: def.description, + mimeType: def.mimeType, + }) + + const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) => + ResourceTemplate.make({ + server, + name: def.name, + uriTemplate: def.uriTemplate, + description: def.description, + mimeType: def.mimeType, + }) + const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => connection.tools().pipe( Effect.map((defs) => { @@ -443,6 +429,7 @@ export const layer = Layer.effect( entry.prompts = undefined entry.status = { status: "failed", error: "Connection closed" } fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) + fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)) fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)) fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) }) @@ -458,6 +445,10 @@ export const layer = Layer.effect( connection.onPromptsChanged(() => { fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore)) }) + connection.onResourcesChanged(() => { + if (entry.client !== connection) return + fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)) + }) } const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { @@ -501,6 +492,7 @@ export const layer = Layer.effect( // after the initial registration sweep and emits no list-changed notification would otherwise // stay invisible to the model. yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) + yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore)) return @@ -557,11 +549,6 @@ export const layer = Layer.effect( concurrency: "unbounded", discard: true, }) - const gate = Effect.fnUntraced(function* (server: ServerName | string) { - const target = yield* requireServer(server) - yield* Deferred.await(target.entry.startup) - }) - return Service.of({ servers: Effect.fn("MCP.servers")(function* () { const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b)) @@ -637,11 +624,54 @@ export const layer = Layer.effect( }), resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { yield* whenAllReady - return new ResourceCatalog({ resources: [], templates: [] }) + const catalogs = yield* Effect.forEach( + Array.from(runtime), + ([name, entry]) => { + if (!entry.client) return Effect.succeed({ resources: [], templates: [] }) + return Effect.all( + { + resources: entry.client.resources().pipe(Effect.catch(() => Effect.succeed([]))), + templates: entry.client.resourceTemplates().pipe(Effect.catch(() => Effect.succeed([]))), + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map((catalog) => ({ + resources: catalog.resources.map((def) => toResource(name, def)), + templates: catalog.templates.map((def) => toResourceTemplate(name, def)), + })), + ) + }, + { concurrency: "unbounded" }, + ) + return ResourceCatalog.make({ + resources: catalogs + .flatMap((catalog) => catalog.resources) + .toSorted( + (a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri), + ), + templates: catalogs + .flatMap((catalog) => catalog.templates) + .toSorted( + (a, b) => + a.server.localeCompare(b.server) || + a.name.localeCompare(b.name) || + a.uriTemplate.localeCompare(b.uriTemplate), + ), + }) }), readResource: Effect.fn("MCP.readResource")(function* (input) { - yield* gate(input.server) - return undefined + const target = yield* requireServer(input.server) + yield* Deferred.await(target.entry.startup) + if (!target.entry.client) return undefined + const result = yield* target.entry.client + .readResource({ uri: input.uri }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!result) return undefined + return ResourceContent.make({ + server: target.name, + uri: input.uri, + contents: result.contents, + }) }), }) }), diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index a88aebfe07..faeee213fb 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -2,6 +2,7 @@ import path from "path" import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" +import { Money } from "@opencode-ai/schema/money" import { Global } from "./global" import { Flag } from "./flag/flag" import { Flock } from "./util/flock" @@ -18,10 +19,10 @@ export type CatalogModelStatus = typeof CatalogModelStatus.Type const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` const CostTier = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - cache_read: Schema.optional(Schema.Finite), - cache_write: Schema.optional(Schema.Finite), + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, + cache_read: Schema.optional(Money.USDPerMillionTokens), + cache_write: Schema.optional(Money.USDPerMillionTokens), tier: Schema.Struct({ type: Schema.Literal("context"), size: Schema.Finite, @@ -29,17 +30,17 @@ const CostTier = Schema.Struct({ }) const Cost = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - cache_read: Schema.optional(Schema.Finite), - cache_write: Schema.optional(Schema.Finite), + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, + cache_read: Schema.optional(Money.USDPerMillionTokens), + cache_write: Schema.optional(Money.USDPerMillionTokens), tiers: Schema.optional(Schema.Array(CostTier)), context_over_200k: Schema.optional( Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - cache_read: Schema.optional(Schema.Finite), - cache_write: Schema.optional(Schema.Finite), + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, + cache_read: Schema.optional(Money.USDPerMillionTokens), + cache_write: Schema.optional(Money.USDPerMillionTokens), }), ), }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 907920c10e..4e0858fd16 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,6 +1,6 @@ export * as PluginV2 from "./plugin" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event, ID, type Info } from "@opencode-ai/schema/plugin" import { makeLocationNode } from "./effect/app-node" import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" @@ -18,6 +18,7 @@ import { SkillV2 } from "./skill" import { State } from "./state" import { ToolRegistry } from "./tool/registry" import { ToolHooks } from "./tool/hooks" +import { PluginHooks } from "./plugin/hooks" export interface Interface { readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect @@ -57,12 +58,13 @@ const layer = Layer.effect( generation.length === definitions.length && generation.every( (plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version, - ) + ) && + definitions.every((definition) => active.has(definition.id)) ) { return } generation = undefined - const exit = yield* State.batch( + yield* State.batch( Effect.gen(function* () { const scopes = Array.from(active.values()).toReversed() active.clear() @@ -81,13 +83,17 @@ const layer = Layer.effect( Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.exit, ) - if (Exit.isFailure(loaded)) return loaded + if (Exit.isFailure(loaded)) { + yield* Effect.logWarning("failed to load plugin", { + "plugin.id": definition.id, + cause: loaded.cause, + }) + continue + } active.set(definition.id, child) } - return Exit.void }), ) - if (Exit.isFailure(exit)) return yield* exit generation = definitions.map((definition) => ({ id: definition.id, ...(definition.version === undefined ? {} : { version: definition.version }), @@ -131,6 +137,7 @@ export const node = makeLocationNode({ SkillV2.node, ToolRegistry.toolsNode, ToolHooks.node, + PluginHooks.node, PluginRuntime.node, ], }) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 7549089bf3..e6e03a4f15 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -125,6 +125,7 @@ export const Plugin = define({ yield* ctx.agent.transform((draft) => { draft.update(AgentV2.defaultID, (item) => { + item.name = AgentV2.Name.make("Build") item.description = "The default agent. Executes tools based on configured permissions." item.mode = "primary" item.permissions.push( @@ -136,6 +137,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("plan"), (item) => { + item.name = AgentV2.Name.make("Plan") item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( @@ -155,6 +157,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("general"), (item) => { + item.name = AgentV2.Name.make("General") item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" @@ -167,6 +170,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("explore"), (item) => { + item.name = AgentV2.Name.make("Explore") item.description = 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' item.system = PROMPT_EXPLORE @@ -189,6 +193,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("compaction"), (item) => { + item.name = AgentV2.Name.make("Compaction") item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION @@ -196,6 +201,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("title"), (item) => { + item.name = AgentV2.Name.make("Title") item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE @@ -203,6 +209,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("summary"), (item) => { + item.name = AgentV2.Name.make("Summary") item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts new file mode 100644 index 0000000000..4ac437d392 --- /dev/null +++ b/packages/core/src/plugin/hooks.ts @@ -0,0 +1,67 @@ +export * as PluginHooks from "./hooks" + +import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" +import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" +import { Context, Effect, Layer, Scope } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { State } from "../state" + +export interface Domains { + readonly aisdk: AISDKHooks + readonly session: SessionHooks + readonly tool: ToolHooks +} + +type Callback = (event: Event) => Effect.Effect + +export interface Interface { + readonly register: ( + domain: Domain, + name: Name, + callback: Callback, + ) => Effect.Effect + readonly trigger: ( + domain: Domain, + name: Name, + event: Domains[Domain][Name], + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/PluginHooks") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const callbacks = new Map() + const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}` + + const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) { + const scope = yield* Scope.Scope + const id = key(domain, name) + let active = true + callbacks.set(id, [...(callbacks.get(id) ?? []), callback]) + const dispose = Effect.sync(() => { + if (!active) return + active = false + const next = (callbacks.get(id) ?? []).filter((item) => item !== callback) + if (next.length === 0) callbacks.delete(id) + else callbacks.set(id, next) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }) + + const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) { + for (const callback of callbacks.get(key(domain, name)) ?? []) { + const result: Effect.Effect = Reflect.apply(callback, undefined, [event]) + yield* result + } + return event + }) + + return Service.of({ register, trigger }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index a7563a5683..dbb987a0b2 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,7 @@ export * as PluginHost from "./host" -import type { IntegrationDefinition, IntegrationMethodRegistration, PluginContext } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationDefinition, IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Effect, Schema, Stream } from "effect" @@ -23,6 +24,7 @@ import { Tool } from "../tool/tool" import { Tools } from "../tool/tools" import { ToolHooks } from "../tool/hooks" import { WorkspaceV2 } from "../workspace" +import { PluginHooks } from "./hooks" const mutable = (value: T) => value as DeepMutable export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { @@ -37,6 +39,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int const skill = yield* SkillV2.Service const tools = yield* Tools.Service const toolHooks = yield* ToolHooks.Service + const hooks = yield* PluginHooks.Service const runtime = yield* PluginRuntime.Service const locationInfo = () => new Location.Info({ @@ -44,7 +47,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int workspaceID: location.workspaceID, project: location.project, }) - const locationRef = (input?: Parameters[0]) => + const locationRef = (input?: Parameters[0]) => input?.location === undefined ? undefined : Location.Ref.make({ @@ -80,32 +83,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), }, aisdk: { - sdk: (callback) => - aisdk.hook.sdk((event) => { + hook: (name, callback) => { + if (name === "sdk") { + return aisdk.hook.sdk((event) => { + const output = { + model: mutable(event.model), + package: event.package, + options: event.options, + sdk: event.sdk, + } + return Reflect.apply(callback, undefined, [output]).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }) + } + return aisdk.hook.language((event) => { const output = { model: mutable(event.model), - package: event.package, options: event.options, sdk: event.sdk, - } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), - ) - }), - language: (callback) => - aisdk.hook.language((event) => { - const output = { - model: mutable(event.model), - sdk: event.sdk, - options: event.options, language: event.language, } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + return Reflect.apply(callback, undefined, [output]).pipe( Effect.tap(() => Effect.sync(() => (event.language = output.language))), ) - }), + }) + }, }, catalog: { provider: { @@ -165,25 +168,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int integration: { list: () => response(integration.list()), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), - connectKey: (input) => - integration.connection.key({ - integrationID: Integration.ID.make(input.integrationID), - key: input.key, - label: input.label, - }), - connectOauth: (input) => - response( - integration.connection.oauth({ + connect: { + key: (input) => + integration.connection.key({ integrationID: Integration.ID.make(input.integrationID), - methodID: Integration.MethodID.make(input.methodID), - inputs: input.inputs, + key: input.key, label: input.label, }), - ), - attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), - attemptComplete: (input) => - integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), - attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), + oauth: (input) => + response( + integration.connection.oauth({ + integrationID: Integration.ID.make(input.integrationID), + methodID: Integration.MethodID.make(input.methodID), + inputs: input.inputs, + label: input.label, + }), + ), + }, + attempt: { + status: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), + complete: (input) => + integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), + cancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), + }, reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), @@ -254,11 +261,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int registrations, (registration) => tools.register({ [registration.name]: registration.tool }, registration.options), { discard: true }, - ) + ).pipe(Effect.orDie) + return { dispose: Effect.void } }), - execute: { - before: (callback) => - toolHooks.hook.before((event) => { + hook: (name, callback) => { + if (name === "execute.before") { + return toolHooks.hook.before((event) => { const output = { tool: event.tool, sessionID: event.sessionID, @@ -267,38 +275,37 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int toolCallID: event.toolCallID, input: event.input, } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + return Reflect.apply(callback, undefined, [output]).pipe( Effect.tap(() => Effect.sync(() => (event.input = output.input))), ) - }), - after: (callback) => - toolHooks.hook.after((event) => { - const output = { - tool: event.tool, - sessionID: event.sessionID, - agent: event.agent, - assistantMessageID: event.assistantMessageID, - toolCallID: event.toolCallID, - input: event.input, - result: event.result, - output: event.output, - outputPaths: event.outputPaths, - } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => - Effect.sync(() => { - event.result = output.result - event.output = output.output - event.outputPaths = output.outputPaths - }), - ), - ) - }), + }) + } + return toolHooks.hook.after((event) => { + const output = { + tool: event.tool, + sessionID: event.sessionID, + agent: event.agent, + assistantMessageID: event.assistantMessageID, + toolCallID: event.toolCallID, + input: event.input, + result: event.result, + output: event.output, + outputPaths: event.outputPaths, + } + return Reflect.apply(callback, undefined, [output]).pipe( + Effect.tap(() => + Effect.sync(() => { + event.result = output.result + event.output = output.output + event.outputPaths = output.outputPaths + }), + ), + ) + }) }, }, session: { + hook: (name, callback) => hooks.register("session", name, callback), create: (input) => runtime.session.create({ id: input?.id, @@ -312,7 +319,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int command: runtime.session.command, interrupt: (input) => runtime.session.interrupt(input.sessionID), }, - } satisfies PluginContext + } satisfies Plugin.Context }) function registerIntegration(draft: Integration.Draft, definition: IntegrationDefinition) { diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index b18caa5efa..4356d7b57e 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,6 +1,6 @@ export * as PluginInternal from "./internal" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Context, Effect, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { AgentV2 } from "../agent" @@ -32,7 +32,7 @@ import { SessionInstructions } from "../session/instructions" import { SessionTodo } from "../session/todo" import { Shell } from "../shell" import { SkillV2 } from "../skill" -import { ApplyPatchTool } from "../tool/apply-patch" +import { PatchTool } from "../tool/patch" import { EditTool } from "../tool/edit" import { GlobTool } from "../tool/glob" import { GrepTool } from "../tool/grep" @@ -130,7 +130,7 @@ const pre = [ ModelsDevPlugin, ...ProviderPlugins, ...SearchPlugins, - ApplyPatchTool.Plugin, + PatchTool.Plugin, EditTool.Plugin, GlobTool.Plugin, GrepTool.Plugin, diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 67cf0069dc..ef59535e81 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,5 +1,6 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" +import { Money } from "@opencode-ai/schema/money" +import type { ModelInfo } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" import { ModelV2 } from "../model" @@ -11,13 +12,13 @@ function released(date: string) { return Number.isFinite(time) ? time : 0 } -function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { +function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] { const base = { - input: input?.input ?? 0, - output: input?.output ?? 0, + input: input?.input ?? Money.USDPerMillionTokens.zero, + output: input?.output ?? Money.USDPerMillionTokens.zero, cache: { - read: input?.cache_read ?? 0, - write: input?.cache_write ?? 0, + read: input?.cache_read ?? Money.USDPerMillionTokens.zero, + write: input?.cache_write ?? Money.USDPerMillionTokens.zero, }, } return [ @@ -27,8 +28,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { input: item.input, output: item.output, cache: { - read: item.cache_read ?? 0, - write: item.cache_write ?? 0, + read: item.cache_read ?? Money.USDPerMillionTokens.zero, + write: item.cache_write ?? Money.USDPerMillionTokens.zero, }, })) ?? []), ...(input?.context_over_200k @@ -41,8 +42,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { input: input.context_over_200k.input, output: input.context_over_200k.output, cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero, + write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero, }, }, ] @@ -50,13 +51,13 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { ] } -function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) { +function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] | undefined) { if (!override) return base const next = cost(override) const [baseDefault, ...baseTiers] = base const [nextDefault, ...nextTiers] = next - const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` - const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({ + const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({ ...left, ...right, tier: right.tier ?? left.tier, @@ -67,12 +68,25 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] const current = tiers.get(tierKey(item)) tiers.set(tierKey(item), current ? merge(current, item) : item) } - return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] + return [ + merge( + baseDefault ?? { + input: Money.USDPerMillionTokens.zero, + output: Money.USDPerMillionTokens.zero, + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + nextDefault, + ), + ...tiers.values(), + ] } const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] -function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable { +function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable { const npm = model.provider?.npm ?? provider.npm const options = model.reasoning_options ?? [] const effort = options.find((option) => option.type === "effort") @@ -117,7 +131,7 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2. function budgetVariants( npm: string | undefined, option: Extract[number], { type: "budget_tokens" }>, -): NonNullable { +): NonNullable { const max = option.max const high = option.max === undefined @@ -146,7 +160,7 @@ function modeName(model: ModelsDev.Model, mode: string) { return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } -function mergeVariants(model: ModelV2Info, next: NonNullable) { +function mergeVariants(model: ModelInfo, next: NonNullable) { const variants = model.variants ?? [] const existing = new Map(variants.map((variant) => [variant.id, variant])) const nextIDs = new Set(next.map((variant) => variant.id)) @@ -157,13 +171,13 @@ function mergeVariants(model: ModelV2Info, next: NonNullable["modes"]>[string]["provider"] - readonly variants?: NonNullable + readonly variants?: NonNullable } = {}, ) { draft.name = input.name ?? model.name diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index e2d14d641b..a0f152ebb6 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,11 +1,13 @@ export * as PluginPromise from "./promise" -import { define } from "@opencode-ai/plugin/v2/effect" -import type { IntegrationDefinition, Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationDefinition } from "@opencode-ai/plugin/v2/integration" import { Effect, Scope, Stream } from "effect" type HostRegistration = { readonly dispose: Effect.Effect } type Registration = { readonly dispose: () => Promise } +type PromisePlugin = import("@opencode-ai/plugin/v2/plugin").Plugin +type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context /** * Adapts a Promise plugin into an Effect plugin so the existing Effect-only @@ -16,8 +18,8 @@ type Registration = { readonly dispose: () => Promise } * preserves boot-time batching, so Promise-plugin transforms still coalesce * into one reload per domain. */ -export function fromPromise(plugin: Plugin) { - return define({ +export function fromPromise(plugin: PromisePlugin) { + return Plugin.define({ id: plugin.id, effect: (host) => Effect.gen(function* () { @@ -43,7 +45,7 @@ export function fromPromise(plugin: Plugin) { }), ) - const context2: PluginContext = { + const context2: PromisePluginContext = { options: host.options, agent: { list: (input) => run(host.agent.list(input)), @@ -51,10 +53,8 @@ export function fromPromise(plugin: Plugin) { reload: () => run(host.agent.reload()), }, aisdk: { - sdk: (callback) => - register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), - language: (callback) => - register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), + hook: (name, callback) => + register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, catalog: { provider: { @@ -79,11 +79,15 @@ export function fromPromise(plugin: Plugin) { integration: { list: (input) => run(host.integration.list(input)), get: (input) => run(host.integration.get(input)), - connectKey: (input) => run(host.integration.connectKey(input)), - connectOauth: (input) => run(host.integration.connectOauth(input)), - attemptStatus: (input) => run(host.integration.attemptStatus(input)), - attemptComplete: (input) => run(host.integration.attemptComplete(input)), - attemptCancel: (input) => run(host.integration.attemptCancel(input)), + connect: { + key: (input) => run(host.integration.connect.key(input)), + oauth: (input) => run(host.integration.connect.oauth(input)), + }, + attempt: { + status: (input) => run(host.integration.attempt.status(input)), + complete: (input) => run(host.integration.attempt.complete(input)), + cancel: (input) => run(host.integration.attempt.cancel(input)), + }, register: (definition) => register(host.integration.register(adaptIntegration(definition))), transform: transform(host.integration), reload: () => run(host.integration.reload()), @@ -105,12 +109,19 @@ export function fromPromise(plugin: Plugin) { transform: transform(host.skill), reload: () => run(host.skill.reload()), }, + tool: { + transform: transform(host.tool), + hook: (name, callback) => + register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), + }, session: { create: (input) => run(host.session.create(input)), get: (input) => run(host.session.get(input)), prompt: (input) => run(host.session.prompt(input)), command: (input) => run(host.session.command(input)), interrupt: (input) => run(host.session.interrupt(input)), + hook: (name, callback) => + register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, } diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index 607e565d4a..ea37b00453 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const AlibabaPlugin = define({ id: "opencode.provider.alibaba", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 5bbe953f80..ecc137bbde 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -75,7 +75,8 @@ export const AmazonBedrockPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } @@ -108,7 +109,8 @@ export const AmazonBedrockPlugin = define({ evt.sdk = mod.createAmazonBedrock(options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if ( diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 4c5938e51f..bbf0d56dfb 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -17,7 +17,8 @@ export const AnthropicPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 65a40c023b..f50bbdb1ba 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -26,7 +26,8 @@ export const AzurePlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { @@ -44,7 +45,8 @@ export const AzurePlugin = define({ evt.sdk = mod.createAzure(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage( @@ -75,7 +77,8 @@ export const AzureCognitiveServicesPlugin = define({ }) } }) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage( diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 42fea46550..13614d6be0 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -14,7 +14,8 @@ export const CerebrasPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index afab0c36df..0602549614 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -6,7 +6,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CloudflareAIGatewayPlugin = define({ id: "opencode.provider.cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 9b4558b5d1..c3aa50fbb4 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -19,7 +19,8 @@ export const CloudflareWorkersAIPlugin = define({ if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) } }) }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -35,7 +36,8 @@ export const CloudflareWorkersAIPlugin = define({ ) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 8b0831604a..9284defcb1 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CoherePlugin = define({ id: "opencode.provider.cohere", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index e8316012ad..f4ddf97859 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const DeepInfraPlugin = define({ id: "opencode.provider.deepinfra", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index 2e51674ba0..ae20d0d020 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -7,7 +7,8 @@ export const DynamicProviderPlugin = define({ id: "opencode.provider.dynamic", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.sdk) return diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 07249391a0..b67c0ef179 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GatewayPlugin = define({ id: "opencode.provider.gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index ab9364260c..139e6efd2d 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -23,14 +23,16 @@ export const GithubCopilotPlugin = define({ model.enabled = false }) }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 1a8a4a1231..a413c85d7d 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -7,7 +7,8 @@ import { ProviderV2 } from "../../provider" export const GitLabPlugin = define({ id: "opencode.provider.gitlab", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) @@ -31,7 +32,8 @@ export const GitLabPlugin = define({ }) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 2c307a8725..ffc29f4bf3 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -85,7 +85,8 @@ export const GoogleVertexPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) @@ -104,7 +105,8 @@ export const GoogleVertexPlugin = define({ }) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) @@ -135,7 +137,8 @@ export const GoogleVertexAnthropicPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) @@ -161,7 +164,8 @@ export const GoogleVertexAnthropicPlugin = define({ }) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 3d2013a523..c62d962eb5 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GooglePlugin = define({ id: "opencode.provider.google", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index d84ece2151..51b8e4c42f 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GroqPlugin = define({ id: "opencode.provider.groq", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index 92bc09abec..f5de664432 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const MistralPlugin = define({ id: "opencode.provider.mistral", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index 3854bdcde2..76646b4e45 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const OpenAICompatiblePlugin = define({ id: "opencode.provider.openai-compatible", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 72fe8ed646..5e4a1b08ef 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -210,14 +210,16 @@ export const OpenAIPlugin = define({ Effect.forkScoped({ startImmediately: true }), ) yield* refresh().pipe(Effect.forkScoped) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) evt.sdk = mod.createOpenAI(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.openai) return evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 58690df0b2..cca6a2dfa7 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -10,6 +10,7 @@ import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" import { ConfigProviderV1 } from "../../v1/config/provider" +import { Money } from "@opencode-ai/schema/money" import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options" import { ConfigV1 } from "../../v1/config/config" @@ -220,20 +221,23 @@ function withoutCredentials(body: Readonly> | undefined) function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) { const base = { - input: input.input, - output: input.output, - cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 }, + input: Money.USDPerMillionTokens.make(input.input), + output: Money.USDPerMillionTokens.make(input.output), + cache: { + read: Money.USDPerMillionTokens.make(input.cache_read ?? 0), + write: Money.USDPerMillionTokens.make(input.cache_write ?? 0), + }, } if (!input.context_over_200k) return [base] return [ base, { tier: { type: "context" as const, size: 200_000 }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, + input: Money.USDPerMillionTokens.make(input.context_over_200k.input), + output: Money.USDPerMillionTokens.make(input.context_over_200k.output), cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0), + write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0), }, }, ] diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index c27cc30207..f00386a94a 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -23,7 +23,8 @@ export const OpenRouterPlugin = define({ } } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 9eb5b1e246..36cafdb2d1 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const PerplexityPlugin = define({ id: "opencode.provider.perplexity", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 4a8c26e82a..559493787d 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -8,7 +8,8 @@ export const SapAICorePlugin = define({ id: "opencode.provider.sap-ai-core", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = @@ -37,7 +38,8 @@ export const SapAICorePlugin = define({ ) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 2e6cc9f9b4..45c5f095ee 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -67,7 +67,8 @@ export function cortexFetch(upstream: FetchLike = fetch) { export const SnowflakeCortexPlugin = define({ id: "opencode.provider.snowflake-cortex", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index d9454cfd24..ea43db9e4a 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const TogetherAIPlugin = define({ id: "opencode.provider.togetherai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index c9d5b163ae..9930ce831d 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const VenicePlugin = define({ id: "opencode.provider.venice", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 70a27c8f67..fc392fe5e1 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -14,7 +14,8 @@ export const VercelPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index fb1d33e86e..724a3d2567 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -5,14 +5,16 @@ import { ProviderV2 } from "../../provider" export const XAIPlugin = define({ id: "opencode.provider.xai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index f6ceb06a8f..3dd42a8037 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -1,20 +1,12 @@ export * as SdkPlugins from "./sdk" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Context, Effect, Layer } from "effect" import { makeGlobalNode } from "../effect/app-node" import { EventV2 } from "../event" export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} }) -export interface Store { - readonly plugins: Map -} - -export const makeStore = (): Store => ({ plugins: new Map() }) - -const defaultStore = makeStore() - /** * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, * so `PluginSupervisor` can add them on every Location boot through the ordinary @@ -22,10 +14,9 @@ const defaultStore = makeStore() * config. Registration publishes an unlocated update so every booted Location * reloads its plugin generation from the shared store. * - * The store is shared explicitly between the SDK construction graph and the - * embedded route graph because `LocationServiceMap` builds Location layers lazily - * in a nested graph. Each embedded SDK creates its own store, so instances do not - * see each other's contributions. + * Each host-global layer owns one private store. Location graphs reuse that + * layer through Effect's memoization, so separate hosts remain isolated while + * every Location in one host sees the same registrations. */ export interface Interface { readonly register: (plugin: Plugin) => Effect.Effect @@ -34,26 +25,19 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SdkPlugins") {} -export const layerWithStore = (store: Store) => - Layer.effect( - Service, - Effect.gen(function* () { - const events = yield* EventV2.Service - yield* Effect.addFinalizer(() => +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const plugins = new Map() + return Service.of({ + register: (plugin) => Effect.sync(() => { - store.plugins.clear() - }), - ) - return Service.of({ - register: (plugin) => - Effect.sync(() => { - store.plugins.set(plugin.id, plugin) - }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), - all: () => [...store.plugins.values()], - }) - }), - ) - -export const layer = layerWithStore(defaultStore) + plugins.set(plugin.id, plugin) + }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), + all: () => [...plugins.values()], + }) + }), +) export const node = makeGlobalNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/src/plugin/search/exa.ts b/packages/core/src/plugin/search/exa.ts index 4d0f46dc68..cd16ef3ec0 100644 --- a/packages/core/src/plugin/search/exa.ts +++ b/packages/core/src/plugin/search/exa.ts @@ -1,6 +1,6 @@ export * as SearchExa from "./exa" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { SearchMcp } from "./mcp" diff --git a/packages/core/src/plugin/search/parallel.ts b/packages/core/src/plugin/search/parallel.ts index ccaaecaee1..19880ff06d 100644 --- a/packages/core/src/plugin/search/parallel.ts +++ b/packages/core/src/plugin/search/parallel.ts @@ -1,6 +1,6 @@ export * as SearchParallel from "./parallel" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { InstallationVersion } from "../../installation/version" diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 91c5297c0e..439b77fd36 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -33,7 +33,8 @@ export const Plugin = define({ SkillV2.EmbeddedSource.make({ type: "embedded", skill: SkillV2.Info.make({ - name: "opencode", + id: SkillV2.ID.make("opencode"), + name: SkillV2.Name.make("OpenCode"), description: OpencodeDescription, location: AbsolutePath.make("/builtin/opencode.md"), content: OpencodeContent, @@ -44,7 +45,8 @@ export const Plugin = define({ SkillV2.EmbeddedSource.make({ type: "embedded", skill: SkillV2.Info.make({ - name: "report", + id: SkillV2.ID.make("report"), + name: SkillV2.Name.make("Report"), description: REPORT_DESCRIPTION, slash: true, location: AbsolutePath.make("/builtin/report.md"), @@ -103,15 +105,22 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* ( }) function terminal() { - return [ - process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined, - process.env.TERM ? `TERM=${process.env.TERM}` : undefined, - process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined, - ] - .filter((item): item is string => item !== undefined) - .join(", ") || "Unavailable: terminal environment variables are not set" + return ( + [ + process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined, + process.env.TERM ? `TERM=${process.env.TERM}` : undefined, + process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined, + ] + .filter((item): item is string => item !== undefined) + .join(", ") || "Unavailable: terminal environment variables are not set" + ) } function shell() { - return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set" + return ( + process.env.SHELL ?? + process.env.ComSpec ?? + process.env.COMSPEC ?? + "Unavailable: shell environment variable is not set" + ) } diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 4c4e71be17..6bd4f6bfac 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -1,6 +1,6 @@ export * as PluginSupervisor from "./supervisor" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event } from "@opencode-ai/schema/config" import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect" import path from "path" diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b5d28c4612..ad53940971 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect" +import { Effect, Layer, Schema, Context, Stream, Scope } from "effect" import { ListAnchor } from "@opencode-ai/schema/session" import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" @@ -19,6 +19,7 @@ import { SessionSchema } from "./session/schema" import { AbsolutePath, PositiveInt, RelativePath } from "./schema" import { AgentV2 } from "./agent" import { SessionV1 } from "./v1/session" +import { Money } from "@opencode-ai/schema/money" import { InstallationVersion } from "./installation/version" import { Slug } from "./util/slug" import { ProjectTable } from "./project/sql" @@ -34,7 +35,7 @@ import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" -import { Revert } from "@opencode-ai/schema/revert" +import { Session } from "@opencode-ai/schema/session" import { FSUtil } from "./fs-util" import { Mime } from "./mime" import type { EventLog } from "@opencode-ai/schema/event-log" @@ -46,8 +47,8 @@ import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex" import { fileURLToPath } from "url" -export const RevertState = Revert.State -export type RevertState = Revert.State +export const RevertState = Session.Revert +export type RevertState = Session.Revert // get project -> project.locations // @@ -136,7 +137,7 @@ export class BusyError extends Schema.TaggedErrorClass()("Session.Bus sessionID: SessionSchema.ID, }) {} export class SkillNotFoundError extends Schema.TaggedErrorClass()("Session.SkillNotFoundError", { - skill: Schema.String, + skill: SkillV2.ID, }) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -170,14 +171,14 @@ export interface Interface { id: SessionMessage.ID direction: "previous" | "next" } - }) => Effect.Effect + }) => Effect.Effect readonly message: (input: { sessionID: SessionSchema.ID messageID: SessionMessage.ID - }) => Effect.Effect + }) => Effect.Effect readonly context: ( sessionID: SessionSchema.ID, - ) => Effect.Effect + ) => Effect.Effect /** * Durable, ordered, gap-free session log read. Replays public durable * session events after the exclusive `after` cursor, emits a `Synced` @@ -191,7 +192,10 @@ export interface Interface { after?: number follow?: boolean }) => Stream.Stream - readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect + readonly switchAgent: (input: { + sessionID: SessionSchema.ID + agent: AgentV2.ID + }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref @@ -209,7 +213,7 @@ export interface Interface { sessionID: SessionSchema.ID command: string arguments?: string - agent?: string + agent?: AgentV2.ID model?: ModelV2.Ref files?: PromptInput.Prompt["files"] agents?: PromptInput.Prompt["agents"] @@ -227,7 +231,7 @@ export interface Interface { readonly skill: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID - skill: string + skill: SkillV2.ID resume?: boolean }) => Effect.Effect readonly compact: ( @@ -243,13 +247,14 @@ export interface Interface { text: string description?: string metadata?: Record + resume?: boolean }) => Effect.Effect readonly revert: { readonly stage: (input: { sessionID: SessionSchema.ID messageID: SessionMessage.ID files?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect } @@ -272,7 +277,7 @@ const layer = Layer.effect( const scope = yield* Scope.Scope const activeShells = new Set() const shellLocks = KeyedMutex.makeUnsafe() - const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( @@ -321,7 +326,7 @@ const layer = Layer.effect( variant: input.model.variant, } : undefined, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: now, updated: now }, }) @@ -528,7 +533,7 @@ const layer = Layer.effect( }) const model = command.model ?? commandAgent?.model ?? input.model if (agent !== undefined && session.agent !== AgentV2.ID.make(agent)) - yield* result.switchAgent({ sessionID: input.sessionID, agent }) + yield* result.switchAgent({ sessionID: input.sessionID, agent: AgentV2.ID.make(agent) }) if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model }) return yield* result.prompt({ @@ -590,12 +595,13 @@ const layer = Layer.effect( skill: Effect.fn("V2Session.skill")(function* (input) { const session = yield* result.get(input.sessionID) const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location))) - const skill = (yield* skills.list()).find((item) => item.name === input.skill) + const skill = (yield* skills.list()).find((item) => item.id === input.skill) if (!skill) return yield* new SkillNotFoundError({ skill: input.skill }) yield* events.publish( SessionEvent.Skill.Activated, { sessionID: input.sessionID, + id: skill.id, name: skill.name, text: skill.content, }, @@ -682,6 +688,7 @@ const layer = Layer.effect( description: input.description, metadata: input.metadata, }) + if (input.resume === false) return yield* execution .resume(input.sessionID) .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index e90b6455dc..ab5e41e405 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -25,20 +25,27 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside Rules: - Keep every section, even when empty. - Use terse bullets, not prose paragraphs. - Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known. -- Put relevant files and symbols inside the section where they matter; do not add extra sections. - Do not mention the summary process or that context was compacted.` type Settings = { @@ -57,19 +64,21 @@ type Dependencies = { export type AutoInput = { readonly sessionID: SessionSchema.ID - readonly messages: readonly SessionMessage.Message[] + readonly messages: readonly SessionMessage.Info[] readonly request: LLMRequest } type CompactInput = { readonly sessionID: SessionSchema.ID - readonly messages: readonly SessionMessage.Message[] + readonly messages: readonly SessionMessage.Info[] readonly model: Model + readonly inputID?: SessionMessage.ID } export type ManualInput = { readonly session: SessionSchema.Info - readonly messages: readonly SessionMessage.Message[] + readonly messages: readonly SessionMessage.Info[] + readonly inputID: SessionMessage.ID } export interface Interface { @@ -92,7 +101,7 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[ ) .join("\n") -const serialize = (message: SessionMessage.Message) => { +const serialize = (message: SessionMessage.Info) => { if (message.type === "user") { const files = message.files?.map( @@ -121,7 +130,7 @@ const serialize = (message: SessionMessage.Message) => { if (message.type === "system") return `[System update]: ${message.text}` if (message.type === "synthetic") return `[Synthetic context]: ${message.text}` if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}` - if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}` + if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}` return "" } @@ -140,7 +149,7 @@ const settings = (documents: readonly Config.Entry[]) => { } const select = ( - messages: readonly SessionMessage.Message[], + messages: readonly SessionMessage.Info[], tokens: number, ): { readonly head: string; readonly recent: string } | undefined => { const conversation = messages @@ -191,6 +200,7 @@ const make = (dependencies: Dependencies) => { readonly context: readonly string[] readonly recent: string readonly output?: number + readonly inputID?: SessionMessage.ID }) { const context = input.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false @@ -201,6 +211,8 @@ const make = (dependencies: Dependencies) => { yield* dependencies.events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, reason: input.reason, + recent: input.recent, + inputID: input.inputID, }) const chunks: string[] = [] @@ -228,9 +240,27 @@ const make = (dependencies: Dependencies) => { }), Effect.as(true), Effect.catchTag("LLM.Error", () => Effect.succeed(false)), + Effect.onInterrupt(() => + input.reason === "auto" + ? dependencies.events.publish(SessionEvent.Compaction.Failed, { + sessionID: input.sessionID, + reason: input.reason, + error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, + inputID: input.inputID, + }) + : Effect.void, + ), ) const summary = chunks.join("") - if (!summarized || failed || !summary.trim()) return false + if (!summarized || failed || !summary.trim()) { + yield* dependencies.events.publish(SessionEvent.Compaction.Failed, { + sessionID: input.sessionID, + reason: input.reason, + error: { type: "compaction.failed", message: "Compaction produced no summary" }, + inputID: input.inputID, + }) + return false + } yield* dependencies.events.publish(SessionEvent.Compaction.Ended, { sessionID: input.sessionID, reason: input.reason, @@ -277,6 +307,7 @@ const make = (dependencies: Dependencies) => { ), recent: forcedShortContext ? "" : selected.recent, output: input.output, + inputID: input.inputID, }) }) const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) { @@ -320,6 +351,7 @@ export const layer = Layer.effect( sessionID: input.session.id, messages: input.messages, model: resolved.model, + inputID: input.inputID, }) }), }) diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index e719366940..f2e3122c96 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -8,7 +8,7 @@ import { InstructionCheckpointTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -const decode = Schema.decodeUnknownEffect(SessionMessage.Message) +const decode = Schema.decodeUnknownEffect(SessionMessage.Info) export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { return yield* db diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 1c8ddf153d..2c507d0058 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -1,4 +1,4 @@ -import { DateTime } from "effect" +import { DateTime, Schema } from "effect" import { AgentV2 } from "../agent" import { Location } from "../location" import { ModelV2 } from "../model" @@ -9,7 +9,10 @@ import { WorkspaceV2 } from "../workspace" import { SessionSchema } from "./schema" import { SessionTable } from "./sql" import { SessionMessage } from "./message" -import { Snapshot } from "../snapshot" +import { PersistedRevert } from "@opencode-ai/schema/session-revert" +import { Money } from "@opencode-ai/schema/money" + +const decodeRevert = Schema.decodeUnknownSync(PersistedRevert) export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info { return SessionSchema.Info.make({ @@ -31,7 +34,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In variant: ModelV2.VariantID.make(row.model.variant ?? "default"), } : undefined, - cost: row.cost, + cost: Money.USD.make(row.cost), tokens: { input: row.tokens_input, output: row.tokens_output, @@ -46,7 +49,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined, }), subpath: row.path ? RelativePath.make(row.path) : undefined, - revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined, + revert: row.revert ? decodeRevert(row.revert) : undefined, time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index c08439d0c1..41eb36ea5b 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -2,7 +2,7 @@ export * as SessionInput from "./input" import { and, asc, eq, isNull } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" -import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input" +import { Admitted, Compaction, Delivery, Info, PromptEntry } from "@opencode-ai/schema/session-input" import type { Database } from "../database/database" import type { EventV2 } from "../event" import { KeyedMutex } from "../effect/keyed-mutex" @@ -14,7 +14,7 @@ import { SessionInputTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -export { Admitted, Compaction, Delivery, Entry, PromptEntry } +export { Admitted, Compaction, Delivery, Info, PromptEntry } const decodePrompt = Schema.decodeUnknownSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt) @@ -24,7 +24,7 @@ export class LifecycleConflict extends Schema.TaggedErrorClass { +const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => { const base = { admittedSeq: row.admitted_seq, id: SessionMessage.ID.make(row.id), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 96881e7c69..01e045ce5f 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -4,7 +4,7 @@ import { SessionEvent } from "./event" import { SessionMessage } from "./message" export type MemoryState = { - messages: SessionMessage.Message[] + messages: SessionMessage.Info[] } export interface Adapter { @@ -14,13 +14,13 @@ export interface Adapter { messageID: SessionMessage.ID, ) => Effect.Effect readonly getShell: ( - shellID: SessionMessage.Shell["shell"]["id"], + shellID: SessionMessage.Shell["shellID"], ) => Effect.Effect readonly getCompaction: () => Effect.Effect readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect - readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect + readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect } export function memory(state: MemoryState): Adapter { @@ -29,9 +29,7 @@ export function memory(state: MemoryState): Adapter { const shellIndex = (messageID: SessionMessage.ID) => state.messages.findLastIndex((message) => message.id === messageID) const compactionIndex = () => - state.messages.findLastIndex( - (message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"), - ) + state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running") // A newer step supersedes stale incomplete rows; never resume an older assistant projection. const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") @@ -64,7 +62,7 @@ export function memory(state: MemoryState): Adapter { getShell(shellID) { return Effect.sync(() => { return state.messages.find((message): message is SessionMessage.Shell => { - return message.type === "shell" && message.shell.id === shellID + return message.type === "shell" && message.shellID === shellID }) }) }, @@ -143,6 +141,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return Effect.gen(function* () { yield* SessionEvent.All.match(event, { + "session.usage.updated": () => Effect.void, "session.agent.selected": (event) => { return adapter.appendMessage( SessionMessage.AgentSelected.make({ @@ -185,13 +184,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: SessionMessage.ID.fromEvent(event.id), type: "system", text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }), ), "session.synthetic": (event) => { return adapter.appendMessage( SessionMessage.Synthetic.make({ - sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, metadata: event.data.metadata, @@ -206,8 +205,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { SessionMessage.Skill.make({ id: SessionMessage.ID.fromEvent(event.id), type: "skill", + skill: event.data.id, name: event.data.name, text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }), ) @@ -218,7 +219,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: SessionMessage.ID.fromEvent(event.id), type: "shell", metadata: event.metadata, - shell: event.data.shell, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, time: { created: event.created }, }), ) @@ -229,7 +232,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { if (currentShell) { yield* adapter.updateShell( produce(currentShell, (draft) => { - draft.shell = castDraft(event.data.shell) + draft.status = event.data.shell.status + draft.exit = event.data.shell.exit draft.output = event.data.output draft.time.completed = event.created }), @@ -269,6 +273,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { type: "assistant", agent: event.data.agent, model: event.data.model, + metadata: event.metadata, time: { created: event.created }, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, @@ -296,6 +301,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { draft.finish = "error" draft.error = castDraft(event.data.error) draft.retry = undefined + if (event.data.cost !== undefined && event.data.tokens !== undefined) { + draft.cost = event.data.cost + draft.tokens = castDraft(event.data.tokens) + } }) }, "session.text.started": (event) => { @@ -324,7 +333,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: event.data.callID, name: event.data.name, time: { created: event.created }, - state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }), + state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }), }), ), ) @@ -334,7 +343,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.tool.input.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) - if (match && match.state.status === "pending") match.state.input = event.data.text + if (match && match.state.status === "streaming") match.state.input = event.data.text }) }, "session.tool.called": (event) => { @@ -377,7 +386,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { input: match.state.input, structured: event.data.structured, content: [...event.data.content], - outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [], result: event.data.result, }), ) @@ -387,7 +395,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.tool.failed": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) - if (match && (match.state.status === "pending" || match.state.status === "running")) { + if (match && (match.state.status === "streaming" || match.state.status === "running")) { match.executed = event.data.executed || match.executed === true match.providerResultState = event.data.resultState match.time.completed = event.created @@ -443,31 +451,30 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.compaction.admitted": (event) => + "session.compaction.admitted": () => Effect.void, + "session.compaction.started": (event) => adapter.appendMessage( - SessionMessage.Compaction.make({ - id: event.data.inputID, + SessionMessage.CompactionRunning.make({ + id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id), type: "compaction", - status: "queued", + status: "running", metadata: event.metadata, - reason: "manual", + reason: event.data.reason, summary: "", - recent: "", + recent: event.data.recent ?? "", time: { created: event.created }, }), ), - "session.compaction.started": (event) => + "session.compaction.delta": (event) => Effect.gen(function* () { - if (event.data.reason !== "manual") return const current = yield* adapter.getCompaction() - if (!current) return - yield* adapter.updateCompaction({ ...current, status: "running" }) + if (current?.status !== "running") return + yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text }) }), - "session.compaction.delta": () => Effect.void, "session.compaction.ended": (event) => { return Effect.gen(function* () { - const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined - if (current) { + const current = yield* adapter.getCompaction() + if (current?.status === "running") { yield* adapter.updateCompaction({ ...current, status: "completed", @@ -491,11 +498,20 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }) }, - "session.compaction.failed": () => + "session.compaction.failed": (event) => Effect.gen(function* () { const current = yield* adapter.getCompaction() - if (!current) return - yield* adapter.updateCompaction({ ...current, status: "failed" }) + const failed = SessionMessage.CompactionFailed.make({ + id: current?.id ?? event.data.inputID ?? SessionMessage.ID.fromEvent(event.id), + type: "compaction", + status: "failed", + metadata: current?.metadata ?? event.metadata, + reason: event.data.reason, + error: event.data.error, + time: current?.time ?? { created: event.created }, + }) + if (current?.status === "running") return yield* adapter.updateCompaction(failed) + yield* adapter.appendMessage(failed) }), "session.revert.staged": () => Effect.void, "session.revert.cleared": () => Effect.void, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 625b6f1553..dcf79ff3a1 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,7 +1,7 @@ export * as SessionProjector from "./projector" import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Layer, Schema, Stream } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" import { makeGlobalNode } from "../effect/app-node" @@ -24,15 +24,14 @@ import { } from "./sql" import type { DeepMutable } from "../schema" import { Slug } from "../util/slug" +import { Money } from "@opencode-ai/schema/money" type DatabaseService = Database.Interface["db"] -type MessageEvent = Exclude< - SessionEvent.DurableEvent, - typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type -> +type CurrentDurableEvent = Extract +type MessageEvent = Exclude -const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info) +const encodeMessage = Schema.encodeSync(SessionMessage.Info) export class SessionAlreadyProjected extends Error {} @@ -48,11 +47,6 @@ type Usage = { const ForkBatchSize = 500 -const emptyUsage = (): Usage => ({ - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, -}) - const forkTitle = (value: string) => { const match = value.match(/^(.+) \(fork #(\d+)\)$/) if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})` @@ -67,22 +61,6 @@ function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] } } -function addUsage(target: Usage, value: Usage) { - target.cost += value.cost - target.tokens.input += value.tokens.input - target.tokens.output += value.tokens.output - target.tokens.reasoning += value.tokens.reasoning - target.tokens.cache.read += value.tokens.cache.read - target.tokens.cache.write += value.tokens.cache.write -} - -function messageUsage(row: typeof SessionMessageTable.$inferSelect): Usage | undefined { - if (row.type !== "assistant") return undefined - const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) - if (message.type !== "assistant" || message.cost === undefined || message.tokens === undefined) return undefined - return { cost: message.cost, tokens: message.tokens } -} - function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert { return { id: info.id, @@ -108,7 +86,14 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning, tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read, tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write, - revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null, + revert: info.revert + ? { + messageID: SessionMessage.ID.make(info.revert.messageID), + partID: info.revert.partID, + snapshot: info.revert.snapshot, + diff: info.revert.diff, + } + : null, permission: info.permission ? [...info.permission] : undefined, time_created: info.time.created, time_updated: info.time.updated, @@ -151,6 +136,37 @@ function applyUsage( .pipe(Effect.orDie) } +const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* ( + db: DatabaseService, + events: EventV2.Interface, + sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"], +) { + const row = yield* db + .select({ + cost: SessionTable.cost, + input: SessionTable.tokens_input, + output: SessionTable.tokens_output, + reasoning: SessionTable.tokens_reasoning, + cacheRead: SessionTable.tokens_cache_read, + cacheWrite: SessionTable.tokens_cache_write, + }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!row) return + yield* events.publish(SessionEvent.UsageUpdated, { + sessionID, + cost: Money.USD.make(row.cost), + tokens: { + input: row.input, + output: row.output, + reasoning: row.reasoning, + cache: { read: row.cacheRead, write: row.cacheWrite }, + }, + }) +}) + const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( db: DatabaseService, event: typeof SessionEvent.Forked.Type, @@ -187,7 +203,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .limit(1) .get() .pipe(Effect.orDie) - const copiedSeq = copied?.seq ?? 0 + const copiedSeq = copied?.seq const stored = yield* db .insert(SessionTable) @@ -237,9 +253,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) } - const usage = emptyUsage() let cursor = -1 - while (true) { + while (copiedSeq !== undefined) { const rows = yield* db .select() .from(SessionMessageTable) @@ -247,8 +262,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( and( eq(SessionMessageTable.session_id, event.data.parentID), gt(SessionMessageTable.seq, cursor), - copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1), - sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`, + lt(SessionMessageTable.seq, copiedSeq + 1), + sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`, ), ) .orderBy(asc(SessionMessageTable.seq)) @@ -271,7 +286,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( seq: row.seq, time_created: row.time_created, time_updated: row.time_updated, - data: row.type === "synthetic" ? { ...row.data, sessionID: event.data.sessionID } : row.data, + data: row.data, } }), ) @@ -318,34 +333,16 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) } - for (const row of rows) { - const value = messageUsage(row) - if (value) addUsage(usage, value) - } cursor = rows.at(-1)!.seq } - - yield* db - .update(SessionTable) - .set({ - cost: usage.cost, - tokens_input: usage.tokens.input, - tokens_output: usage.tokens.output, - tokens_reasoning: usage.tokens.reasoning, - tokens_cache_read: usage.tokens.cache.read, - tokens_cache_write: usage.tokens.cache.write, - }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie) - if (copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq) + if (copiedSeq !== undefined) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq) }) function run(db: DatabaseService, event: MessageEvent) { return Effect.gen(function* () { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }) - const updateMessage = (message: SessionMessage.Message) => { + const updateMessage = (message: SessionMessage.Info) => { if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) @@ -362,7 +359,7 @@ function run(db: DatabaseService, event: MessageEvent) { .run() .pipe(Effect.orDie) } - const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message) + const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message) const adapter: SessionMessageUpdater.Adapter = { getModel() { return db @@ -421,7 +418,7 @@ function run(db: DatabaseService, event: MessageEvent) { and( eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell"), - sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`, + sql`json_extract(${SessionMessageTable.data}, '$.shellID') = ${shellID}`, ), ) .orderBy(desc(SessionMessageTable.seq)) @@ -442,7 +439,7 @@ function run(db: DatabaseService, event: MessageEvent) { and( eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction"), - sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`, + sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`, ), ) .orderBy(desc(SessionMessageTable.seq)) @@ -463,7 +460,7 @@ function run(db: DatabaseService, event: MessageEvent) { }) } -function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Message) { +function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) { if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) const { id, type, ...data } = encoded @@ -670,14 +667,12 @@ const layer = Layer.effectDiscard( Effect.gen(function* () { if (event.durable === undefined) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) - const admitted = yield* SessionInput.projectCompactionAdmitted(db, { + yield* SessionInput.projectCompactionAdmitted(db, { admittedSeq: event.durable.seq, id: event.data.inputID, sessionID: event.data.sessionID, timeCreated: event.created, }) - if (admitted.id !== event.data.inputID) return - yield* run(db, event) }), ) yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event)) @@ -685,20 +680,23 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event)) yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) - yield* events.project(SessionEvent.Skill.Activated, (event) => - insertMessage(db, event, { - id: SessionMessage.ID.fromEvent(event.id), - type: "skill", - name: event.data.name, - text: event.data.text, - time: { created: event.created }, - }), - ) + yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Started, (event) => run(db, event)) - yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event)) - yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event)) + yield* events.project(SessionEvent.Step.Ended, (event) => + Effect.gen(function* () { + yield* run(db, event) + yield* applyUsage(db, event.data.sessionID, event.data) + }), + ) + yield* events.project(SessionEvent.Step.Failed, (event) => + Effect.gen(function* () { + yield* run(db, event) + if (event.data.cost !== undefined && event.data.tokens !== undefined) + yield* applyUsage(db, event.data.sessionID, { cost: event.data.cost, tokens: event.data.tokens }) + }), + ) yield* events.project(SessionEvent.Text.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event)) @@ -728,22 +726,26 @@ const layer = Layer.effectDiscard( yield* run(db, event) if (event.durable === undefined) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) - yield* SessionInput.settleCompaction(db, { - sessionID: event.data.sessionID, - handledSeq: event.durable.seq, - }) + if (event.data.reason === "manual") + yield* SessionInput.settleCompaction(db, { + sessionID: event.data.sessionID, + handledSeq: event.durable.seq, + }) }), ) yield* events.project(SessionEvent.RevertEvent.Staged, (event) => - db - .update(SessionTable) - .set({ - revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined }, - time_updated: DateTime.toEpochMillis(event.created), - }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie, Effect.asVoid), + Effect.gen(function* () { + const revert = event.data.revert + yield* db + .update(SessionTable) + .set({ + revert: { ...revert, files: revert.files ? [...revert.files] : undefined }, + time_updated: DateTime.toEpochMillis(event.created), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + }), ) yield* events.project(SessionEvent.RevertEvent.Cleared, (event) => db @@ -790,6 +792,17 @@ const layer = Layer.effectDiscard( yield* InstructionCheckpoint.reset(db, event.data.sessionID) }), ) + yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe( + Stream.runForEach((event) => { + if ( + event.type === SessionEvent.Step.Failed.type && + (event.data.cost === undefined || event.data.tokens === undefined) + ) + return Effect.void + return publishSessionUsage(db, events, event.data.sessionID) + }), + Effect.forkScoped({ startImmediately: true }), + ) }), ) diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index 80ec187472..42bc34e694 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -1,7 +1,7 @@ export * as SessionRevert from "./revert" import { and, asc, eq, gt } from "drizzle-orm" -import { DateTime, Effect, Schema } from "effect" +import { Effect, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" import { RelativePath } from "../schema" @@ -46,7 +46,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) { .orderBy(asc(SessionMessageTable.seq)) .all() .pipe(Effect.orDie) - const decode = Schema.decodeUnknownEffect(SessionMessage.Message) + const decode = Schema.decodeUnknownEffect(SessionMessage.Info) const files = new Map() for (const row of rows) { const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie) @@ -70,7 +70,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID }) const restore = new Map() if (original) { - for (const file of input.session.revert?.files ?? []) restore.set(file.path, original) + for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original) } if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree) if (restore.size) yield* snapshot.restore({ files: restore }) @@ -81,10 +81,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { const revert = { messageID: input.messageID, snapshot: original, - diff: files - .map((file) => file.patch) - .join("") - .trim(), files, } satisfies SessionSchema.Info["revert"] yield* events.publish(SessionEvent.RevertEvent.Staged, { @@ -100,7 +96,7 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined if (original) yield* snapshot.restore({ - files: new Map((session.revert.files ?? []).map((file) => [file.path, original])), + files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])), }) const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Cleared, { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 2e05d1edcf..d757b19484 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -11,12 +11,14 @@ import { type ProviderErrorEvent, } from "@opencode-ai/llm" import { SessionError } from "@opencode-ai/schema/session-error" +import { Money } from "@opencode-ai/schema/money" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" +import { ModelV2 } from "../../model" import { PermissionV2 } from "../../permission" import { Instructions } from "../../instructions/index" import { InstructionBuiltIns } from "../../instructions/builtins" @@ -49,6 +51,32 @@ import { llmClient } from "../../effect/app-node-platform" import { StepFailedError, UserInterruptedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" +import { PluginHooks } from "../../plugin/hooks" + +type StepTokens = { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } +} + +// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract. +export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) { + const context = tokens.input + tokens.cache.read + tokens.cache.write + const tier = costs + .filter((cost) => cost.tier?.type === "context" && context > cost.tier.size) + .toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0] + const cost = tier ?? costs.find((cost) => cost.tier === undefined) + if (!cost) return Money.USD.zero + return Money.USD.make( + (tokens.input * cost.input + + (tokens.output + tokens.reasoning) * cost.output + + tokens.cache.read * cost.cache.read + + tokens.cache.write * cost.cache.write) / + 1_000_000, + ) +} /** * Runs one durable coding-agent Session until it settles. @@ -107,6 +135,7 @@ const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* AgentV2.Service const tools = yield* ToolRegistry.Service + const hooks = yield* PluginHooks.Service const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service const location = yield* Location.Service @@ -137,7 +166,7 @@ const layer = Layer.effect( for (const message of yield* store.context(sessionID)) { if (message.type !== "assistant") continue for (const tool of message.content) { - if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue + if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue yield* events.publish(SessionEvent.Tool.Failed, { sessionID, assistantMessageID: message.id, @@ -204,6 +233,7 @@ const layer = Layer.effect( } const resolved = yield* models.resolve(session) const model = resolved.model + const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq) const context = entries.map((entry) => entry.message) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps @@ -221,16 +251,40 @@ const layer = Layer.effect( .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: [ - ...toLLMMessages(context, resolved.ref), + ...toLLMMessages(context, resolved.ref, providerMetadataKey), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []), ], tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) + const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) + const requestEvent: SessionHooks["request"] = { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + system: [...request.system], + messages: [...request.messages], + tools: Object.fromEntries( + request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), + ), + } + // Plugins may reshape the draft, but cannot advertise tools excluded earlier + // by permissions or registration state. + yield* hooks.trigger("session", "request", requestEvent) + const hookedRequest = LLM.updateRequest(request, { + system: requestEvent.system, + messages: requestEvent.messages, + tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => { + const registered = availableTools.get(name) + if (!registered) return [] + return [{ ...registered, description: tool.description, inputSchema: tool.input }] + }), + }) + const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name)) // Automatic compaction completed; rebuild the request from compacted history. if ( !(yield* SessionInput.pendingCompaction(db, session.id)) && - (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request })) + (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest })) ) return { _tag: "RestartAfterCompaction", step: currentStep } as const const startSnapshot = yield* snapshots.capture() @@ -240,7 +294,7 @@ const layer = Layer.effect( // The selected catalog identity, not model.id: route-level ids are provider API // model ids (for example gpt-5.5-fast resolves to api id gpt-5.5). model: resolved.ref, - provider: model.provider, + providerMetadataKey, snapshot: startSnapshot, assistantMessageID, }) @@ -248,10 +302,9 @@ const layer = Layer.effect( // Durable publishes are serialized so tool fibers and step settlement never interleave // mid-event. const serialized = (effect: Effect.Effect) => publication.withPermit(effect) - const publish = (event: LLMEvent, outputPaths: ReadonlyArray = [], error?: SessionError.Error) => - serialized(publisher.publish(event, outputPaths, error)) + const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error)) let overflowFailure: ProviderErrorEvent | undefined - const providerStream = llm.stream(request).pipe( + const providerStream = llm.stream(hookedRequest).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return @@ -272,6 +325,21 @@ const layer = Layer.effect( ) return } + // A request hook hid this registered tool from the current request. Fail only + // this call durably and continue so the model can react, instead of executing + // a tool that was not advertised. Unregistered tools flow through settle, which + // durably fails them as unknown. + if (!advertisedTools.has(event.name) && availableTools.has(event.name)) { + needsContinuation = true + yield* publish( + LLMEvent.toolError({ + id: event.id, + name: event.name, + message: `Tool is not available for this request: ${event.name}`, + }), + ) + return + } needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) ownedToolFibers.push( @@ -292,7 +360,6 @@ const layer = Layer.effect( result: settlement.result, output: settlement.output, }), - settlement.outputPaths ?? [], settlement.error, ).pipe( Effect.andThen( @@ -312,6 +379,11 @@ const layer = Layer.effect( Effect.ensuring(serialized(publisher.flush())), ) + const stepUsage = (settlement: NonNullable>) => ({ + cost: calculateCost(resolved.cost, settlement.tokens), + tokens: settlement.tokens, + }) + // Captures the end snapshot, diffs it against the step's start, and durably ends the // assistant step. const publishStepEnd = (settlement: NonNullable>) => @@ -328,8 +400,7 @@ const layer = Layer.effect( sessionID: session.id, assistantMessageID: yield* publisher.startAssistant(), finish: settlement.finish, - cost: 0, - tokens: settlement.tokens, + ...stepUsage(settlement), snapshot: endSnapshot, files, }), @@ -452,7 +523,8 @@ const layer = Layer.effect( const stepEndedCleanly = !streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement) - if (stepFailure) yield* serialized(publisher.publishStepFailure()) + if (stepFailure) + yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (userDeclined) return yield* Effect.interrupt @@ -527,12 +599,30 @@ const layer = Layer.effect( return yield* compaction.compactManual({ session, messages: yield* store.context(sessionID), + inputID: pending.id, }) }), ).pipe(Effect.exit) if (Exit.isSuccess(compacted) && compacted.value) return true - yield* events.publish(SessionEvent.Compaction.Failed, { sessionID }) - if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause) + if (Exit.isFailure(compacted)) { + const unsettled = yield* SessionInput.pendingCompaction(db, sessionID) + if (unsettled) + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "manual", + error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) }, + inputID: unsettled.id, + }) + return yield* Effect.failCause(compacted.cause) + } + const unsettled = yield* SessionInput.pendingCompaction(db, sessionID) + if (unsettled) + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "manual", + error: { type: "compaction.failed", message: "Compaction could not start" }, + inputID: unsettled.id, + }) return true }), ) @@ -595,6 +685,7 @@ export const node = makeLocationNode({ llmClient, AgentV2.node, ToolRegistry.node, + PluginHooks.node, SessionRunnerModel.node, SessionStore.node, Location.node, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 6cc0d0845d..defd95f9c1 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -82,6 +82,8 @@ export interface Resolved { readonly model: Model /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ readonly ref: ModelV2.Ref + /** Catalog pricing in dollars per million tokens. */ + readonly cost: ModelV2.Info["cost"] } export interface Interface { @@ -94,13 +96,14 @@ export class Service extends Context.Service()("@opencode/v2 export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) /** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ -export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({ +export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({ model, ref: ModelV2.Ref.make({ id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(model.provider), ...(variant === undefined ? {} : { variant }), }), + cost, }) const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { @@ -341,6 +344,7 @@ const layer = Layer.effect( providerID: selected.providerID, ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), }), + cost: selected.cost, } }), }) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 5f4eeebc84..dfd0dca499 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -6,13 +6,16 @@ import { SessionEvent } from "../event" import { SessionMessage } from "../message" import { SessionSchema } from "../schema" import { SessionError } from "@opencode-ai/schema/session-error" +import { Money } from "@opencode-ai/schema/money" +import { AgentV2 } from "../../agent" +import { Snapshot } from "../../snapshot" type Input = { readonly sessionID: SessionSchema.ID - readonly agent: string + readonly agent: AgentV2.ID readonly model: ModelV2.Ref - readonly provider: string - readonly snapshot?: string + readonly providerMetadataKey: string + readonly snapshot?: Snapshot.ID readonly assistantMessageID?: SessionMessage.ID } @@ -84,7 +87,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantMessageID ??= SessionMessage.ID.create() stepStarted = true yield* events.publish(SessionEvent.Step.Started, { - ...input, + sessionID: input.sessionID, + agent: input.agent, + model: input.model, assistantMessageID, snapshot: input.snapshot, }) @@ -94,34 +99,42 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantMessageID === undefined ? Effect.die(new Error("Tool event before assistant step start")) : Effect.succeed(assistantMessageID) - const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider] - + const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey] const fragments = ( name: string, ended: (id: string, value: string, ordinal: number, state?: Record) => Effect.Effect, single = false, ) => { - const chunks = new Map() + const chunks = new Map< + string, + { readonly ordinal: number; readonly values: string[]; state?: Record } + >() let nextOrdinal = 0 - const start = (id: string) => + const start = (id: string, state?: Record) => Effect.suspend(() => { if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`)) const ordinal = nextOrdinal++ - chunks.set(id, { ordinal, values: [] }) + chunks.set(id, { ordinal, values: [], state }) return Effect.succeed(ordinal) }) - const append = (id: string, value: string) => + const append = (id: string, value: string, state?: Record) => Effect.suspend(() => { const current = chunks.get(id) if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) current.values.push(value) + if (state !== undefined) current.state = { ...current.state, ...state } return Effect.succeed(current.ordinal) }) const end = Effect.fnUntraced(function* (id: string, state?: Record) { const current = chunks.get(id) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) - yield* ended(id, current.values.join(""), current.ordinal, state) + yield* ended( + id, + current.values.join(""), + current.ordinal, + state === undefined ? current.state : { ...current.state, ...state }, + ) chunks.delete(id) }) const flush = Effect.fnUntraced(function* () { @@ -216,7 +229,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) if (replace || stepFailure === undefined) stepFailure = error }) - const publishStepFailure = Effect.fnUntraced(function* () { + const publishStepFailure = Effect.fnUntraced(function* (usage?: { + readonly cost: Money.USD + readonly tokens: ReturnType + }) { if (stepFailed || stepFailure === undefined) return const assistantMessageID = yield* startAssistant() stepFailed = true @@ -224,6 +240,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) sessionID: input.sessionID, assistantMessageID, error: stepFailure, + ...usage, }) }) @@ -252,11 +269,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`)) } - const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( - event: LLMEvent, - outputPaths: ReadonlyArray = [], - error?: SessionError.Error, - ) { + const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) { switch (event.type) { case "step-start": yield* startAssistant() @@ -284,7 +297,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return case "reasoning-start": retryEvidence = true - const startedReasoningOrdinal = yield* reasoning.start(event.id) + const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata)) yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), @@ -293,7 +306,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) }) return case "reasoning-delta": - const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text) + const deltaReasoningOrdinal = yield* reasoning.append( + event.id, + event.text, + providerState(event.providerMetadata), + ) yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), @@ -336,14 +353,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`)) tool.called = true tool.providerExecuted = event.providerExecuted === true - const state = providerState(event.providerMetadata) yield* events.publish(SessionEvent.Tool.Called, { sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, callID: event.id, input: record(event.input), executed: tool.providerExecuted, - state, + state: providerState(event.providerMetadata), }) return } @@ -378,7 +394,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantMessageID: tool.assistantMessageID, callID: event.id, ...result, - outputPaths, ...(executed ? { result: event.result } : {}), executed, resultState, @@ -409,12 +424,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) case "step-finish": yield* flush() if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) + stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } if (event.reason === "content-filter") { providerFailed = true yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true) return } - stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } return case "finish": return diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index f4e1d7ca01..bd75094878 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -69,7 +69,7 @@ const providerMetadata = ( ): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state }) const toolInput = (tool: SessionMessage.AssistantTool) => - tool.state.status === "pending" + tool.state.status === "streaming" ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input) : tool.state.input @@ -113,19 +113,19 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid } } -const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { +const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => { const sameModel = String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id) const reuseProviderMetadata = sameModel && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") - return sameModel + return reuseProviderMetadata ? [ { type: "reasoning", text: item.text, - providerMetadata: reuseProviderMetadata ? providerMetadata(model.providerID, item.state) : undefined, + providerMetadata: providerMetadata(providerMetadataKey, item.state), }, ] : item.text.length > 0 @@ -133,13 +133,13 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { : [] const call = toolCall( item, - reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined, + reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined, ) if (item.executed !== true) return [call] const result = toolResult( item, reuseProviderMetadata - ? providerMetadata(model.providerID, item.providerResultState ?? item.providerState) + ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState) : undefined, ) return result ? [call, result] : [call] @@ -155,7 +155,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { toolResult( item, reuseProviderMetadata - ? providerMetadata(model.providerID, item.providerResultState ?? item.providerState) + ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState) : undefined, ), ) @@ -168,7 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { ] } -function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] { +function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] { switch (message.type) { case "agent-switched": case "model-switched": @@ -202,12 +202,12 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess Message.make({ id: message.id, role: "user", - content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`, + content: `Shell command: ${message.command}\n\n${message.output?.output ?? ""}`, metadata: message.metadata, }), ] case "assistant": - return assistant(message, model) + return assistant(message, model, providerMetadataKey) case "compaction": if (message.status !== "completed") return [] return [ @@ -232,5 +232,8 @@ ${message.recent} } /** Translate projected V2 Session history into canonical @opencode-ai/llm context. */ -export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) => - messages.flatMap((message) => toLLMMessage(message, model)) +export const toLLMMessages = ( + messages: readonly SessionMessage.Info[], + model: ModelV2.Ref, + providerMetadataKey: string = model.providerID, +) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey)) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index e7d3bdca58..6e09505e3c 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" import type { Prompt } from "@opencode-ai/schema/prompt" import type { SessionInput } from "./input" -import type { Snapshot } from "../snapshot" +import type { FileDiff } from "@opencode-ai/schema/file-diff" import { PermissionV1 } from "../v1/permission" import { ProjectV2 } from "../project" import type { SessionSchema } from "./schema" @@ -13,10 +13,11 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" import type { Instructions } from "../instructions/index" -import type { Revert } from "@opencode-ai/schema/revert" +import type { Session } from "@opencode-ai/schema/session" +import type { RevertV1 } from "@opencode-ai/schema/session-revert" import type { Schema } from "effect" -type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> +type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id"> type V1MessageData = Omit type V1PartData = Omit @@ -41,7 +42,7 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - summary_diffs: text({ mode: "json" }).$type(), + summary_diffs: text({ mode: "json" }).$type(), metadata: text({ mode: "json" }).$type>(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), @@ -49,7 +50,7 @@ export const SessionTable = sqliteTable( tokens_reasoning: integer().notNull().default(0), tokens_cache_read: integer().notNull().default(0), tokens_cache_write: integer().notNull().default(0), - revert: text({ mode: "json" }).$type(), + revert: text({ mode: "json" }).$type(), permission: text({ mode: "json" }).$type(), agent: text(), model: text({ mode: "json" }).$type<{ @@ -148,7 +149,7 @@ export const SessionInputTable = sqliteTable( .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().$type().notNull(), + type: text().$type().notNull(), prompt: text({ mode: "json" }).$type(), delivery: text().$type(), admitted_seq: integer().notNull(), diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 6458aa85fb..24c99cf515 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -13,10 +13,10 @@ import { fromRow } from "./info" export interface Interface { readonly get: (sessionID: Session.ID) => Effect.Effect - readonly context: (sessionID: Session.ID) => Effect.Effect + readonly context: (sessionID: Session.ID) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, - ) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Message } | undefined> + ) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined> } export class Service extends Context.Service()("@opencode/v2/SessionStore") {} @@ -25,7 +25,7 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service - const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) return Service.of({ get: Effect.fn("SessionStore.get")(function* (sessionID) { diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 511e02af87..13b93004e4 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -28,11 +28,15 @@ export type Source = typeof Source.Type export const Info = Skill.Info export type Info = Skill.Info +export const ID = Skill.ID +export type ID = Skill.ID +export const Name = Skill.Name +export type Name = Skill.Name export const Event = Skill.Event export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => - skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") + skills.filter((skill) => PermissionV2.evaluate("skill", skill.id, agent.permissions).effect !== "deny") const Frontmatter = Schema.Struct({ name: Schema.String.pipe(Schema.optional), @@ -96,7 +100,7 @@ const layer = Layer.effect( source: Source.key(source), type: source.type, directories: [], - skills: [source.skill.name], + skills: [source.skill.id], }) return { skills: [source.skill], directories: [] } } @@ -112,15 +116,13 @@ const layer = Layer.effect( if (!markdown) continue const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined if (!frontmatter) continue - const name = - frontmatter.name !== undefined - ? frontmatter.name - : path.dirname(filepath) === directory - ? path.basename(filepath, ".md") - : undefined - if (!name) continue + const id = + path.dirname(filepath) === directory + ? path.basename(filepath, ".md") + : path.basename(path.dirname(filepath)) skills.push({ - name, + id: ID.make(id), + name: Name.make(frontmatter.name ?? id), description: frontmatter.description, slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash, autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"), @@ -133,7 +135,7 @@ const layer = Layer.effect( source: Source.key(source), type: source.type, directories, - skills: skills.map((skill) => skill.name), + skills: skills.map((skill) => skill.id), }) return { skills, directories } }) @@ -148,7 +150,7 @@ const layer = Layer.effect( yield* Effect.logInfo("skill cache invalidated", { file, sources: invalidated.map(([key]) => key), - skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.name)), + skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)), }) yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid) }) @@ -159,12 +161,12 @@ const layer = Layer.effect( ) const list = Effect.fn("SkillV2.list")(function* () { - const skills = new Map() + const skills = new Map() for (const source of state.get().sources) { const key = Source.key(source) const loaded = cache.get(key) ?? (yield* load(source)) cache.set(key, loaded) - for (const skill of loaded.skills) skills.set(skill.name, skill) + for (const skill of loaded.skills) skills.set(skill.id, skill) } return Array.from(skills.values()) }) @@ -180,4 +182,8 @@ const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] }) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [SkillDiscovery.node, FSUtil.node, EventV2.node], +}) diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 95ebe87eed..5eae66065b 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -8,7 +8,8 @@ import { SkillV2 } from "../skill" import { Instructions } from "../instructions/index" const Summary = Schema.Struct({ - name: Schema.String, + id: SkillV2.ID, + name: SkillV2.Name, description: Schema.String, }) type Summary = typeof Summary.Type @@ -16,6 +17,7 @@ type Summary = typeof Summary.Type const entries = (skills: ReadonlyArray) => skills.flatMap((skill) => [ " ", + ` ${skill.id}`, ` ${skill.name}`, ` ${skill.description}`, " ", @@ -34,8 +36,8 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray skill.name, - (before, after) => before.description !== after.description, + (skill) => skill.id, + (before, after) => before.name !== after.name || before.description !== after.description, ) // Additions and removals render as small deltas; anything else restates the full list. if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) @@ -50,7 +52,7 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray skill.name).join(", ")}.`, + `The following skill IDs are no longer available and must not be used: ${diff.removed.map((skill) => skill.id).join(", ")}.`, ]), ].join("\n") } @@ -77,9 +79,9 @@ const layer = Layer.effect( .flatMap((skill) => skill.description === undefined || skill.autoinvoke === false ? [] - : [{ name: skill.name, description: skill.description }], + : [{ id: skill.id, name: skill.name, description: skill.description }], ) - .toSorted((a, b) => a.name.localeCompare(b.name)) + .toSorted((a, b) => a.id.localeCompare(b.id)) return Instructions.make({ key: Instructions.Key.make("core/skill-guidance"), codec: Schema.toCodecJson(Schema.Array(Summary)), diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 9843e69226..8ef532f488 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -10,10 +10,10 @@ import { Git } from "./git" import { Global } from "./global" import { Location } from "./location" import { AbsolutePath, RelativePath } from "./schema" +import { ID } from "@opencode-ai/schema/snapshot" import { Hash } from "./util/hash" -export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) -export type ID = typeof ID.Type +export { ID } export class Error extends Schema.TaggedErrorClass()("Snapshot.Error", { operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]), @@ -253,12 +253,3 @@ function failure(operation: Error["operation"], cause: unknown) { cause, }) } - -/** Legacy persisted session diff shape. */ -export type LegacyFileDiff = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 4b427f9835..0b3f9c3628 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -41,7 +41,7 @@ Registrations are scoped: ## Permissions -The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action. +The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action. Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement. diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 1128243b70..927cd3098d 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -6,7 +6,7 @@ */ export * as EditTool from "./edit" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 4c0938c3d2..b047101c39 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -1,7 +1,7 @@ export * as GlobTool from "./glob" import { ToolFailure } from "@opencode-ai/llm" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema } from "effect" import path from "path" import { FileSystem } from "../filesystem" diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 64f2a3a9d6..937963f4ca 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -1,6 +1,6 @@ export * as GrepTool from "./grep" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import path from "path" diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/patch.ts similarity index 92% rename from packages/core/src/tool/apply-patch.ts rename to packages/core/src/tool/patch.ts index aee442169e..e6a043a0dd 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/patch.ts @@ -1,6 +1,6 @@ -export * as ApplyPatchTool from "./apply-patch" +export * as PatchTool from "./patch" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" @@ -12,7 +12,7 @@ import { Patch } from "../patch" import { PermissionV2 } from "../permission" import { Tool } from "./tool" -export const name = "apply_patch" +export const name = "patch" export const Input = Schema.Struct({ patchText: Schema.String.annotate({ @@ -55,8 +55,8 @@ type Prepared = }) export const Plugin = { - id: "opencode.tool.apply-patch", - effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) { + id: "opencode.tool.patch", + effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service const fs = yield* FSUtil.Service @@ -91,11 +91,11 @@ export const Plugin = { if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) const hunks = yield* Effect.try({ try: () => Patch.parse(input.patchText), - catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), + catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }), }) if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) - if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) + if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" }) const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] for (const hunk of hunks) @@ -194,6 +194,19 @@ export const Plugin = { ), ) .pipe(Effect.orDie) + + yield* ctx.session.hook("request", (event) => + Effect.sync(() => { + const usePatch = + event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt") + if (usePatch) { + delete event.tools.edit + delete event.tools.write + return + } + delete event.tools.patch + }), + ) }), } diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index a2ac9828c6..d5217f8505 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -1,6 +1,6 @@ export * as QuestionTool from "./question" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { Form } from "../form" diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index f6a7c474b5..001a29ba55 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -1,6 +1,6 @@ export * as ReadTool from "./read" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { dirname } from "path" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 0275cd8d87..cef6f3c071 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -191,12 +191,8 @@ const registryLayer = Layer.effect( const registration = entries.at(-1)?.registration if (registration) registrations.set(name, registration) } - // OpenAI/GPT models use apply_patch; every other model uses edit and write. - const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt") for (const [name, registration] of registrations) { - const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch if ( - wrongEditTool || (registration.deferred && !Flag.CODEMODE_ENABLED) || whollyDisabled(permission(registration.tool, name), input.permissions ?? []) ) diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index ca6b9f1969..d2b8bb4744 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -2,7 +2,7 @@ export * as ShellTool from "./shell" import path from "path" import { ToolFailure } from "@opencode-ai/llm" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 95c68cb257..1f80bd9684 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -1,6 +1,6 @@ export * as SkillTool from "./skill" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" @@ -13,11 +13,11 @@ export const name = "skill" const FILE_LIMIT = 10 export const Input = Schema.Struct({ - name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }), + id: SkillV2.ID.annotate({ description: "The ID of the skill from the available skills list" }), }) export const Output = Schema.Struct({ - name: Schema.String, + name: SkillV2.Name, directory: Schema.String, output: Schema.String, }) @@ -27,7 +27,7 @@ export const description = [ "", "Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.", "", - "The skill name must match one of the available skills in the instructions.", + "The skill ID must match one of the available skills in the instructions.", ].join("\n") export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) => { @@ -70,13 +70,13 @@ export const Plugin = { execute: (input, context) => Effect.gen(function* () { const current = yield* skills.list() - const skill = current.find((skill) => skill.name === input.name) - if (!skill) return yield* unableToLoad(input.name) + const skill = current.find((skill) => skill.id === input.id) + if (!skill) return yield* unableToLoad(input.id) return yield* Effect.gen(function* () { yield* permission.assert({ action: name, - resources: [skill.name], - save: [skill.name], + resources: [skill.id], + save: [skill.id], sessionID: context.sessionID, agent: context.agent, source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, @@ -94,7 +94,7 @@ export const Plugin = { directory, output: toModelOutput(skill, files), } - }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) + }).pipe(Effect.mapError((error) => unableToLoad(input.id, error))) }), }), ), diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 0f781c5e6c..e6c994c216 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -1,10 +1,11 @@ export * as SubagentTool from "./subagent" import { ToolFailure } from "@opencode-ai/llm" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { AgentV2 } from "../agent" import { PluginRuntime } from "../plugin/runtime" +import { PermissionV2 } from "../permission" import { SessionSchema } from "../session/schema" import { Tool } from "./tool" @@ -42,6 +43,7 @@ export const Plugin = { effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const agents = yield* AgentV2.Service + const permission = yield* PermissionV2.Service const scope = yield* Scope.Scope // Concatenate the child's final completed assistant text. Distinguishes "completed with no @@ -114,6 +116,20 @@ export const Plugin = { if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) if (agent.mode === "primary") return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` }) + yield* permission + .assert({ + action: name, + resources: [agent.id], + save: [agent.id], + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.assistantMessageID, + callID: context.toolCallID, + }, + }) + .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }))) // Model selection is policy/config/session state, not an LLM-facing tool argument. const model = agent.model ?? parent.model @@ -176,5 +192,32 @@ export const Plugin = { ), ) .pipe(Effect.orDie) + + yield* ctx.session.hook("request", (event) => + Effect.gen(function* () { + const tool = event.tools[name] + if (!tool) return + const selected = yield* agents.resolve(event.agent) + if (!selected) return + const available = (yield* agents.list()) + .filter( + (agent) => + agent.mode !== "primary" && + !agent.hidden && + PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny", + ) + .toSorted((a, b) => a.id.localeCompare(b.id)) + if (available.length === 0) return + tool.description = [ + tool.description, + "", + "Available subagents:", + ...available.map( + (agent) => + `- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`, + ), + ].join("\n") + }), + ) }), } diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index 189ee846b9..96c0f10f1a 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -1,6 +1,6 @@ export * as TodoWriteTool from "./todowrite" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { PermissionV2 } from "../permission" diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index d2efc61c73..d761645939 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,6 +1,6 @@ export * as WebFetchTool from "./webfetch" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 6678b19578..77f5d4f8af 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -1,6 +1,6 @@ export * as WebSearchTool from "./websearch" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { Integration } from "../integration" diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index c50a3a1e6d..a88063f4e2 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -6,7 +6,7 @@ */ export * as WriteTool from "./write" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { FileMutation } from "../file-mutation" diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 495c125a1e..0a4b7dadce 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -2,7 +2,7 @@ import type { LanguageModelV3CallOptions } from "@ai-sdk/provider" import { AISDK } from "@opencode-ai/core/aisdk" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { LLM } from "@opencode-ai/llm" +import { LLM, Message } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { expect } from "bun:test" import { Effect } from "effect" @@ -51,13 +51,11 @@ it.effect("projects request settings, headers, and body overlays", () => apiKey: "secret", thinkingConfig: { thinkingBudget: 1024 }, }) - const resolved = yield* aisdk.model( - { - ...input, - headers: { "x-test": "header" }, - body: { safety_setting: "strict" }, - }, - ) + const resolved = yield* aisdk.model({ + ...input, + headers: { "x-test": "header" }, + body: { safety_setting: "strict" }, + }) const prepared = yield* LLMClient.prepare( LLM.request({ model: resolved, prompt: "Hello" }), ) @@ -69,3 +67,54 @@ it.effect("projects request settings, headers, and body overlays", () => expect(body).toEqual({ safety_setting: "strict" }) }), ) + +it.effect("projects replay metadata onto AI SDK prompt parts", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const resolved = yield* aisdk.model(model("@ai-sdk/anthropic")) + expect(resolved.route.providerMetadataKey).toBe("anthropic") + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: resolved, + messages: [ + Message.assistant([ + { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } }, + { + type: "tool-call", + id: "hosted", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: { anthropic: { blockType: "server_tool_use" } }, + }, + ]), + ], + }), + ) + + expect(prepared.body.prompt).toEqual([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Think", + providerOptions: { anthropic: { signature: "signed" } }, + }, + { + type: "tool-call", + toolCallId: "hosted", + toolName: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerOptions: { anthropic: { blockType: "server_tool_use" } }, + }, + ], + }, + ]) + }), +) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 83f71c3398..ad667f288c 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect, Fiber, Layer, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" @@ -298,13 +299,31 @@ describe("CatalogV2", () => { catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] - model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(1), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] - model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(10), + output: Money.USDPerMillionTokens.make(10), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] model.time.released = Date.now() }) }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index f371e19543..f3d1defc68 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -4,6 +4,7 @@ import { describe, expect } from "bun:test" import { Effect, PubSub, Schema, Stream } from "effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { CommandV2 } from "@opencode-ai/core/command" +import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -87,7 +88,7 @@ Review files`, name: "review", template: "Review files", description: "File review", - agent: "reviewer", + agent: AgentV2.ID.make("reviewer"), model: { providerID: ProviderV2.ID.make("anthropic"), id: ModelV2.ID.make("claude"), diff --git a/packages/core/test/config/fixtures/plugin/directory-plugin.ts b/packages/core/test/config/fixtures/plugin/directory-plugin.ts index e26e12bdac..f5e15c2c00 100644 --- a/packages/core/test/config/fixtures/plugin/directory-plugin.ts +++ b/packages/core/test/config/fixtures/plugin/directory-plugin.ts @@ -1,6 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export default define({ +export default Plugin.define({ id: "directory-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts index afa7f5c37e..365d5566d2 100644 --- a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts +++ b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts @@ -1,6 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export default define({ +export default Plugin.define({ id: "folder-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 34a69ffbc5..2bfe1d6c74 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { describe, expect } from "bun:test" -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "@opencode-ai/core/agent" @@ -178,7 +178,7 @@ describe("PluginSupervisor config", () => { it.live("loads user plugins before internal post plugins", () => Effect.gen(function* () { const sdk = yield* SdkPlugins.Service - yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void })) + yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void })) yield* withLocation( { plugins: [ @@ -273,9 +273,9 @@ function withLocation( function mutablePlugin(description: string) { const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href return ` -import { define } from ${JSON.stringify(plugin)} +import { Plugin } from ${JSON.stringify(plugin)} -export default define({ +export default Plugin.define({ id: "mutable-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index d1d27dc6e9..0c7c85a1ca 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" @@ -253,7 +254,17 @@ describe("ConfigProviderPlugin.Plugin", () => { expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) expect(model.enabled).toBe(false) expect(model.limit).toEqual({ context: 100, output: 75 }) - expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) + expect(model.cost).toEqual([ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + tier: undefined, + }, + ]) expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true }) expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(model.variants?.map((variant) => variant.id)).toEqual([ diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 4f147c0f3d..10f02a7342 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url" import path from "path" import { SqliteClient } from "@effect/sql-sqlite-bun" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schema } from "effect" import { eq, inArray, sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" import { migrations } from "@opencode-ai/core/database/migration.gen" @@ -17,6 +17,7 @@ import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/ import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events" import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox" +import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -26,6 +27,7 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionSchema } from "@opencode-ai/core/session/schema" +import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionTable } from "@opencode-ai/core/session/sql" import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" @@ -42,6 +44,256 @@ const run = (effect: Effect.Effect) => const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { + test("migrates pre-launch V2 state in place", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`, + ) + yield* db.run( + sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`, + ) + const messages = [ + ["msg_skill", "skill", { name: "effect", text: "Use Effect", time: { created: 1 } }], + [ + "msg_shell", + "shell", + { + shell: { id: "sh_old", command: "pwd", status: "exited", exit: 0, cwd: "/tmp" }, + output: { output: "/tmp", cursor: 4, size: 4, truncated: false }, + time: { created: 2, completed: 3 }, + }, + ], + [ + "msg_assistant", + "assistant", + { + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_old", + name: "read", + provider: "removed", + state: { status: "pending", input: '{"path":"README.md"}', title: "removed" }, + time: { created: 3 }, + }, + ], + time: { created: 3 }, + }, + ], + [ + "msg_failed", + "compaction", + { + status: "failed", + reason: "manual", + summary: "removed", + recent: "removed", + time: { created: 4 }, + }, + ], + [ + "msg_queued", + "compaction", + { status: "queued", reason: "manual", summary: "", recent: "", time: { created: 5 } }, + ], + [ + "msg_synthetic", + "synthetic", + { sessionID: "ses_test", text: "context", description: "source", time: { created: 6 } }, + ], + [ + "msg_running", + "compaction", + { status: "running", reason: "auto", summary: "partial", recent: "recent", time: { created: 7 } }, + ], + [ + "msg_completed", + "compaction", + { status: "completed", reason: "auto", summary: "summary", recent: "recent", time: { created: 8 } }, + ], + ] as const + for (const [id, type, data] of messages) + yield* db.run( + sql`INSERT INTO session_message VALUES (${id}, 'ses_test', ${type}, 1, 10, 11, ${JSON.stringify(data)})`, + ) + yield* db.run( + sql`INSERT INTO session_input VALUES ('msg_queued', 'ses_test', 'compaction', NULL, NULL, 4, NULL, 5)`, + ) + yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 9, 'owner')`) + yield* db.run(sql`INSERT INTO instruction_checkpoint VALUES ('ses_test', 'baseline', '{"source":"value"}', 7)`) + const events = [ + ["evt_skill", 1, 101, "session.skill.activated.1", { sessionID: "ses_test", name: "effect", text: "Use" }], + ["evt_started", 2, 102, "session.compaction.started.1", { sessionID: "ses_test", reason: "auto" }], + ["evt_delta", 3, 103, "session.compaction.delta.1", { sessionID: "ses_test", text: "partial" }], + ["evt_failed", 4, 104, "session.compaction.failed.1", { sessionID: "ses_test" }], + [ + "evt_revert", + 5, + 105, + "session.revert.staged.1", + { + sessionID: "ses_test", + revert: { + messageID: "msg_skill", + snapshot: "tree", + diff: "removed", + files: [{ path: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }], + }, + }, + ], + [ + "evt_skill_current", + 6, + 106, + "session.skill.activated.2", + { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" }, + ], + ] as const + for (const [id, seq, created, type, data] of events) + yield* db.run( + sql`INSERT INTO event VALUES (${id}, 'ses_test', ${seq}, ${created}, ${type}, ${JSON.stringify(data)})`, + ) + + yield* DatabaseMigration.applyOnly(db, [migratePrelaunchV2StateMigration]) + + const rows = yield* db.all<{ + id: string + type: string + seq: number + time_created: number + time_updated: number + data: string + }>(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message ORDER BY id`) + for (const row of rows) + Schema.decodeUnknownSync(SessionMessage.Info)({ ...JSON.parse(row.data), id: row.id, type: row.type }) + expect(rows.every((row) => row.seq === 1 && row.time_created === 10 && row.time_updated === 11)).toBe(true) + expect(rows.map((row) => [row.id, JSON.parse(row.data)])).toEqual([ + [ + "msg_assistant", + expect.objectContaining({ + content: [expect.objectContaining({ state: { status: "streaming", input: '{"path":"README.md"}' } })], + }), + ], + ["msg_completed", expect.objectContaining({ status: "completed", summary: "summary", recent: "recent" })], + [ + "msg_failed", + { + time: { created: 4 }, + status: "failed", + reason: "manual", + error: { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + }, + ], + ["msg_running", expect.objectContaining({ status: "running", summary: "partial", recent: "recent" })], + ["msg_shell", expect.objectContaining({ shellID: "sh_old", command: "pwd", status: "exited", exit: 0 })], + ["msg_skill", { time: { created: 1 }, skill: "effect", name: "effect", text: "Use Effect" }], + ["msg_synthetic", { time: { created: 6 }, text: "context", description: "source" }], + ]) + expect(yield* db.get(sql`SELECT * FROM session_input`)).toEqual({ + id: "msg_queued", + session_id: "ses_test", + type: "compaction", + prompt: null, + delivery: null, + admitted_seq: 4, + promoted_seq: null, + time_created: 5, + }) + const migratedEvents = yield* db.all<{ + id: string + aggregate_id: string + seq: number + created: number + type: string + data: string + }>(sql`SELECT * FROM event ORDER BY seq`) + expect(migratedEvents.map((event) => ({ ...event, data: JSON.parse(event.data) }))).toEqual([ + { + id: "evt_skill", + aggregate_id: "ses_test", + seq: 1, + created: 101, + type: "session.skill.activated.1", + data: { sessionID: "ses_test", id: "effect", name: "effect", text: "Use" }, + }, + { + id: "evt_started", + aggregate_id: "ses_test", + seq: 2, + created: 102, + type: "session.compaction.started.1", + data: { sessionID: "ses_test", reason: "auto", recent: "" }, + }, + { + id: "evt_failed", + aggregate_id: "ses_test", + seq: 4, + created: 104, + type: "session.compaction.failed.1", + data: { + sessionID: "ses_test", + reason: "auto", + error: { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + }, + }, + { + id: "evt_revert", + aggregate_id: "ses_test", + seq: 5, + created: 105, + type: "session.revert.staged.1", + data: { + sessionID: "ses_test", + revert: { + messageID: "msg_skill", + snapshot: "tree", + files: [{ file: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }], + }, + }, + }, + { + id: "evt_skill_current", + aggregate_id: "ses_test", + seq: 6, + created: 106, + type: "session.skill.activated.1", + data: { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" }, + }, + ]) + expect(yield* db.get(sql`SELECT * FROM event_sequence`)).toEqual({ + aggregate_id: "ses_test", + seq: 9, + owner_id: "owner", + }) + expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({ + session_id: "ses_test", + baseline: "baseline", + snapshot: '{"source":"value"}', + baseline_seq: 7, + }) + }), + ) + }) + test("resets incompatible V2 Session event history", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/fixture/mcp-timeout.ts b/packages/core/test/fixture/mcp-timeout.ts index 15c851236c..4855b0ec26 100644 --- a/packages/core/test/fixture/mcp-timeout.ts +++ b/packages/core/test/fixture/mcp-timeout.ts @@ -4,16 +4,27 @@ import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, ListToolsRequestSchema, + ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js" -const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } }) +const server = new Server( + { name: "timeout", version: "1.0.0" }, + { capabilities: { prompts: {}, resources: {}, tools: {} } }, +) server.setRequestHandler(ListToolsRequestSchema, async () => { if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100) return { tools: [{ name: "slow", inputSchema: { type: "object" } }] } }) server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] })) +server.setRequestHandler(ListResourcesRequestSchema, async () => { + if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100) + return { resources: [{ name: "slow", uri: "test://slow" }] } +}) +server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] })) server.setRequestHandler(CallToolRequestSchema, async () => { await Bun.sleep(100) return { content: [] } @@ -22,5 +33,9 @@ server.setRequestHandler(GetPromptRequestSchema, async () => { await Bun.sleep(100) return { messages: [] } }) +server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + await Bun.sleep(100) + return { contents: [{ uri: request.params.uri, text: "slow" }] } +}) await server.connect(new StdioServerTransport()) diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts index 2c4d1f86a6..b247a4d8b7 100644 --- a/packages/core/test/fixture/mcp.ts +++ b/packages/core/test/fixture/mcp.ts @@ -14,15 +14,12 @@ export const emptyMcpLayer = Layer.succeed( instructions: () => Effect.succeed([]), prompts: () => Effect.succeed([]), prompt: () => Effect.succeed(undefined), - resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })), + resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })), readResource: () => Effect.succeed(undefined), }), ) -export const emptyConfigLayer = Layer.succeed( - Config.Service, - Config.Service.of({ entries: () => Effect.succeed([]) }), -) +export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) export const testLocationLayer = Layer.succeed( Location.Service, diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index f77e32fd8b..7441ff83db 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -146,7 +146,7 @@ describe("Git trees", () => { RelativePath.make("scope/tracked.txt"), ]) const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 }) - expect(diffs.map((item) => [item.path, item.status])).toEqual([ + expect(diffs.map((item) => [item.file, item.status])).toEqual([ [RelativePath.make("scope/added.txt"), "added"], [RelativePath.make("scope/tracked.txt"), "modified"], ]) @@ -154,7 +154,7 @@ describe("Git trees", () => { const files = new Map([[RelativePath.make("scope/tracked.txt"), before]]) const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 }) expect(preview).toHaveLength(1) - expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt")) yield* git.tree.restore({ repository, files }) expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n") expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n") diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 5677e288ef..c09d1e6296 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -4,8 +4,9 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { Tool } from "@opencode-ai/core/tool/tool" import { Tools } from "@opencode-ai/core/tool/tools" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, type Scope } from "effect" +import { host } from "../plugin/host" export const toolIdentity = { agent: AgentV2.ID.make("build"), @@ -48,7 +49,7 @@ export const registerToolPlugin = (plugin: { }): Effect.Effect => Effect.gen(function* () { const tools = yield* Tools.Service - const context: Pick = { + const context = host({ tool: { transform: (callback) => Effect.gen(function* () { @@ -66,15 +67,13 @@ export const registerToolPlugin = (plugin: { registrations, (registration) => tools.register({ [registration.name]: registration.tool }, registration.options), { discard: true }, - ) + ).pipe(Effect.orDie) + return { dispose: Effect.void } }), - execute: { - before: () => Effect.die("registerToolPlugin does not support tool hooks"), - after: () => Effect.die("registerToolPlugin does not support tool hooks"), - }, + hook: () => Effect.die("registerToolPlugin does not support tool hooks"), }, - } - yield* plugin.effect(context as PluginContext) + }) + yield* plugin.effect(context) }) export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index f945cb2265..dcfecdb0ae 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -3,8 +3,9 @@ import path from "path" import { describe, expect } from "bun:test" import { Config } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" -import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { Money } from "@opencode-ai/schema/money" +import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect" +import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -12,6 +13,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" @@ -28,8 +30,43 @@ import { Reference } from "../src/reference" import { ToolRegistry } from "../src/tool/registry" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node]))) +const itWithSdk = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])), +) describe("LocationServiceMap", () => { + itWithSdk.live("preserves embedded SDK plugins after Location eviction", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const sdk = yield* SdkPlugins.Service + const locations = yield* LocationServiceMap.Service + const id = AgentV2.ID.make("persistent-sdk-agent") + const plugin = EffectPlugin.define({ + id: "persistent-sdk-plugin", + effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})), + }) + yield* sdk.register(plugin) + + const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const read = Effect.gen(function* () { + const supervisor = yield* PluginSupervisor.Service + yield* supervisor.ready + const agents = yield* AgentV2.Service + return yield* agents.get(id) + }) + + expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined() + yield* locations.invalidate(ref) + expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined() + }), + ), + ), + ) + it.live("applies ordered plugin config operations during boot", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -172,6 +209,36 @@ describe("LocationServiceMap", () => { ), ) + it.live("normalizes ref key shapes to one cached location graph", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const directory = AbsolutePath.make(dir.path) + const absent = Location.Ref.make({ directory }) + const present = Location.Ref.make({ directory, workspaceID: undefined }) + // The two shapes are not structurally Equal: own-key sets differ. + expect(Object.keys(absent)).toEqual(["directory"]) + expect(Object.keys(present)).toEqual(["directory", "workspaceID"]) + expect(Equal.equals(absent, present)).toBe(false) + + const first = yield* locations.contextEffect(absent) + expect(yield* locations.contextEffect(present)).toBe(first) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1) + + // Invalidating with the shape opposite to the one that booted must evict. + yield* locations.invalidate(present) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0) + }), + ), + ), + ), + ) + it.live("isolates catalog state by location", () => Effect.acquireRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), @@ -224,6 +291,7 @@ describe("LocationServiceMap", () => { "edit", "glob", "grep", + "patch", "question", "read", "shell", @@ -241,6 +309,7 @@ describe("LocationServiceMap", () => { "edit", "glob", "grep", + "patch", "question", "read", "shell", @@ -288,7 +357,7 @@ describe("LocationServiceMap", () => { id: ModelV2.ID.make("chat"), providerID: ProviderV2.ID.make("unavailable"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location, @@ -337,7 +406,7 @@ describe("LocationServiceMap", () => { providerID: ProviderV2.ID.make("aliased"), variant: ModelV2.VariantID.make("high"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location, @@ -366,7 +435,7 @@ describe("LocationServiceMap", () => { Effect.flatMap((dir) => Effect.gen(function* () { const plugins = yield* PluginV2.Service - const reviewer = define({ + const reviewer = EffectPlugin.define({ id: "reviewer", effect: (ctx) => ctx.agent diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 26977a8729..fbbbb97259 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -3,26 +3,169 @@ import { describe, expect, test } from "bun:test" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js" import { ConfigMCP } from "@opencode-ai/core/config/mcp" +import { Config } from "@opencode-ai/core/config" +import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { Form } from "@opencode-ai/core/form" +import { Integration } from "@opencode-ai/core/integration" +import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" import { MCPClient } from "@opencode-ai/core/mcp/client" import { PermissionV2 } from "@opencode-ai/core/permission" +import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { McpTool } from "@opencode-ai/core/tool/mcp" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { Deferred, Effect, Fiber, Layer, Stream } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" +import { location } from "./fixture/location" import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void let calls = 0 +type ResourcePage = { + items: Array<{ name: string; uri: string; description?: string; mimeType?: string }> + nextCursor?: string +} + +type ResourceTemplatePage = { + items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }> + nextCursor?: string +} + +function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) { + return Effect.acquireRelease( + Effect.promise(async () => { + const state = { + resources: [] as ResourcePage["items"], + templates: [] as ResourceTemplatePage["items"], + resourcePages: undefined as Record | undefined, + templatePages: undefined as Record | undefined, + contents: [ + { uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>, + resourceLists: 0, + templateLists: 0, + } + const protocol = new Server( + { name: "mcp-resources", version: "1.0.0" }, + { + capabilities: { + tools: {}, + ...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }), + }, + }, + ) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + if (input.resources !== false) { + protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { + state.resourceLists += 1 + const page = state.resourcePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => { + state.templateLists += 1 + const page = state.templatePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents })) + } + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + const http = Bun.serve({ + port: 0, + fetch: (request) => transport.handleRequest(request), + }) + return { + state, + url: http.url.toString(), + sendResourceListChanged: () => protocol.sendResourceListChanged(), + close: async () => { + await protocol.close().catch(() => {}) + await http.stop(true) + }, + } + }), + (server) => Effect.promise(server.close), + ) +} + +function resourceMcpLayer(url: string) { + const directory = AbsolutePath.make(import.meta.dir) + const unusedIntegration = () => Effect.die("unused integration service") + return MCP.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: new Config.Info({ + mcp: new ConfigMCP.Info({ + servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) }, + }), + }), + }), + ]), + }), + ), + Layer.succeed(Location.Service, Location.Service.of(location({ directory }))), + Layer.mock(EventV2.Service, { + subscribe: () => Stream.never, + publish: (definition, data) => + Effect.succeed({ + id: EventV2.ID.create(), + type: definition.type, + data, + } as EventV2.Payload), + }), + Layer.mock(Form.Service, {}), + Layer.mock(Integration.Service, { + connection: { + active: unusedIntegration, + resolve: unusedIntegration, + key: unusedIntegration, + oauth: unusedIntegration, + update: unusedIntegration, + remove: unusedIntegration, + }, + attempt: { + status: unusedIntegration, + complete: unusedIntegration, + cancel: unusedIntegration, + }, + search: { + list: unusedIntegration, + get: unusedIntegration, + }, + }), + Layer.mock(Credential.Service, {}), + ), + ), + ) +} + const mcp = Layer.mock(MCP.Service, { tools: () => Effect.succeed([ @@ -241,6 +384,163 @@ test("applies the configured MCP execution timeout to prompts", async () => { await expect(result).rejects.toThrow("Request timed out") }) +test("applies configured MCP timeouts to resource operations", async () => { + const catalog = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "resource-catalog-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + environment: { MCP_TIMEOUT_TARGET: "resource-catalog" }, + timeout: new ConfigMCP.Timeout({ catalog: 10 }), + }), + import.meta.dir, + ) + return yield* connection.resources() + }), + ), + ) + await expect(catalog).rejects.toThrow("Request timed out") + + const read = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "resource-read-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + timeout: new ConfigMCP.Timeout({ execution: 10 }), + }), + import.meta.dir, + ) + return yield* connection.readResource({ uri: "test://slow" }) + }), + ), + ) + await expect(read).rejects.toThrow("Request timed out") +}) + +test("lists, reads, and reports MCP resource changes", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer({ listChanged: true }) + server.state.resourcePages = { + initial: { + items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }], + nextCursor: "resources-2", + }, + "resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] }, + } + server.state.templatePages = { + initial: { + items: [{ name: "File", uriTemplate: "docs://{path}" }], + nextCursor: "templates-2", + }, + "templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] }, + } + const connection = yield* MCPClient.connect( + "resources", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }), + import.meta.dir, + ) + + expect(yield* connection.resources()).toEqual([ + { name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined }, + { name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" }, + ]) + expect(yield* connection.resourceTemplates()).toEqual([ + { name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined }, + { name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined }, + ]) + expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({ + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }) + + const changed = yield* Deferred.make() + connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void)) + yield* Effect.promise(server.sendResourceListChanged) + yield* Deferred.await(changed) + }), + ), + ) +}) + +test("skips MCP resource requests when the capability is absent", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer({ resources: false }) + const connection = yield* MCPClient.connect( + "resources", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }), + import.meta.dir, + ) + expect(yield* connection.resources()).toEqual([]) + expect(yield* connection.resourceTemplates()).toEqual([]) + expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined() + expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({ + resources: 0, + templates: 0, + }) + }), + ), + ) +}) + +test("loads and reads MCP resources", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer() + server.state.resources = [{ name: "Readme", uri: "docs://readme" }] + server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }] + + yield* Effect.gen(function* () { + const service = yield* MCP.Service + expect(yield* service.resourceCatalog()).toEqual({ + resources: [ + { + server: "resources", + name: "Readme", + uri: "docs://readme", + description: undefined, + mimeType: undefined, + }, + ], + templates: [ + { + server: "resources", + name: "File", + uriTemplate: "docs://{path}", + description: undefined, + mimeType: undefined, + }, + ], + }) + + server.state.resources = [{ name: "Guide", uri: "docs://guide" }] + expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"]) + expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({ + server: "resources", + uri: "docs://readme", + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }) + }).pipe(Effect.provide(resourceMcpLayer(server.url))) + }), + ), + ) +}) + it.effect("advertises MCP output schemas to Code Mode", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts new file mode 100644 index 0000000000..8927df9a05 --- /dev/null +++ b/packages/core/test/plugin-hooks.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test" +import { Message, SystemPart } from "@opencode-ai/llm" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" +import { Session } from "@opencode-ai/schema/session" +import { Effect, Layer } from "effect" +import { PluginHooks } from "../src/plugin/hooks" + +describe("PluginHooks", () => { + it("registers scoped domain hooks and triggers them sequentially", async () => { + const seen: string[] = [] + const program = Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "request", (event) => + Effect.sync(() => { + seen.push("first") + event.system.push(SystemPart.make("second")) + }), + ) + yield* hooks.register("session", "request", (event) => + Effect.sync(() => { + seen.push(event.system[1]?.text ?? "missing") + event.messages = [Message.user("changed")] + }), + ) + const event = { + sessionID: Session.ID.make("ses_hooks"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), + system: [SystemPart.make("first")], + messages: [Message.user("original")], + tools: {}, + } + + expect(yield* hooks.trigger("session", "request", event)).toBe(event) + expect(seen).toEqual(["first", "second"]) + expect(event.messages).toEqual([Message.user("changed")]) + }) + + await Effect.runPromise( + Effect.scoped(program).pipe( + Effect.provide(PluginHooks.node.implementation as Layer.Layer), + ), + ) + }) +}) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 5d87102b7d..79fcc5d0f6 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "@opencode-ai/core/agent" @@ -49,7 +49,7 @@ describe("PluginV2", () => { .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) const managed = () => - define({ + EffectPlugin.define({ id: "managed", effect: (ctx) => ctx.agent @@ -97,25 +97,40 @@ describe("PluginV2", () => { }), ) - it.effect("retries the same generation after materialization fails", () => + it.effect("skips failed plugins and loads the rest", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service let fail = true - const plugin = define({ - id: "retry", + const good = EffectPlugin.define({ + id: "good", effect: (ctx) => ctx.agent - .transform(() => { - if (fail) throw new Error("materialization failed") - }) + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = "loaded" + }), + ) .pipe(Effect.asVoid), }) + const bad = EffectPlugin.define({ + id: "bad", + effect: () => { + if (fail) return Effect.die(new Error("materialization failed")) + return Effect.void + }, + }) + + yield* plugins.activate([{ plugin: good }, { plugin: bad }]) + expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded") - expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true) fail = false - yield* plugins.activate([{ plugin }]) - - expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }]) + yield* plugins.activate([{ plugin: good }, { plugin: bad }]) + expect(yield* plugins.list()).toEqual([ + { id: Plugin.ID.make("good") }, + { id: Plugin.ID.make("bad") }, + ]) }), ) @@ -142,7 +157,7 @@ describe("PluginV2", () => { Effect.gen(function* () { const plugins = yield* PluginV2.Service let visible = true - const plugin = define({ + const plugin = EffectPlugin.define({ id: "isolated", effect: () => Effect.serviceOption(Secret).pipe( @@ -161,7 +176,7 @@ describe("PluginV2", () => { Effect.gen(function* () { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service - const plugin = define({ + const plugin = EffectPlugin.define({ id: "tool-plugin", effect: (ctx) => ctx.tool @@ -202,7 +217,7 @@ describe("PluginV2", () => { output: Schema.Struct({ ok: Schema.Boolean }), execute: () => Effect.succeed({ ok: true }), }) - const plugin = define({ + const plugin = EffectPlugin.define({ id: "grouped-tools", effect: (ctx) => ctx.tool @@ -234,7 +249,7 @@ describe("PluginV2", () => { after?: { input: unknown; result: unknown; output: unknown } } = {} - const plugin = define({ + const plugin = EffectPlugin.define({ id: "tool-hooks", effect: (ctx) => Effect.gen(function* () { @@ -252,19 +267,23 @@ describe("PluginV2", () => { ) .pipe(Effect.orDie) - yield* ctx.tool.execute - .before((event) => { - seen.before = event.input - event.input = { text: "before-mutated" } - }) + yield* ctx.tool + .hook("execute.before", (event) => + Effect.sync(() => { + seen.before = event.input + event.input = { text: "before-mutated" } + }), + ) .pipe(Effect.asVoid) - yield* ctx.tool.execute - .after((event) => { - seen.after = { input: event.input, result: event.result, output: event.output } - event.result = { type: "text", value: "after-mutated" } - event.output = { structured: { rewritten: true }, content: [] } - }) + yield* ctx.tool + .hook("execute.after", (event) => + Effect.sync(() => { + seen.after = { input: event.input, result: event.result, output: event.output } + event.result = { type: "text", value: "after-mutated" } + event.output = { structured: { rewritten: true }, content: [] } + }), + ) .pipe(Effect.asVoid) }), }) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index ea6e65bb64..ffef32738d 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -13,6 +13,7 @@ import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { Npm } from "@opencode-ai/core/npm" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { Reference } from "@opencode-ai/core/reference" import { SkillV2 } from "@opencode-ai/core/skill" @@ -46,6 +47,7 @@ export const PluginTestLayer = AppNodeBuilder.build( CommandV2.node, Integration.node, PluginRuntime.node, + PluginHooks.node, Reference.node, SkillV2.node, ToolHooks.node, diff --git a/packages/core/test/plugin/fixtures/config-effect-plugin.ts b/packages/core/test/plugin/fixtures/config-effect-plugin.ts index a5f12a113d..e607b658e3 100644 --- a/packages/core/test/plugin/fixtures/config-effect-plugin.ts +++ b/packages/core/test/plugin/fixtures/config-effect-plugin.ts @@ -1,7 +1,7 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export default define({ +export default Plugin.define({ id: "config-effect-plugin", effect: (ctx) => ctx.agent diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts index ed53e4b947..0fef8fbc65 100644 --- a/packages/core/test/plugin/fixtures/config-promise-plugin.ts +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -1,6 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export default define({ +export default Plugin.define({ id: "config-promise-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/plugin/fixtures/failing-plugin.ts b/packages/core/test/plugin/fixtures/failing-plugin.ts index 4daac0acd0..dac49b410d 100644 --- a/packages/core/test/plugin/fixtures/failing-plugin.ts +++ b/packages/core/test/plugin/fixtures/failing-plugin.ts @@ -1,7 +1,7 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export default define({ +export default Plugin.define({ id: "failing-plugin", effect: () => Effect.die("plugin failed"), }) diff --git a/packages/core/test/plugin/fixtures/variant-source-plugin.ts b/packages/core/test/plugin/fixtures/variant-source-plugin.ts index f799c96888..5dae699f6e 100644 --- a/packages/core/test/plugin/fixtures/variant-source-plugin.ts +++ b/packages/core/test/plugin/fixtures/variant-source-plugin.ts @@ -1,8 +1,8 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect } from "effect" -export default define({ +export default Plugin.define({ id: "variant-source", effect: (ctx) => ctx.catalog diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 81a5dae6b0..2a025c814c 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,4 +1,5 @@ -import type { IntegrationDefinition, IntegrationMethodRegistration, PluginContext } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { IntegrationDefinition, IntegrationMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" @@ -13,9 +14,9 @@ import type { } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" -type Overrides = Partial> +type Overrides = Partial> -export function host(overrides: Overrides = {}): PluginContext { +export function host(overrides: Overrides = {}): Plugin.Context { return { options: {}, agent: overrides.agent ?? { @@ -24,8 +25,7 @@ export function host(overrides: Overrides = {}): PluginContext { reload: () => Effect.die("unused agent.reload"), }, aisdk: overrides.aisdk ?? { - sdk: () => Effect.die("unused aisdk.sdk"), - language: () => Effect.die("unused aisdk.language"), + hook: () => Effect.die("unused aisdk.hook"), }, catalog: overrides.catalog ?? { provider: { @@ -50,11 +50,15 @@ export function host(overrides: Overrides = {}): PluginContext { integration: overrides.integration ?? { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), - connectKey: () => Effect.die("unused integration.connectKey"), - connectOauth: () => Effect.die("unused integration.connectOauth"), - attemptStatus: () => Effect.die("unused integration.attemptStatus"), - attemptComplete: () => Effect.die("unused integration.attemptComplete"), - attemptCancel: () => Effect.die("unused integration.attemptCancel"), + connect: { + key: () => Effect.die("unused integration.connect.key"), + oauth: () => Effect.die("unused integration.connect.oauth"), + }, + attempt: { + status: () => Effect.die("unused integration.attempt.status"), + complete: () => Effect.die("unused integration.attempt.complete"), + cancel: () => Effect.die("unused integration.attempt.cancel"), + }, register: () => Effect.die("unused integration.register"), transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), @@ -78,10 +82,7 @@ export function host(overrides: Overrides = {}): PluginContext { }, tool: overrides.tool ?? { transform: () => Effect.die("unused tool.transform"), - execute: { - before: () => Effect.die("unused tool.execute.before"), - after: () => Effect.die("unused tool.execute.after"), - }, + hook: () => Effect.die("unused tool.hook"), }, session: overrides.session ?? { create: () => Effect.die("unused session.create"), @@ -89,11 +90,14 @@ export function host(overrides: Overrides = {}): PluginContext { prompt: () => Effect.die("unused session.prompt"), command: () => Effect.die("unused session.command"), interrupt: () => Effect.die("unused session.interrupt"), + // Plugins register session hooks during setup, so a bare host accepts the + // registration; the callback only runs when a test triggers the request pipeline. + hook: () => Effect.succeed({ dispose: Effect.void }), }, } } -export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { +export function agentHost(agent: AgentV2.Interface): Plugin.Context["agent"] { return { list: () => Effect.die("unused agent.list"), reload: agent.reload, @@ -118,7 +122,7 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { } } -export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] { +export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog"] { return { provider: { list: () => Effect.die("unused catalog.provider.list"), @@ -190,15 +194,19 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog" } } -export function integrationHost(integration: Integration.Interface): PluginContext["integration"] { +export function integrationHost(integration: Integration.Interface): Plugin.Context["integration"] { return { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), - connectKey: () => Effect.die("unused integration.connectKey"), - connectOauth: () => Effect.die("unused integration.connectOauth"), - attemptStatus: () => Effect.die("unused integration.attemptStatus"), - attemptComplete: () => Effect.die("unused integration.attemptComplete"), - attemptCancel: () => Effect.die("unused integration.attemptCancel"), + connect: { + key: () => Effect.die("unused integration.connect.key"), + oauth: () => Effect.die("unused integration.connect.oauth"), + }, + attempt: { + status: () => Effect.die("unused integration.attempt.status"), + complete: () => Effect.die("unused integration.attempt.complete"), + cancel: () => Effect.die("unused integration.attempt.cancel"), + }, reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 545e09e0c3..c336666c86 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1,5 +1,6 @@ import path from "path" import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect, Layer } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" @@ -51,23 +52,31 @@ describe("ModelsDevPlugin", () => { temperature: true, tool_call: true, cost: { - input: 2.5, - output: 15, + input: Money.USDPerMillionTokens.make(2.5), + output: Money.USDPerMillionTokens.make(15), tiers: [ { tier: { type: "context", size: 272_000 }, - input: 3, - output: 18, - cache_read: 0.25, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(18), + cache_read: Money.USDPerMillionTokens.make(0.25), }, ], - context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 }, + context_over_200k: { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(22.5), + cache_read: Money.USDPerMillionTokens.make(0.5), + }, }, limit: { context: 1_050_000, input: 922_000, output: 128_000 }, experimental: { modes: { fast: { - cost: { input: 5, output: 30, cache_read: 0.5 }, + cost: { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(30), + cache_read: Money.USDPerMillionTokens.make(0.5), + }, provider: { headers: { "x-mode": "fast" }, body: { service_tier: "priority" }, @@ -107,18 +116,31 @@ describe("ModelsDevPlugin", () => { variants: [], }) expect(fast?.cost).toEqual([ - { input: 5, output: 30, cache: { read: 0.5, write: 0 } }, + { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(30), + cache: { + read: Money.USDPerMillionTokens.make(0.5), + write: Money.USDPerMillionTokens.zero, + }, + }, { tier: { type: "context", size: 272_000 }, - input: 3, - output: 18, - cache: { read: 0.25, write: 0 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(18), + cache: { + read: Money.USDPerMillionTokens.make(0.25), + write: Money.USDPerMillionTokens.zero, + }, }, { tier: { type: "context", size: 200_000 }, - input: 5, - output: 22.5, - cache: { read: 0.5, write: 0 }, + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(22.5), + cache: { + read: Money.USDPerMillionTokens.make(0.5), + write: Money.USDPerMillionTokens.zero, + }, }, ]) }), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 50253ac681..ac2093ee28 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -5,7 +5,7 @@ import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginPromise } from "@opencode-ai/core/plugin/promise" -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -17,7 +17,7 @@ describe("fromPromise", () => { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) const seen: string[] = [] - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-client-reads", setup: async (ctx) => { const results = await Promise.all([ @@ -47,7 +47,7 @@ describe("fromPromise", () => { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-example", setup: async (ctx) => { expect(ctx.options.mode).toBe("strict") @@ -76,7 +76,7 @@ describe("fromPromise", () => { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-dispose", setup: async (ctx) => { const registration = await ctx.agent.transform((draft) => { @@ -100,7 +100,7 @@ describe("fromPromise", () => { const integrations = yield* Integration.Service const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-search", setup: async (ctx) => { await ctx.integration.register({ diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index f20091d514..13109550c7 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,4 +1,5 @@ import { AISDK } from "@opencode-ai/core/aisdk" +import { Money } from "@opencode-ai/schema/money" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -185,7 +186,16 @@ describe("OpenAIPlugin", () => { draft.package = item.package }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => { - model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.make(0.1), + write: Money.USDPerMillionTokens.zero, + }, + }, + ] }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index c8b3757d3d..f8334026f9 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" @@ -65,7 +66,16 @@ function withEnv(vars: Record, effect: () = ) } -const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] +const cost = (input: number, output = 0) => [ + { + input: Money.USDPerMillionTokens.make(input), + output: Money.USDPerMillionTokens.make(output), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, +] describe("OpencodePlugin", () => { it.effect("registers account and service account methods", () => diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 2bcf675052..de0f745183 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -37,17 +37,19 @@ describe("SkillPlugin.Plugin", () => { Effect.provide(NodeFileSystem.layer), ) const skills = yield* skill.list() - const report = skills.find((item) => item.name === "report") + const report = skills.find((item) => item.id === "report") expect(skills).toContainEqual( expect.objectContaining({ - name: "opencode", + id: "opencode", + name: "OpenCode", description: expect.stringContaining("any question about OpenCode itself"), }), ) expect(skills).toContainEqual( expect.objectContaining({ - name: "report", + id: "report", + name: "Report", description: expect.stringContaining("opencode issue"), }), ) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 40d9f48a19..703b098f63 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -104,13 +105,10 @@ describe("SessionV2.compact", () => { expect(second.id).toBe(first.id) expect(requests).toHaveLength(0) - expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({ - type: "compaction", - status: "queued", - reason: "manual", - summary: "", - recent: "", + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, created.id)).toMatchObject({ + id: first.id, }) + expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined() }), ) }) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 1cfd353f54..e8fb3addec 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -52,6 +52,15 @@ const it = testEffect( ), ) +test("compaction prompt preserves detailed work state and relevant files", () => { + const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] }) + + expect(prompt).toContain("## Work State\n### Completed") + expect(prompt).toContain("### Active") + expect(prompt).toContain("### Blocked") + expect(prompt).toContain("## Relevant Files") +}) + test("compaction describes tool media without embedding base64", () => { const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" const serialized = SessionCompaction.serializeToolContent([ @@ -74,13 +83,14 @@ test("compaction prompt requires the checkpoint headings in order", () => { "## Objective", "## Important Details", "## Work State", + "### Completed", + "### Active", + "### Blocked", "## Next Move", + "## Relevant Files", ]) expect(prompt).toContain("one or two brief sentences") expect(prompt).toContain("constraints/preferences, decisions and why") - expect(prompt).toContain("Completed:") - expect(prompt).toContain("Active:") - expect(prompt).toContain("Blocked:") expect(prompt).toContain("immediate concrete action") expect(prompt).toContain("next action if known") expect(prompt).toContain("Keep every section, even when empty.") @@ -131,7 +141,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () => .subscribe(SessionEvent.Compaction.Delta) .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true) + expect( + yield* compaction.compactManual({ + session, + messages: [userMessage], + inputID: SessionMessage.ID.make("msg_manual_compaction"), + }), + ).toBe(true) expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"]) expect(requests).toHaveLength(1) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 0a2e57edf5..3c4bb65a44 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import path from "path" import { DateTime, Effect, Layer, Stream } from "effect" +import { Money } from "@opencode-ai/schema/money" import { AgentV2 } from "@opencode-ai/core/agent" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" @@ -210,7 +211,7 @@ describe("SessionV2.create", () => { expect(forked.parentID).toBeUndefined() expect(forkContext).toMatchObject([ { type: "user", text: "First" }, - { type: "synthetic", text: "parent note", sessionID: forked.id }, + { type: "synthetic", text: "parent note" }, ]) expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id)) expect(history).toHaveLength(1) @@ -225,9 +226,17 @@ describe("SessionV2.create", () => { promotedSeq: 2, }) - yield* session.prompt({ sessionID: parent.id, prompt: PromptInput.Prompt.make({ text: "Parent changed" }), resume: false }) + yield* session.prompt({ + sessionID: parent.id, + prompt: PromptInput.Prompt.make({ text: "Parent changed" }), + resume: false, + }) yield* SessionInput.promoteSteers(db, events, parent.id) - yield* session.prompt({ sessionID: forked.id, prompt: PromptInput.Prompt.make({ text: "Child continues" }), resume: false }) + yield* session.prompt({ + sessionID: forked.id, + prompt: PromptInput.Prompt.make({ text: "Child continues" }), + resume: false, + }) yield* SessionInput.promoteSteers(db, events, forked.id) expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) @@ -260,8 +269,25 @@ describe("SessionV2.create", () => { resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) + const assistantMessageID = SessionMessage.ID.create() + const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) + yield* events.publish(SessionEvent.Step.Started, { + sessionID: parent.id, + assistantMessageID, + agent: AgentV2.ID.make("build"), + model, + }) + yield* events.publish(SessionEvent.Step.Ended, { + sessionID: parent.id, + assistantMessageID, + finish: "stop", + cost: Money.USD.make(0.75), + tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, + }) const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id }) + const beforeFirst = yield* session.fork({ sessionID: parent.id, messageID: first.id }) + const complete = yield* session.fork({ sessionID: parent.id }) const context = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) @@ -269,6 +295,13 @@ describe("SessionV2.create", () => { expect(context).toMatchObject([{ text: "First" }]) expect(context[0]?.id).not.toBe(first.id) expect(history[0]).toMatchObject({ data: { from: second.id } }) + expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) + expect(yield* session.context(beforeFirst.id)).toEqual([]) + expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) + expect(complete).toMatchObject({ + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) }), ) @@ -375,7 +408,11 @@ describe("SessionV2.create", () => { const events = yield* EventV2.Service const { db } = yield* Database.Service const created = yield* session.create({ location }) - yield* session.prompt({ sessionID: created.id, prompt: PromptInput.Prompt.make({ text: "Hello" }), resume: false }) + yield* session.prompt({ + sessionID: created.id, + prompt: PromptInput.Prompt.make({ text: "Hello" }), + resume: false, + }) yield* SessionInput.promoteSteers(db, events, created.id) expect( @@ -497,7 +534,7 @@ describe("SessionV2.create", () => { const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") - expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } }) + expect(shell).toMatchObject({ type: "shell", command: "echo hello", status: "exited", exit: 0 }) expect(shell?.output?.output).toContain("hello") expect(shell?.output?.truncated).toBe(false) expect(shell?.time.completed).toBeDefined() @@ -517,8 +554,8 @@ describe("SessionV2.create", () => { const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") - expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } }) - expect(shell?.shell.exit).not.toBe(0) + expect(shell).toMatchObject({ type: "shell", command: "false", status: "exited" }) + expect(shell?.exit).not.toBe(0) expect(shell?.time.completed).toBeDefined() }), ), @@ -529,7 +566,7 @@ describe("SessionV2.create", () => { const session = yield* SessionV2.Service const created = yield* session.create({ location }) - yield* session.switchAgent({ sessionID: created.id, agent: "plan" }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("plan") }) expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) expect( @@ -544,7 +581,7 @@ describe("SessionV2.create", () => { const missing = SessionV2.ID.make("ses_missing_agent_switch") expect( - yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe( + yield* session.switchAgent({ sessionID: missing, agent: AgentV2.ID.make("plan") }).pipe( Effect.flip, Effect.map((error) => error._tag), ), diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 854cd73d4b..34439836a4 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -303,7 +303,6 @@ describe("SessionInstructions", () => { const synthetic = SessionMessage.Synthetic.make({ id: SessionMessage.ID.make("msg_synthetic"), type: "synthetic", - sessionID: SessionV2.ID.make("ses_test"), text: "Instructions from: /repo/sub/AGENTS.md\ncontent", description: "Loaded /repo/sub/AGENTS.md", metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } }, diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index ac2fe59496..a6c7797e0f 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema, Stream } from "effect" import { Database } from "@opencode-ai/core/database/database" +import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -87,11 +88,11 @@ describe("SessionV2.log", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const created = yield* session.create({ location }) - yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("one") }) // Not in the durable manifest, so reads must skip it without failing. yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" }) - yield* session.switchAgent({ sessionID: created.id, agent: "two" }) - yield* session.switchAgent({ sessionID: created.id, agent: "three" }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("two") }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("three") }) const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 }))) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index ce30170362..1eb7f6b6fd 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Schema } from "effect" -import { asc, eq } from "drizzle-orm" +import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect" +import { asc, eq, sql } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { AgentV2 } from "@opencode-ai/core/agent" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" @@ -15,9 +16,11 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/schema/prompt" +import { Money } from "@opencode-ai/schema/money" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { fromRow } from "@opencode-ai/core/session/info" import { SessionInput } from "@opencode-ai/core/session/input" import { Shell } from "@opencode-ai/schema/shell" import { @@ -35,23 +38,27 @@ const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") } -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const encodeMessage = Schema.encodeSync(SessionMessage.Info) +const build = AgentV2.defaultID const assistantRow = ( id: SessionMessage.ID, seq: number, time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created }, + usage?: Pick, ) => { const { id: _, type, ...data - } = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time })) + } = encodeMessage( + SessionMessage.Assistant.make({ id, type: "assistant", agent: build, model, content: [], time, ...usage }), + ) return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } } describe("SessionProjector", () => { - it.effect("projects staged, cleared, and committed reverts", () => + it.effect("does not settle a pending manual compaction on an auto failure", () => Effect.gen(function* () { const db = (yield* Database.Service).db yield* db @@ -69,14 +76,132 @@ describe("SessionProjector", () => { version: "test", }) .run() + const events = yield* EventV2.Service + const inputID = SessionMessage.ID.make("msg_manual_compaction") + yield* SessionInput.admitCompaction(db, events, { id: inputID, sessionID }) + + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "auto", + error: { type: "compaction.failed", message: "Auto compaction failed" }, + }) + + expect(yield* SessionInput.pendingCompaction(db, sessionID)).toMatchObject({ id: inputID }) + }), + ) + + it.effect("loads legacy revert storage into canonical state", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + const legacy = JSON.stringify({ + messageID: "msg_boundary", + snapshot: "tree", + diff: "legacy patch", + files: [{ path: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }], + }) + yield* db.run(sql`update session set revert = ${legacy} where id = ${sessionID}`) + const stored = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get() + if (!stored) return yield* Effect.die("Session row missing") + const storedRevert = fromRow(stored).revert + expect(String(storedRevert?.messageID)).toBe("msg_boundary") + expect(String(storedRevert?.snapshot)).toBe("tree") + expect(storedRevert?.files).toEqual([ + { file: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }, + ]) + }), + ) + + it.effect("folds live compaction deltas into running memory state", () => + Effect.gen(function* () { + const state = { + messages: [ + SessionMessage.CompactionRunning.make({ + id: SessionMessage.ID.make("msg_compaction"), + type: "compaction", + status: "running", + reason: "manual", + summary: "partial ", + recent: "recent", + time: { created }, + }), + ], + } + yield* SessionMessageUpdater.update( + SessionMessageUpdater.memory(state), + SessionEvent.Compaction.Delta.make({ + id: EventV2.ID.make("evt_delta"), + type: "session.compaction.delta", + created, + data: { sessionID, text: "summary" }, + }), + ) + expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" }) + }), + ) + + it.effect("projects staged, cleared, and committed reverts", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + cost: 1.25, + tokens_input: 10, + tokens_output: 4, + tokens_reasoning: 2, + tokens_cache_read: 3, + tokens_cache_write: 1, + }) + .run() const boundary = SessionMessage.ID.make("msg_boundary") const earlier = SessionMessage.ID.make("msg_earlier") yield* db .insert(SessionMessageTable) .values([ assistantRow(earlier, 0), - assistantRow(boundary, 1), - assistantRow(SessionMessage.ID.make("msg_later"), 2), + assistantRow( + boundary, + 1, + { created }, + { + cost: Money.USD.make(0.5), + tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, + }, + ), + assistantRow( + SessionMessage.ID.make("msg_later"), + 2, + { created }, + { + cost: Money.USD.make(0.75), + tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, + }, + ), ]) .run() yield* db @@ -86,7 +211,7 @@ describe("SessionProjector", () => { const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, - revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] }, + revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] }, }) expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ messageID: boundary, @@ -106,6 +231,14 @@ describe("SessionProjector", () => { expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), ).toEqual([earlier]) + expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({ + cost: Money.USD.make(1.25), + tokens_input: 10, + tokens_output: 4, + tokens_reasoning: 2, + tokens_cache_read: 3, + tokens_cache_write: 1, + }) // A committed revert resets the context checkpoint so the next turn re-initializes. expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined() }), @@ -252,7 +385,7 @@ describe("SessionProjector", () => { yield* events.publish(SessionEvent.AgentSelected, { sessionID, - agent: "build", + agent: build, }) yield* events.publish(SessionEvent.ModelSelected, { sessionID, @@ -294,6 +427,7 @@ describe("SessionProjector", () => { yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", + recent: "recent context", }) yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, @@ -303,18 +437,18 @@ describe("SessionProjector", () => { yield* db .select({ id: EventTable.id }) .from(EventTable) - .where(eq(EventTable.type, SessionEvent.Compaction.Delta.type)) + .where(sql`${EventTable.type} like 'session.compaction.delta.%'`) .all() .pipe(Effect.orDie), - ).toEqual([]) + ).toHaveLength(0) expect( yield* db - .select({ id: SessionMessageTable.id }) + .select({ data: SessionMessageTable.data }) .from(SessionMessageTable) .where(eq(SessionMessageTable.type, "compaction")) .all() .pipe(Effect.orDie), - ).toEqual([]) + ).toEqual([{ data: expect.objectContaining({ status: "running", summary: "", recent: "recent context" }) }]) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", @@ -330,7 +464,7 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }), ) expect(messages.map((message) => message.type)).toEqual([ @@ -346,7 +480,9 @@ describe("SessionProjector", () => { }) expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel }) expect(messages.find((message) => message.type === "shell")).toMatchObject({ - shell: { command: "pwd", status: "exited", exit: 0 }, + command: "pwd", + status: "exited", + exit: 0, output: { output: "/project", truncated: false }, time: { completed: DateTime.makeUnsafe(0) }, }) @@ -386,11 +522,7 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_creator_collision") - const { - id: _, - type, - ...data - } = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } }) + const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } }) yield* db .insert(SessionMessageTable) .values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data }) @@ -400,7 +532,7 @@ describe("SessionProjector", () => { .publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: id, - agent: "build", + agent: build, model, }) .pipe(Effect.exit) @@ -417,7 +549,7 @@ describe("SessionProjector", () => { const stale = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", - agent: "build", + agent: build, model, content: [], time: { created }, @@ -425,7 +557,7 @@ describe("SessionProjector", () => { const completed = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", - agent: "build", + agent: build, model, content: [], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, @@ -460,7 +592,7 @@ describe("SessionProjector", () => { const events = yield* EventV2.Service const first = SessionMessage.ID.make("msg_retry_first") const second = SessionMessage.ID.make("msg_retry_second") - yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model }) + yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model }) yield* events.publish(SessionEvent.RetryScheduled, { sessionID, assistantMessageID: first, @@ -470,7 +602,7 @@ describe("SessionProjector", () => { }) const decode = (row: typeof SessionMessageTable.$inferSelect) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }) + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }) const firstRow = yield* db .select() .from(SessionMessageTable) @@ -482,7 +614,7 @@ describe("SessionProjector", () => { retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } }, }) - yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model }) + yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model }) yield* events.publish(SessionEvent.RetryScheduled, { sessionID, assistantMessageID: second, @@ -534,12 +666,15 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const service = yield* EventV2.Service + const usageUpdated = yield* service + .subscribe(SessionEvent.UsageUpdated) + .pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true })) yield* service.publish(SessionEvent.Step.Ended, { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), finish: "stop", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: Money.USD.make(1.25), + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, }) const rows = yield* db @@ -550,14 +685,31 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }), ) expect(messages[0]).not.toHaveProperty("time.completed") expect(messages[1]).toMatchObject({ type: "assistant", finish: "stop", + cost: Money.USD.make(1.25), + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, time: { completed: DateTime.makeUnsafe(0) }, }) + expect( + yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie), + ).toMatchObject({ + cost: 1.25, + tokens_input: 10, + tokens_output: 4, + tokens_reasoning: 2, + tokens_cache_read: 3, + tokens_cache_write: 1, + }) + expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({ + sessionID, + cost: Money.USD.make(1.25), + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, + }) }), ) @@ -608,13 +760,13 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }), ) expect(messages).toEqual([ SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", - agent: "build", + agent: build, model, content: [SessionMessage.AssistantText.make({ type: "text", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, @@ -622,7 +774,7 @@ describe("SessionProjector", () => { SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", - agent: "build", + agent: build, model, content: [], time: { created }, diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 3e76ddbb42..34b1ca1487 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -6,6 +6,7 @@ import path from "path" import { pathToFileURL } from "url" import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -105,7 +106,7 @@ const eventCount = (type: string) => ), ) -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const encodeMessage = Schema.encodeSync(SessionMessage.Info) const assistantRow = (id: SessionMessage.ID, seq: number) => { const { id: _, @@ -115,7 +116,7 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => { SessionMessage.Assistant.make({ id, type: "assistant", - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [], time: { created: DateTime.makeUnsafe(0) }, @@ -677,7 +678,6 @@ describe("SessionV2.prompt", () => { ...data } = encodeMessage({ id: messageID, - sessionID, type: "synthetic", text: "Existing history", time: { created: DateTime.makeUnsafe(0) }, diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index b34d0e9918..c593749f37 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -5,13 +5,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" -import { SessionV2 } from "@opencode-ai/core/session" +import { AgentV2 } from "@opencode-ai/core/agent" import { Shell } from "@opencode-ai/schema/shell" import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) +const build = AgentV2.defaultID describe("toLLMMessages", () => { test("omits empty assistant turns", () => { @@ -19,7 +20,7 @@ describe("toLLMMessages", () => { SessionMessage.Assistant.make({ id: id(value), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content, time: { created, completed: created }, @@ -56,7 +57,7 @@ describe("toLLMMessages", () => { SessionMessage.AgentSelected.make({ id: id("agent"), type: "agent-switched", - agent: "build", + agent: build, time: { created }, }), SessionMessage.ModelSelected.make({ @@ -82,24 +83,16 @@ describe("toLLMMessages", () => { SessionMessage.Synthetic.make({ id: id("synthetic"), type: "synthetic", - sessionID: SessionV2.ID.make("ses_translate"), text: "Synthetic context", time: { created }, }), SessionMessage.Shell.make({ id: id("shell"), type: "shell", - shell: Shell.Info.make({ - id: Shell.ID.make("sh_test"), - status: "exited", - command: "pwd", - cwd: "/project", - shell: "/bin/sh", - file: "/tmp/sh_test.out", - exit: 0, - metadata: {}, - time: { started: 0, completed: 0 }, - }), + shellID: Shell.ID.make("sh_test"), + status: "exited", + command: "pwd", + exit: 0, output: { output: "/project", cursor: 8, size: 8, truncated: false }, time: { created, completed: created }, }), @@ -282,7 +275,7 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantText.make({ type: "text", text: "Checking" }), @@ -295,7 +288,7 @@ Recent work type: "tool", id: "pending", name: "read", - state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }), + state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: '{"path":"README.md"}' }), time: { created }, }), SessionMessage.AssistantTool.make({ @@ -437,7 +430,7 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-openai-reasoning"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ @@ -461,13 +454,41 @@ Recent work ]) }) - test("drops provider-native continuation metadata from failed assistant turns", () => { + test("replays flat state under an OpenCode hosted model's route key", () => { + const opencode = ModelV2.Ref.make({ id: ModelV2.ID.make("claude-fable-5"), providerID: ProviderV2.ID.opencode }) + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-opencode-reasoning"), + type: "assistant", + agent: build, + model: opencode, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + text: "Think", + state: { signature: "signed" }, + }), + ], + time: { created, completed: created }, + }), + ], + opencode, + "anthropic", + ) + + expect(messages[0]?.content).toEqual([ + { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } }, + ]) + }) + + test("lowers failed assistant reasoning to text", () => { const messages = toLLMMessages( [ SessionMessage.Assistant.make({ id: id("assistant-failed"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ @@ -501,7 +522,7 @@ Recent work ) expect(messages[0]?.content).toEqual([ - { type: "reasoning", text: "Partial thought", providerMetadata: undefined }, + { type: "text", text: "Partial thought" }, { type: "tool-call", id: "hosted-failed", @@ -536,7 +557,7 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-old-model"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ @@ -631,7 +652,7 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-alias"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 14bfc37bbe..f3c666b4a9 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { LLM, Model } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { DateTime, Effect } from "effect" +import { Money } from "@opencode-ai/schema/money" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" @@ -52,6 +53,7 @@ describe("SessionRunnerModel", () => { expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) expect(resolved.route).toMatchObject({ id: "openai-responses", + providerMetadataKey: "openai", endpoint: { baseURL: "https://openai.example/v1" }, defaults: { headers: { "x-test": "header" }, @@ -131,7 +133,7 @@ describe("SessionRunnerModel", () => { providerID: catalog.providerID, variant: ModelV2.VariantID.make("high"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -170,7 +172,7 @@ describe("SessionRunnerModel", () => { projectID: ProjectV2.ID.global, title: "test", model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -200,7 +202,7 @@ describe("SessionRunnerModel", () => { providerID: catalog.providerID, variant: ModelV2.VariantID.make("unknown"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -236,7 +238,7 @@ describe("SessionRunnerModel", () => { projectID: ProjectV2.ID.global, title: "test", model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -263,6 +265,7 @@ describe("SessionRunnerModel", () => { expect(resolved.route).toMatchObject({ id: "anthropic-messages", + providerMetadataKey: "anthropic", endpoint: { baseURL: "https://anthropic.example/v1" }, }) }), diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 4fdbb49e8b..179818d852 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -1,7 +1,9 @@ import { expect, test } from "bun:test" import { Effect, Schema, Stream } from "effect" import { LLMEvent } from "@opencode-ai/llm" +import { Money } from "@opencode-ai/schema/money" import { EventV2 } from "@opencode-ai/core/event" +import { AgentV2 } from "@opencode-ai/core/agent" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionV2 } from "@opencode-ai/core/session" @@ -12,7 +14,7 @@ import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publis const sessionID = SessionV2.ID.make("ses_tool_event_test") const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" -const capture = () => { +const capture = (providerMetadataKey = "anthropic") => { const published: Array<{ readonly type: string; readonly data: unknown }> = [] const events = EventV2.Service.of({ publish: (definition, data) => @@ -40,12 +42,12 @@ const capture = () => { published, publisher: createLLMEventPublisher(events, { sessionID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("model"), - providerID: ProviderV2.ID.make("provider"), + providerID: ProviderV2.ID.opencode, }, - provider: "openai", + providerMetadataKey, }), } } @@ -97,16 +99,76 @@ test("provider-executed success retains its raw provider result", async () => { expect(success?.data).toHaveProperty("result") }) -test("provider state uses the route provider instead of the catalog provider", async () => { +test("provider metadata is flattened using the route key", async () => { const { published, publisher } = capture() await Effect.runPromise( publisher.publish( - LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }), + LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { signature: "signed" } } }), ), ) expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({ - state: { itemId: "reasoning" }, + state: { signature: "signed" }, + }) +}) + +test("reasoning state from start, empty delta, and end is merged", async () => { + const { published, publisher } = capture() + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { blockType: "thinking" } } }), + ), + ) + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningDelta({ + id: "reasoning", + text: "", + providerMetadata: { anthropic: { signature: "signed" }, gateway: { traceID: "trace" } }, + }), + ), + ) + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningEnd({ id: "reasoning", providerMetadata: { anthropic: { stopReason: "tool_use" } } }), + ), + ) + + expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({ + state: { blockType: "thinking", signature: "signed", stopReason: "tool_use" }, + }) +}) + +test("provider-executed tool metadata is flattened using the route key", async () => { + const { published, publisher } = capture("openai") + await Effect.runPromise( + publisher.publish( + LLMEvent.toolCall({ + id: "hosted", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "call" } }, + }), + ), + ) + await Effect.runPromise( + publisher.publish( + LLMEvent.toolResult({ + id: "hosted", + name: "web_search", + result: { type: "json", value: { found: true } }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "result" } }, + }), + ), + ) + + expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({ + state: { itemId: "call" }, + }) + expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({ + resultState: { itemId: "result" }, }) }) @@ -151,15 +213,39 @@ test("step finish records settlement without publishing step ended", async () => test("content-filter finish retains failure evidence until step closeout", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) - await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "content-filter" }))) + await Effect.runPromise( + publisher.publish( + LLMEvent.stepFinish({ + index: 0, + reason: "content-filter", + usage: { + nonCachedInputTokens: 8, + outputTokens: 3, + reasoningTokens: 1, + }, + }), + ), + ) expect(published.map((event) => event.type)).toEqual(["session.step.started.1"]) - await Effect.runPromise(publisher.publishStepFailure()) + const settlement = publisher.stepSettlement() + expect(settlement).toMatchObject({ + finish: "content-filter", + tokens: { input: 8, output: 2, reasoning: 1 }, + }) + if (!settlement) throw new Error("Expected content-filter settlement") + await Effect.runPromise( + publisher.publishStepFailure({ + cost: Money.USD.make(1.25), + tokens: settlement.tokens, + }), + ) expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"]) expect(published.at(-1)?.data).toMatchObject({ error: { type: "provider.content-filter", message: "Provider blocked the response" }, + cost: 1.25, + tokens: { input: 8, output: 2, reasoning: 1 }, }) - expect(publisher.stepSettlement()).toBeUndefined() }) test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => { diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 3c03094b5e..2944f07972 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -82,24 +82,27 @@ describe("ToolRegistry", () => { }), ) - it.effect("selects one edit tool family for each model", () => + it.effect("materializes all permission-eligible edit tools before request policy", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service yield* service.register({ read: make(), edit: make("edit"), write: make("edit"), - apply_patch: make("edit"), + patch: make("edit"), }) const names = (model: ToolRegistry.MaterializeInput["model"]) => service .materialize({ model }) .pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name))) - expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "apply_patch"]) - expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "apply_patch"]) - expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "apply_patch"]) - expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"]) + expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"]) + expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([ + "read", + "edit", + "write", + "patch", + ]) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ec893a5527..ef2d24445a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { LLMClient, LLMError, @@ -30,6 +30,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { PromptInput } from "@opencode-ai/schema/prompt-input" +import { Money } from "@opencode-ai/schema/money" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" @@ -103,6 +104,20 @@ const client = Layer.succeed( generate: () => Effect.die("unused"), }), ) +const reply = { + stop: () => [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents, + tool: (id: string, name: string, input: unknown) => [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id, name, input }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], +} const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) const defaultSystem = SessionRunnerSystemPrompt.provider(model) const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route }) @@ -116,6 +131,53 @@ const recoveryModel = Model.make({ provider: "fake", route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }), }) + +test("calculates step cost using the matching context tier", () => { + expect( + SessionRunnerLLM.calculateCost( + [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.make(0.1), + write: Money.USDPerMillionTokens.make(0.5), + }, + }, + { + tier: { type: "context", size: 100 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(4), + cache: { + read: Money.USDPerMillionTokens.make(0.2), + write: Money.USDPerMillionTokens.make(0.6), + }, + }, + ], + { input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } }, + ), + ).toBeCloseTo(0.0002926) +}) + +test("does not apply an ineligible tier without base pricing", () => { + expect( + SessionRunnerLLM.calculateCost( + [ + { + tier: { type: "context", size: 100 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(4), + cache: { + read: Money.USDPerMillionTokens.make(0.2), + write: Money.USDPerMillionTokens.make(0.6), + }, + }, + ], + { input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 0 } }, + ), + ).toBe(Money.USD.zero) +}) + const authorizations: Tool.Context[] = [] const executions: string[] = [] const permission = Layer.succeed( @@ -317,6 +379,8 @@ const it = testEffect( ) const sessionID = SessionV2.ID.make("ses_runner_test") const otherSessionID = SessionV2.ID.make("ses_runner_other") +const admit = (session: SessionV2.Interface, text: string) => + session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text }), resume: false }) const insertSession = (id: SessionV2.ID) => Effect.gen(function* () { @@ -338,6 +402,9 @@ const insertSession = (id: SessionV2.ID) => const setup = Effect.gen(function* () { const { db } = yield* Database.Service + requests.length = 0 + authorizations.length = 0 + executions.length = 0 response = [] systemBaseline = "Initial context" systemRemoved = false @@ -363,6 +430,7 @@ const setup = Effect.gen(function* () { .run() .pipe(Effect.orDie) yield* insertSession(sessionID) + return yield* SessionV2.Service }) const providerUnavailable = () => @@ -387,14 +455,9 @@ const rateLimited = (retryAfterMs?: number) => }) const setupOverflowRecovery = Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(700) }), - resume: false, - }) + const session = yield* setup + response = reply.text("Earlier answer", "text-earlier") + yield* admit(session, "Earlier question ".repeat(700)) yield* session.resume(sessionID) currentModel = recoveryModel requests.length = 0 @@ -445,7 +508,10 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess ) }) -const requireAssistant = (messages: readonly SessionMessage.Message[]) => { +const hostedCall = (id: string, query: string) => + LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true }) + +const requireAssistant = (messages: readonly SessionMessage.Info[]) => { const assistant = messages.find((message) => message.type === "assistant") if (!assistant) throw new Error("Assistant message missing") return assistant @@ -541,7 +607,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string LLMEvent.toolInputStart({ id, name: "echo" }), ...chunks.map((text) => LLMEvent.toolInputDelta({ id, name: "echo", text })), ] - const expectedContent = { type: "tool", id, state: { status: "pending", input: text } } + const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } } return { delta: SessionEvent.Tool.Input.Delta, partialEvents, @@ -555,14 +621,12 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string const verifyEphemeralDeltas = (kind: FragmentKind) => Effect.gen(function* () { - yield* setup - requests.length = 0 - const session = yield* SessionV2.Service + const session = yield* setup const prompt = `Stream ${kind}` const chunks = Array.from({ length: 32 }, (_, index) => `${index},`) const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks) const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) + yield* admit(session, prompt) const events = yield* EventV2.Service const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow @@ -588,13 +652,11 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const verifyPartialFlushOnFailure = (kind: FragmentKind) => Effect.gen(function* () { - yield* setup - requests.length = 0 - const session = yield* SessionV2.Service + const session = yield* setup const prompt = `Fail after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) const failure = providerUnavailable() - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) + yield* admit(session, prompt) responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure)) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) @@ -612,12 +674,11 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => const verifyPartialFlushOnInterruption = (kind: FragmentKind) => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const prompt = `Interrupt after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"]) const streamed = yield* Deferred.make() - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) + yield* admit(session, prompt) responseStream = Stream.concat( Stream.fromIterable(fixture.partialEvents), Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), @@ -645,9 +706,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => describe("SessionRunnerLLM", () => { it.effect("advertises and executes a location registered tool", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const registry = yield* ToolRegistry.Service - const session = yield* SessionV2.Service const contexts: Tool.Context[] = [] yield* registry.register({ location_context: Tool.make({ @@ -661,20 +721,8 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Use application context" }), - resume: false, - }) - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-location", name: "location_context", input: { query: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] + yield* admit(session, "Use application context") + responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] yield* session.resume(sessionID) @@ -705,13 +753,7 @@ describe("SessionRunnerLLM", () => { it.effect("starts a real runner turn after default prompt recording", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined - response = [] + const session = yield* setup const message = yield* session.prompt({ sessionID, @@ -728,16 +770,10 @@ describe("SessionRunnerLLM", () => { it.effect("streams one request with registry definitions from chronological V2 user history", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + const session = yield* setup + yield* admit(session, "First") + yield* admit(session, "Second") - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined - response = [] yield* session.resume(sessionID) expect(requests).toHaveLength(1) @@ -753,8 +789,7 @@ describe("SessionRunnerLLM", () => { it.effect("retries the first provider turn after system context becomes available", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service const messageID = SessionMessage.ID.create() systemUnavailable = true @@ -764,7 +799,6 @@ describe("SessionRunnerLLM", () => { prompt: PromptInput.Prompt.make({ text: "First" }), resume: false, }) - requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -791,13 +825,10 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts a source Location runner after a Session moves", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - requests.length = 0 - response = [] + yield* admit(session, "First") yield* session.resume(sessionID) yield* events.publish(SessionEvent.Moved, { @@ -812,7 +843,7 @@ describe("SessionRunnerLLM", () => { .get(), ).toBeUndefined() - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") const exit = yield* session.resume(sessionID).pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) @@ -823,11 +854,9 @@ describe("SessionRunnerLLM", () => { it.effect("copies the context checkpoint to a fork", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - response = [] + yield* admit(session, "First") yield* session.resume(sessionID) const forked = yield* session.fork({ sessionID }) @@ -852,11 +881,9 @@ describe("SessionRunnerLLM", () => { it.effect("heals an undecodable stored applied record by re-announcing context", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - response = [] + yield* admit(session, "First") yield* session.resume(sessionID) yield* db .update(InstructionCheckpointTable) @@ -864,7 +891,7 @@ describe("SessionRunnerLLM", () => { .where(eq(InstructionCheckpointTable.session_id, sessionID)) .run() .pipe(Effect.orDie) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") requests.length = 0 yield* session.resume(sessionID) @@ -886,15 +913,12 @@ describe("SessionRunnerLLM", () => { it.effect("reuses one durable baseline after the context producer changes", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + const session = yield* setup + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -920,13 +944,11 @@ describe("SessionRunnerLLM", () => { it.effect("uses the selected model family prompt when the agent does not override it", () => Effect.gen(function* () { - yield* setup + const session = yield* setup currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-provider-prompt", ["Done"]).completeEvents + response = reply.text("Done", "text-provider-prompt") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ @@ -938,7 +960,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses the selected model family prompt when the agent system override is empty", () => Effect.gen(function* () { - yield* setup + const session = yield* setup currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) const agent = yield* AgentV2.Service yield* agent.transform((editor) => @@ -947,11 +969,9 @@ describe("SessionRunnerLLM", () => { agent.mode = "primary" }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-empty-agent-system", ["Done"]).completeEvents + response = reply.text("Done", "text-empty-agent-system") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ @@ -963,7 +983,7 @@ describe("SessionRunnerLLM", () => { it.effect("includes the effective default agent system before durable context", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agent = yield* AgentV2.Service yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { @@ -971,11 +991,9 @@ describe("SessionRunnerLLM", () => { agent.mode = "primary" }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-build", ["Done"]).completeEvents + response = reply.text("Done", "text-build") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) @@ -984,7 +1002,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses the configured default agent system for omitted-agent sessions", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agent = yield* AgentV2.Service yield* agent.transform((editor) => { editor.update(AgentV2.ID.make("build"), (agent) => { @@ -997,11 +1015,9 @@ describe("SessionRunnerLLM", () => { }) editor.default(AgentV2.ID.make("reviewer")) }) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents + response = reply.text("Done", "text-reviewer") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) @@ -1011,7 +1027,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses only the agent prompt and durable baseline as system parts", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agent = yield* AgentV2.Service yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { @@ -1019,11 +1035,9 @@ describe("SessionRunnerLLM", () => { agent.mode = "primary" }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents + response = reply.text("Done", "text-no-system") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) @@ -1032,7 +1046,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses an explicitly selected non-build agent system", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const { db } = yield* Database.Service const agent = yield* AgentV2.Service yield* agent.transform((editor) => @@ -1047,11 +1061,9 @@ describe("SessionRunnerLLM", () => { .where(eq(SessionTable.id, sessionID)) .run() .pipe(Effect.orDie) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents + response = reply.text("Done", "text-selected") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) @@ -1061,21 +1073,18 @@ describe("SessionRunnerLLM", () => { it.effect("updates selected-agent skill guidance after an agent switch", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") yield* events.publish(SessionEvent.AgentSelected, { sessionID, - agent: "reviewer", + agent: AgentV2.ID.make("reviewer"), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1088,8 +1097,7 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the sampled agent when selection changes during observation", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") @@ -1100,14 +1108,12 @@ describe("SessionRunnerLLM", () => { return events .publish(SessionEvent.AgentSelected, { sessionID, - agent: "reviewer", + agent: AgentV2.ID.make("reviewer"), }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1118,8 +1124,7 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the sampled model when selection changes during model resolution", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service let switched = false modelResolveHook = Effect.suspend(() => { @@ -1132,10 +1137,8 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) expect(requests.map((request) => request.model)).toEqual([model]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1146,15 +1149,12 @@ describe("SessionRunnerLLM", () => { it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + const session = yield* setup + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemRemoved = true - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1167,14 +1167,11 @@ describe("SessionRunnerLLM", () => { it.effect("renders API context entries through the belief lifecycle", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const contextEntries = yield* InstructionEntry.Service yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) // String values render verbatim inside the tagged block at baseline. @@ -1185,7 +1182,7 @@ describe("SessionRunnerLLM", () => { // Non-string JSON pretty-prints; the change narrates as a System update. yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1206,7 +1203,7 @@ describe("SessionRunnerLLM", () => { // Deleting the row announces removal through the stored removal text. yield* contextEntries.remove({ sessionID, key: "deploy-target" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"]) @@ -1219,23 +1216,20 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the baseline and chronological System updates after a model switch", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1255,31 +1249,28 @@ describe("SessionRunnerLLM", () => { ]) yield* replaySessionProjection(sessionID) expect(yield* session.messages({ sessionID })).toHaveLength(6) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fourth" }), resume: false }) + yield* admit(session, "Fourth") yield* session.resume(sessionID) }), ) it.effect("preserves the baseline while context is temporarily unavailable", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) systemUnavailable = false systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1292,17 +1283,15 @@ describe("SessionRunnerLLM", () => { it.effect("rebuilds the baseline directly after completed compaction", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", + recent: "", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, @@ -1311,7 +1300,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1319,26 +1308,24 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Replacement context"], ]) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) }), ) it.effect("runs one durable compaction barrier before later steer and queued prompts", () => Effect.gen(function* () { - yield* setup - requests.length = 0 + const session = yield* setup currentModel = recoveryModel - const session = yield* SessionV2.Service streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() responses = [ - fragmentFixture("text", "text-active", ["Active complete"]).completeEvents, + reply.text("Active complete", "text-active"), [LLMEvent.textDelta({ id: "summary", text: "durable summary" })], - fragmentFixture("text", "text-steer", ["Steer complete"]).completeEvents, - fragmentFixture("text", "text-queue", ["Queue complete"]).completeEvents, + reply.text("Steer complete", "text-steer"), + reply.text("Queue complete", "text-queue"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Active work" }), resume: false }) + yield* admit(session, "Active work") const active = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) @@ -1348,16 +1335,9 @@ describe("SessionRunnerLLM", () => { expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({ id: first.id, }) - expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({ - type: "compaction", - status: "queued", - }) + expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined() - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Steer after compaction" }), - resume: false, - }) + yield* admit(session, "Steer after compaction") yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue after compaction" }), @@ -1384,18 +1364,16 @@ describe("SessionRunnerLLM", () => { it.effect("releases queued prompts when durable compaction fails", () => Effect.gen(function* () { - yield* setup - requests.length = 0 + const session = yield* setup currentModel = recoveryModel - const session = yield* SessionV2.Service streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() responses = [ - fragmentFixture("text", "text-active-failure", ["Active complete"]).completeEvents, + reply.text("Active complete", "text-active-failure"), [], - fragmentFixture("text", "text-after-failure", ["Continued"]).completeEvents, + reply.text("Continued", "text-after-failure"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Active work" }), resume: false }) + yield* admit(session, "Active work") const active = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) @@ -1416,32 +1394,74 @@ describe("SessionRunnerLLM", () => { type: "compaction", status: "failed", }) + expect( + (yield* recordedEventTypes(sessionID)).filter( + (type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1), + ), + ).toHaveLength(1) + }), + ) + + it.effect("settles an admitted manual compaction that cannot start", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const compaction = yield* session.compact({ sessionID }) + + yield* session.resume(sessionID) + + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + reason: "manual", + error: { message: "Compaction could not start" }, + }) + expect( + (yield* recordedEventTypes(sessionID)).filter( + (type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1), + ), + ).toHaveLength(1) + }), + ) + + it.effect("settles an admitted manual compaction when pre-start resolution throws", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const compaction = yield* session.compact({ sessionID }) + modelResolveHook = Effect.die("model resolution failed") + + expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" }) + + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + reason: "manual", + }) + expect( + (yield* recordedEventTypes(sessionID)).filter( + (type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1), + ), + ).toHaveLength(1) }), ) it.effect("automatically compacts into a completed summary and retained recent turn", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(180) }), - resume: false, - }) + const session = yield* setup + response = reply.text("Earlier answer", "text-first") + yield* admit(session, "Earlier question ".repeat(180)) yield* session.resume(sessionID) currentModel = compactModel requests.length = 0 responses = [ - fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents, - fragmentFixture("text", "text-final", ["Continued"]).completeEvents, + reply.text("## Objective\n- Preserve the task", "text-summary"), + reply.text("Continued", "text-final"), ] - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recent exact request ".repeat(180) }), - resume: false, - }) + yield* admit(session, "Recent exact request ".repeat(180)) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1460,14 +1480,10 @@ describe("SessionRunnerLLM", () => { requests.length = 0 executions.length = 0 responses = [ - fragmentFixture("text", "text-summary-2", ["## Objective\n- Preserve the updated task"]).completeEvents, - fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents, + reply.text("## Objective\n- Preserve the updated task", "text-summary-2"), + reply.text("Continued again", "text-final-2"), ] - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Newest exact request ".repeat(180) }), - resume: false, - }) + yield* admit(session, "Newest exact request ".repeat(180)) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1490,10 +1506,10 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ], - fragmentFixture("text", "text-summary", ["## Objective\n- Recover overflow"]).completeEvents, - fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, + reply.text("## Objective\n- Recover overflow", "text-summary"), + reply.text("Recovered", "text-final"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1518,12 +1534,8 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ] - responses = [ - overflow(), - fragmentFixture("text", "text-summary", ["## Objective\n- Recover once"]).completeEvents, - overflow(), - ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()] + yield* admit(session, "Continue") expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(3) @@ -1548,10 +1560,10 @@ describe("SessionRunnerLLM", () => { }), ) responses = [ - fragmentFixture("text", "text-summary", ["## Objective\n- Recover raw overflow"]).completeEvents, - fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, + reply.text("## Objective\n- Recover raw overflow", "text-summary"), + reply.text("Recovered", "text-final"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1569,14 +1581,15 @@ describe("SessionRunnerLLM", () => { [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "summary unavailable" })], ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(2) const context = yield* session.context(sessionID) - expect(context.some((message) => message.type === "compaction")).toBe(false) - expect(context.slice(-2)).toMatchObject([ + expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" })) + expect(context.slice(-3)).toMatchObject([ { type: "user", text: "Continue" }, + { type: "compaction", status: "failed", reason: "auto" }, { type: "assistant", finish: "error", error: { message: "prompt too long" } }, ]) }), @@ -1587,12 +1600,12 @@ describe("SessionRunnerLLM", () => { const session = yield* setupOverflowRecovery responses = [ [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], - fragmentFixture("text", "text-summary", ["## Objective\n- Interrupted"]).completeEvents, + reply.text("## Objective\n- Interrupted", "text-summary"), ] const firstGate = yield* Deferred.make() const summaryGate = yield* Deferred.make() streamGate = firstGate - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow streamGate = summaryGate @@ -1603,26 +1616,26 @@ describe("SessionRunnerLLM", () => { expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) streamGate = undefined expect(requests).toHaveLength(2) - expect((yield* session.context(sessionID)).some((message) => message.type === "compaction")).toBe(false) + expect(yield* session.context(sessionID)).toContainEqual( + expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }), + ) }), ) it.effect("rebaselines after compaction from the last-applied belief while unobservable", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", + recent: "", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, @@ -1631,7 +1644,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) // The rebaseline proceeds while the source is unobservable, restating the model's belief. @@ -1642,14 +1655,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects reasoning and tool events without executing or continuing tools", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use tools" }), resume: false }) + const session = yield* setup + yield* admit(session, "Use tools") - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.reasoningStart({ id: "reasoning-1" }), @@ -1666,7 +1674,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "hello" }, providerExecuted: true, - providerMetadata: { fake: { source: "provider" } }, + providerMetadata: { openai: { source: "provider" } }, }), LLMEvent.toolResult({ id: "call-provider", @@ -1679,7 +1687,7 @@ describe("SessionRunnerLLM", () => { ], }, providerExecuted: true, - providerMetadata: { fake: { source: "provider" } }, + providerMetadata: { openai: { source: "provider" } }, }), LLMEvent.stepFinish({ index: 0, @@ -1704,6 +1712,7 @@ describe("SessionRunnerLLM", () => { { type: "assistant", finish: "tool-calls", + cost: 0, tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } }, content: [ { type: "reasoning", text: "Think" }, @@ -1742,31 +1751,10 @@ describe("SessionRunnerLLM", () => { it.effect("continues with reloaded history after durably settling one local tool call", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) + const session = yield* setup + yield* admit(session, "Echo this") - requests.length = 0 - authorizations.length = 0 - executions.length = 0 - streamGate = undefined - streamStarted = undefined - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-final" }), - LLMEvent.textDelta({ id: "text-final", text: "Done" }), - LLMEvent.textEnd({ id: "text-final" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.text("Done", "text-final")] yield* session.resume(sessionID) @@ -1808,25 +1796,11 @@ describe("SessionRunnerLLM", () => { it.effect("reloads a model switch before a tool-driven continuation turn", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) + yield* admit(session, "Echo this") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop()] toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 @@ -1851,32 +1825,30 @@ describe("SessionRunnerLLM", () => { it.effect("restores durable reasoning provider metadata in a second-turn request", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Think first" }), resume: false }) + const session = yield* setup + yield* admit(session, "Think first") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.reasoningStart({ id: "reasoning-anthropic" }), LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }), LLMEvent.reasoningEnd({ id: "reasoning-anthropic", - providerMetadata: { fake: { signature: "sig_1" }, anthropic: { ignored: true } }, + providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } }, }), LLMEvent.reasoningStart({ id: "reasoning-openai", providerMetadata: { - fake: { itemId: "rs_1", reasoningEncryptedContent: null }, - openai: { ignored: true }, + openai: { itemId: "rs_1", reasoningEncryptedContent: null }, + anthropic: { ignored: true }, }, }), LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }), LLMEvent.reasoningEnd({ id: "reasoning-openai", providerMetadata: { - fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, - openai: { ignored: true }, + openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, + anthropic: { ignored: true }, }, }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), @@ -1890,7 +1862,11 @@ describe("SessionRunnerLLM", () => { { type: "assistant", content: [ - { type: "reasoning", text: "Signed thought", state: { signature: "sig_1" } }, + { + type: "reasoning", + text: "Signed thought", + state: { signature: "sig_1" }, + }, { type: "reasoning", text: "Encrypted thought", @@ -1900,16 +1876,20 @@ describe("SessionRunnerLLM", () => { }, ]) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") response = [] yield* session.resume(sessionID) expect(requests[1]?.messages[1]?.content).toEqual([ - { type: "reasoning", text: "Signed thought", providerMetadata: { fake: { signature: "sig_1" } } }, + { + type: "reasoning", + text: "Signed thought", + providerMetadata: { openai: { signature: "sig_1" } }, + }, { type: "reasoning", text: "Encrypted thought", - providerMetadata: { fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, }, ]) }), @@ -1917,11 +1897,9 @@ describe("SessionRunnerLLM", () => { it.effect("replays durable provider-executed tool results inline in a second-turn request", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Search first" }), resume: false }) + const session = yield* setup + yield* admit(session, "Search first") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ @@ -1929,14 +1907,14 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { fake: { itemId: "hosted-search" }, openai: { ignored: true } }, + providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } }, }), LLMEvent.toolResult({ id: "hosted-search", name: "web_search", result: { type: "json", value: [{ title: "Effect" }] }, providerExecuted: true, - providerMetadata: { fake: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } }, + providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } }, }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), @@ -1944,7 +1922,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") response = [] yield* session.resume(sessionID) @@ -1956,7 +1934,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { fake: { itemId: "hosted-search" } }, + providerMetadata: { openai: { itemId: "hosted-search" } }, }, { type: "tool-result", @@ -1964,7 +1942,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", result: { type: "json", value: [{ title: "Effect" }] }, providerExecuted: true, - providerMetadata: { fake: { blockType: "web_search_tool_result" } }, + providerMetadata: { openai: { blockType: "web_search_tool_result" } }, }, ]) }), @@ -1972,17 +1950,12 @@ describe("SessionRunnerLLM", () => { it.effect("starts recorded local tools eagerly and awaits settlement before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo five times" }), resume: false }) + const session = yield* setup + yield* admit(session, "Echo five times") - requests.length = 0 - executions.length = 0 toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() const providerGate = yield* Deferred.make() - response = [] - responses = undefined const initial = Stream.fromIterable([ LLMEvent.stepStart({ index: 0 }), ...Array.from({ length: 5 }, (_, index) => @@ -1993,7 +1966,6 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ]) - streamGate = undefined responseStream = Stream.concat( initial, Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)), @@ -2033,25 +2005,12 @@ describe("SessionRunnerLLM", () => { it.effect("settles repeated provider-local tool call IDs against their owning assistant messages", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo twice" }), resume: false }) + const session = yield* setup + yield* admit(session, "Echo twice") - requests.length = 0 - executions.length = 0 responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "first" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "second" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], + reply.tool("tool_0", "echo", { text: "first" }), + reply.tool("tool_0", "echo", { text: "second" }), [], ] @@ -2121,20 +2080,10 @@ describe("SessionRunnerLLM", () => { it.effect("joins concurrent resume calls into one active provider run", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run once" }), resume: false }) + const session = yield* setup + yield* admit(session, "Run once") - requests.length = 0 - responses = undefined - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-once" }), - LLMEvent.textDelta({ id: "text-once", text: "Once" }), - LLMEvent.textEnd({ id: "text-once" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ] + response = reply.text("Once", "text-once") streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2160,23 +2109,10 @@ describe("SessionRunnerLLM", () => { it.effect("steers an active provider turn with newly recorded prompts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2203,29 +2139,10 @@ describe("SessionRunnerLLM", () => { it.effect("promotes queued input after continuation ends", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2250,24 +2167,11 @@ describe("SessionRunnerLLM", () => { it.effect("preserves durable queued input for a later wake after interruption", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), - resume: false, - }) + yield* admit(session, "Interrupt current work") - requests.length = 0 - responses = [ - [], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [[], reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2297,24 +2201,11 @@ describe("SessionRunnerLLM", () => { it.effect("preserves durable steering input for a later resume after interruption", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), - resume: false, - }) + yield* admit(session, "Interrupt current work") - requests.length = 0 - responses = [ - [], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [[], reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2344,28 +2235,10 @@ describe("SessionRunnerLLM", () => { it.effect("promotes queued inputs one at a time in FIFO order", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2387,9 +2260,8 @@ describe("SessionRunnerLLM", () => { it.effect("promotes queued input after steering continuation ends", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start steering" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start steering") yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue for later" }), @@ -2397,19 +2269,7 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop()] yield* session.resume(sessionID) @@ -2421,33 +2281,10 @@ describe("SessionRunnerLLM", () => { it.effect("promotes steers before the next queued input", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop(), reply.stop(), reply.stop()] const firstGate = yield* Deferred.make() const secondGate = yield* Deferred.make() streamGate = firstGate @@ -2489,23 +2326,10 @@ describe("SessionRunnerLLM", () => { it.effect("coalesces multiple active steering prompts into one continuation turn", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2529,13 +2353,9 @@ describe("SessionRunnerLLM", () => { it.effect("runs steering input accepted while the active provider turn fails", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = undefined - response = [] streamFailure = invalidRequest() streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2558,20 +2378,15 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails local tools left running by a prior process before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool" }), - resume: false, - }) + yield* admit(session, "Recover interrupted tool") yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { @@ -2620,20 +2435,15 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails hosted tools left running by a prior process before continuing inline", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover interrupted hosted tool" }), - resume: false, - }) + yield* admit(session, "Recover interrupted hosted tool") yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { @@ -2667,7 +2477,7 @@ describe("SessionRunnerLLM", () => { type: "tool-call", id: "call-hosted-interrupted", providerExecuted: true, - providerMetadata: { fake: { itemId: "call-hosted-interrupted" } }, + providerMetadata: { openai: { itemId: "call-hosted-interrupted" } }, }, { type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } }, ]) @@ -2676,20 +2486,15 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails pending tool input left by a prior process before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool input" }), - resume: false, - }) + yield* admit(session, "Recover interrupted tool input") yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { @@ -2713,8 +2518,7 @@ describe("SessionRunnerLLM", () => { it.effect("promotes the first queued input when woken while idle", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait in queue" }), @@ -2722,7 +2526,6 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow @@ -2733,26 +2536,17 @@ describe("SessionRunnerLLM", () => { it.effect("retries inbox input after prompt projection rolls back", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover promoted input" }), - resume: false, - }) + yield* admit(session, "Recover promoted input") expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false requests.length = 0 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ] + response = reply.stop() yield* (yield* SessionExecution.Service).wake(sessionID) while (requests.length === 0) yield* Effect.yieldNow @@ -2763,21 +2557,15 @@ describe("SessionRunnerLLM", () => { it.effect("does not strand a committed promotion when a post-commit listener defects", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service yield* events.listen((event) => event.type === SessionEvent.PromptPromoted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, ) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Run committed promotion" }), - resume: false, - }) + yield* admit(session, "Run committed promotion") - requests.length = 0 yield* session.resume(sessionID) expect(requests).toHaveLength(1) @@ -2787,19 +2575,15 @@ describe("SessionRunnerLLM", () => { it.effect("runs different sessions concurrently", () => Effect.gen(function* () { - yield* setup + const session = yield* setup yield* insertSession(otherSessionID) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run first" }), resume: false }) + yield* admit(session, "Run first") yield* session.prompt({ sessionID: otherSessionID, prompt: PromptInput.Prompt.make({ text: "Run second" }), resume: false, }) - requests.length = 0 - responses = undefined - response = [] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2824,12 +2608,11 @@ describe("SessionRunnerLLM", () => { it.effect("bounds 64-character session prompt cache keys", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const longSessionID = SessionV2.ID.make(`ses_${"a".repeat(64)}`) const otherLongSessionID = SessionV2.ID.make(`ses_${"b".repeat(64)}`) yield* insertSession(longSessionID) yield* insertSession(otherLongSessionID) - const session = yield* SessionV2.Service yield* session.prompt({ sessionID: longSessionID, prompt: PromptInput.Prompt.make({ text: "Run long session" }), @@ -2841,7 +2624,6 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 yield* session.resume(longSessionID) yield* session.resume(otherLongSessionID) @@ -2854,17 +2636,9 @@ describe("SessionRunnerLLM", () => { it.effect("fans out one failed run and allows a later retry", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Retry after failure" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Retry after failure") - requests.length = 0 - responses = undefined - response = [] streamFailure = invalidRequest() streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2889,30 +2663,10 @@ describe("SessionRunnerLLM", () => { it.effect("durably settles local tool failures before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call missing" }), resume: false }) - - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-missing", name: "missing", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-after-error" }), - LLMEvent.textDelta({ id: "text-after-error", text: "Recovered" }), - LLMEvent.textEnd({ id: "text-after-error" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] - streamGate = undefined - streamStarted = undefined + const session = yield* setup + yield* admit(session, "Call missing") + responses = [reply.tool("call-missing", "missing", {}), reply.text("Recovered", "text-after-error")] yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -2924,7 +2678,10 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-missing", - state: { status: "error", error: { message: "Unknown tool: missing" } }, + state: { + status: "error", + error: { type: "tool.unknown", message: "Unknown tool: missing" }, + }, }, ], }, @@ -2935,27 +2692,10 @@ describe("SessionRunnerLLM", () => { it.effect("returns unexpected local tool defects to the model and continues", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call defect" }), resume: false }) + const session = yield* setup + yield* admit(session, "Call defect") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-defect", name: "defect", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-after-defect" }), - LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }), - LLMEvent.textEnd({ id: "text-after-defect" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-defect", "defect", {}), reply.text("Recovered", "text-after-defect")] yield* session.resume(sessionID) @@ -2991,8 +2731,7 @@ describe("SessionRunnerLLM", () => { it.effect("returns policy-blocked tools to the model and continues", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ blocked: Tool.make({ @@ -3005,22 +2744,9 @@ describe("SessionRunnerLLM", () => { ), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call blocked" }), resume: false }) + yield* admit(session, "Call blocked") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-blocked", name: "blocked", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()] yield* session.resume(sessionID) @@ -3040,8 +2766,7 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts runner continuation when permission approval is declined", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ declined: Tool.make({ @@ -3051,15 +2776,9 @@ describe("SessionRunnerLLM", () => { execute: () => Effect.die(new PermissionV2.DeclinedError()), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call declined" }), resume: false }) + yield* admit(session, "Call declined") - requests.length = 0 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ] + response = reply.tool("call-declined", "declined", {}) const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -3084,8 +2803,7 @@ describe("SessionRunnerLLM", () => { it.effect("returns permission corrections to the model and continues", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ corrected: Tool.make({ @@ -3098,22 +2816,9 @@ describe("SessionRunnerLLM", () => { ), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call corrected" }), resume: false }) + yield* admit(session, "Call corrected") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-corrected", name: "corrected", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()] yield* session.resume(sessionID) @@ -3133,20 +2838,10 @@ describe("SessionRunnerLLM", () => { it.effect("fails the drain when tool output persistence fails", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call storefail" }), resume: false }) + const session = yield* setup + yield* admit(session, "Call storefail") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-storefail", name: "storefail", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] + responses = [reply.tool("call-storefail", "storefail", {}), []] const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -3178,8 +2873,7 @@ describe("SessionRunnerLLM", () => { it.effect("preserves permission rejection and stops before continuation", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ permissionfail: Tool.make({ @@ -3197,19 +2891,9 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Reject permission" }), - resume: false, - }) - requests.length = 0 + yield* admit(session, "Reject permission") responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-permission", name: "permissionfail", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], + reply.tool("call-permission", "permissionfail", {}), [LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })], ] @@ -3247,8 +2931,7 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts runner continuation when a question is cancelled", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ question: Tool.make({ @@ -3258,18 +2941,9 @@ describe("SessionRunnerLLM", () => { execute: () => Effect.die(new QuestionTool.CancelledError()), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Ask then stop" }), resume: false }) + yield* admit(session, "Ask then stop") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-question", name: "question", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] + responses = [reply.tool("call-question", "question", {}), []] const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild) const exit = yield* Fiber.join(run) @@ -3295,13 +2969,8 @@ describe("SessionRunnerLLM", () => { it.effect("awaits started local tools before surfacing provider stream failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Settle before failing" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Settle before failing") const failure = providerUnavailable() toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3341,14 +3010,8 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails blocked local tools when a provider turn is interrupted", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt blocked tool" }), - resume: false, - }) - executions.length = 0 + const session = yield* setup + yield* admit(session, "Interrupt blocked tool") toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( Stream.fromIterable([ @@ -3403,15 +3066,8 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts a blocked provider turn without local tool execution", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt provider" }), - resume: false, - }) - requests.length = 0 - response = [] + const session = yield* setup + yield* admit(session, "Interrupt provider") streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -3435,23 +3091,12 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt tool settlement" }), - resume: false, - }) - executions.length = 0 + const session = yield* setup + yield* admit(session, "Interrupt tool settlement") toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-await-interrupt", name: "echo", input: { text: "blocked" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ] + response = reply.tool("call-await-interrupt", "echo", { text: "blocked" }) const runner = yield* SessionRunner.Service const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) @@ -3483,35 +3128,18 @@ describe("SessionRunnerLLM", () => { it.effect("forces a text response on an agent's configured final step", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Finish at the limit" }), - resume: false, - }) + yield* admit(session, "Finish at the limit") - requests.length = 0 - executions.length = 0 responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], + reply.tool("call-terminal", "echo", { text: "done" }), + reply.tool("call-forbidden", "echo", { text: "forbidden" }), ] yield* session.resume(sessionID) @@ -3535,36 +3163,19 @@ describe("SessionRunnerLLM", () => { it.effect("resets the configured step allowance when steering input promotes", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start work" }), resume: false }) + yield* admit(session, "Start work") - requests.length = 0 - executions.length = 0 responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-before-steer", name: "echo", input: { text: "before" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-after-steer", name: "echo", input: { text: "after" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], + reply.tool("call-before-steer", "echo", { text: "before" }), + reply.tool("call-after-steer", "echo", { text: "after" }), + reply.stop(), ] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -3587,14 +3198,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects provider errors as terminal assistant step failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail durably" }), resume: false }) + const session = yield* setup + yield* admit(session, "Fail durably") - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })] expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") @@ -3609,11 +3215,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects provider errors emitted before assistant step start", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail before step" }), resume: false }) + const session = yield* setup + yield* admit(session, "Fail before step") - requests.length = 0 response = [LLMEvent.providerError({ message: "Provider unavailable" })] expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") @@ -3628,14 +3232,17 @@ describe("SessionRunnerLLM", () => { it.effect("projects content-filter finishes as visible terminal failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Blocked response" }), resume: false }) + const session = yield* setup + yield* admit(session, "Blocked response") response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "partial" }), LLMEvent.textDelta({ id: "partial", text: "Partial" }), - LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), + LLMEvent.stepFinish({ + index: 0, + reason: "content-filter", + usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 }, + }), LLMEvent.finish({ reason: "content-filter" }), ] @@ -3646,22 +3253,23 @@ describe("SessionRunnerLLM", () => { type: "assistant", finish: "error", error: { type: "provider.content-filter" }, + cost: 0, + tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } }, content: [{ type: "text", text: "Partial" }], }, ]) + expect(yield* session.get(sessionID)).toMatchObject({ + cost: 0, + tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } }, + }) expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1") }), ) it.effect("settles a local tool before one content-filter step failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Tool before blocked response" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Tool before blocked response") toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 @@ -3695,15 +3303,9 @@ describe("SessionRunnerLLM", () => { it.effect("does not recover context overflow after durable assistant output", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail after output" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Fail after output") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-partial" }), @@ -3728,13 +3330,8 @@ describe("SessionRunnerLLM", () => { it.effect("projects raw provider stream failures as terminal assistant step failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail raw stream durably" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Fail raw stream durably") const failure = invalidRequest() responseStream = Stream.fail(failure) @@ -3749,12 +3346,10 @@ describe("SessionRunnerLLM", () => { it.effect("retries eligible pre-output failures after exponential backoff", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry transport" }), resume: false }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Retry transport") responseStream = Stream.fail(providerUnavailable()) - response = fragmentFixture("text", "retry-success", ["Recovered"]).completeEvents + response = reply.text("Recovered", "retry-success") const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow @@ -3778,12 +3373,10 @@ describe("SessionRunnerLLM", () => { it.effect("uses a larger provider retry-after delay", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry rate limit" }), resume: false }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Retry rate limit") responseStream = Stream.fail(rateLimited(5_000)) - response = fragmentFixture("text", "retry-after-success", ["Recovered"]).completeEvents + response = reply.text("Recovered", "retry-after-success") const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow @@ -3797,10 +3390,8 @@ describe("SessionRunnerLLM", () => { it.effect("stops after five total retry attempts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Exhaust retries" }), resume: false }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Exhaust retries") streamFailure = providerUnavailable() const run = yield* session.resume(sessionID).pipe(Effect.forkChild) @@ -3833,20 +3424,14 @@ describe("SessionRunnerLLM", () => { it.effect("counts retry attempts against the agent step allowance", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Bound retries by steps" }), - resume: false, - }) - requests.length = 0 + yield* admit(session, "Bound retries by steps") const failure = providerUnavailable() responseStream = Stream.fail(failure) streamFailure = failure @@ -3866,10 +3451,8 @@ describe("SessionRunnerLLM", () => { it.effect("does not retry non-eligible provider failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not retry" }), resume: false }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Do not retry") const failure = invalidRequest() streamFailure = failure @@ -3881,16 +3464,9 @@ describe("SessionRunnerLLM", () => { it.effect("does not continue automatically after a provider error follows a local tool call", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Do not continue failed provider" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Do not continue failed provider") - requests.length = 0 - const executionCount = executions.length toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 @@ -3908,7 +3484,7 @@ describe("SessionRunnerLLM", () => { toolExecutionsStarted = undefined expect(requests).toHaveLength(1) - expect(executions.slice(executionCount)).toEqual(["settled"]) + expect(executions).toEqual(["settled"]) const context = yield* session.context(sessionID) const assistant = requireAssistant(context) expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ @@ -3922,23 +3498,12 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails a hosted tool when its provider errors before returning a result", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail hosted tool durably" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Fail hosted tool durably") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-provider-error", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), + hostedCall("call-hosted-provider-error", "effect"), LLMEvent.providerError({ message: "Provider unavailable" }), ] @@ -3965,13 +3530,8 @@ describe("SessionRunnerLLM", () => { it.effect("preserves a tool defect before provider failure settlement", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Defect while provider fails" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Defect while provider fails") response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }), @@ -3995,22 +3555,9 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail hosted tool at EOF" }), - resume: false, - }) - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-eof", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), - ] + const session = yield* setup + yield* admit(session, "Fail hosted tool at EOF") + response = [LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")] expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider did not return a tool result") const assistant = requireAssistant(yield* session.context(sessionID)) @@ -4040,21 +3587,11 @@ describe("SessionRunnerLLM", () => { it.effect("fails an unresolved hosted tool before one clean step end", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Settle hosted tool before ending" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Settle hosted tool before ending") response = [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-clean-end", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), + hostedCall("call-hosted-clean-end", "effect"), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), ] @@ -4077,13 +3614,8 @@ describe("SessionRunnerLLM", () => { it.effect("settles unresolved local and hosted tools before one raw provider failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail unresolved tools" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Fail unresolved tools") const failure = invalidRequest() const providerFailed = yield* Deferred.make() toolExecutionGate = yield* Deferred.make() @@ -4091,12 +3623,7 @@ describe("SessionRunnerLLM", () => { Stream.fromIterable([ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }), - LLMEvent.toolCall({ - id: "call-hosted-raw-failure-pair", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), + hostedCall("call-hosted-raw-failure-pair", "effect"), ]), Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe(Stream.flatMap(() => Stream.fail(failure))), ) @@ -4125,25 +3652,11 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails a hosted tool left unresolved by a raw provider stream failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail hosted tool on raw failure" }), - resume: false, - }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Fail hosted tool on raw failure") const failure = providerUnavailable() responseStream = Stream.concat( - Stream.fromIterable([ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-raw-failure", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), - ]), + Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]), Stream.fail(failure), ) @@ -4175,13 +3688,9 @@ describe("SessionRunnerLLM", () => { it.effect("rejects a second text start before the open fragment ends", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Two blocks" }), resume: false }) + const session = yield* setup + yield* admit(session, "Two blocks") - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-1" }), @@ -4197,13 +3706,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects sequential text fragments as separate content parts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Two blocks" }), resume: false }) + const session = yield* setup + yield* admit(session, "Two blocks") - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-1" }), @@ -4245,11 +3750,7 @@ describe("SessionRunnerLLM", () => { it.effect("rejects duplicate streamed text starts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - responses = undefined - streamGate = undefined - streamStarted = undefined + const session = yield* setup response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })] const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) @@ -4261,23 +3762,15 @@ describe("SessionRunnerLLM", () => { it.effect("transitions streamed raw tool input to parsed called input", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Call provider tool" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Call provider tool") - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }), LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }), LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }), - LLMEvent.toolCall({ id: "call-parsed", name: "web_search", input: { query: "hello" }, providerExecuted: true }), + hostedCall("call-parsed", "hello"), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), ] @@ -4296,11 +3789,7 @@ describe("SessionRunnerLLM", () => { it.effect("rejects malformed streamed tool input ordering", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - responses = undefined - streamGate = undefined - streamStarted = undefined + const session = yield* setup response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })] const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 8f9803ef4a..d8cd7fbb56 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -26,7 +26,8 @@ const skills = Layer.mock(SkillV2.Service, { list: () => Effect.succeed([ SkillV2.Info.make({ - name: "effect", + id: SkillV2.ID.make("effect"), + name: SkillV2.Name.make("Effect"), description: "Effect guidance", location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), content: "Use Effect", @@ -60,10 +61,10 @@ describe("SessionV2.skill", () => { const session = yield* sessions.create({ location }) const id = SessionMessage.ID.make("msg_caller_skill") - yield* sessions.skill({ id, sessionID: session.id, skill: "effect", resume: false }) + yield* sessions.skill({ id, sessionID: session.id, skill: SkillV2.ID.make("effect"), resume: false }) expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual( - expect.objectContaining({ id, type: "skill", name: "effect", text: "Use Effect" }), + expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }), ) }), ) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 8344771d2a..e61bb54b63 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -4,6 +4,7 @@ import { DateTime, Effect, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { AgentV2 } from "@opencode-ai/core/agent" import { EventTable } from "@opencode-ai/core/event/sql" import { ModelV2 } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" @@ -51,7 +52,7 @@ describe("Tool.Progress", () => { yield* service.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model, }) const readAssistant = Effect.gen(function* () { diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index ae68d6c7ff..100d44cfe2 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -76,6 +76,7 @@ test("Core reuses the canonical shared schemas", async () => { const schemas = [ [AgentV2.ID, Agent.ID], + [AgentV2.Name, Agent.Name], [AgentV2.Color, Agent.Color], [AgentV2.Info, Agent.Info], [coreCommand.Info, Command.Info], @@ -145,7 +146,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionMessage.Synthetic, SessionMessage.Synthetic], [coreSessionMessage.System, SessionMessage.System], [coreSessionMessage.Shell, SessionMessage.Shell], - [coreSessionMessage.ToolStatePending, SessionMessage.ToolStatePending], + [coreSessionMessage.ToolStateStreaming, SessionMessage.ToolStateStreaming], [coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning], [coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted], [coreSessionMessage.ToolStateError, SessionMessage.ToolStateError], @@ -156,7 +157,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionMessage.AssistantContent, SessionMessage.AssistantContent], [coreSessionMessage.Assistant, SessionMessage.Assistant], [coreSessionMessage.Compaction, SessionMessage.Compaction], - [coreSessionMessage.Message, SessionMessage.Message], + [coreSessionMessage.Info, SessionMessage.Info], [coreSessionTodo.Info, SessionTodo.Info], [coreSessionTodo.Event, SessionTodo.Event], [coreSkill.DirectorySource, Skill.DirectorySource], diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index b9193023bf..1b8d846e69 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -88,13 +88,15 @@ describe("SkillV2", () => { ]) expect(yield* skill.list()).toEqual([ SkillV2.Info.make({ - name: "foo", + id: SkillV2.ID.make("foo"), + name: SkillV2.Name.make("foo"), slash: true, location: AbsolutePath.make(path.join(first, "foo.md")), content: "# foo", }), { - name: "review", + id: SkillV2.ID.make("review"), + name: SkillV2.Name.make("review"), description: "Second", location: AbsolutePath.make(path.join(second, "review", "SKILL.md")), content: "# review", @@ -129,8 +131,8 @@ describe("SkillV2", () => { const skill = yield* SkillV2.Service yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) - expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) - expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) + expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")]) + expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")]) expect(pulls).toBe(1) expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([]) }), @@ -165,7 +167,8 @@ metadata: expect(yield* skill.list()).toEqual([ { - name: "manual", + id: SkillV2.ID.make("manual"), + name: SkillV2.Name.make("manual"), description: "Manual only", slash: true, autoinvoke: false, diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 704f42b803..1c5b5b2839 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -11,24 +11,28 @@ import { it } from "../lib/effect" const build = AgentV2.ID.make("build") const effect = SkillV2.Info.make({ - name: "effect", + id: SkillV2.ID.make("effect"), + name: SkillV2.Name.make("Effect"), description: "Build applications with Effect", location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), content: "Effect guidance", }) const hidden = SkillV2.Info.make({ - name: "hidden", + id: SkillV2.ID.make("hidden"), + name: SkillV2.Name.make("Hidden"), location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")), content: "Undescribed guidance", }) const denied = SkillV2.Info.make({ - name: "denied", + id: SkillV2.ID.make("denied"), + name: SkillV2.Name.make("Denied"), description: "Must not be advertised", location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")), content: "Denied guidance", }) const manual = SkillV2.Info.make({ - name: "manual", + id: SkillV2.ID.make("manual"), + name: SkillV2.Name.make("Manual"), description: "Load only when explicitly selected", autoinvoke: false, location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")), @@ -59,7 +63,8 @@ describe("SkillGuidance", () => { "Use the skill tool to load a skill when a task matches its description.", "", " ", - " effect", + " effect", + " Effect", " Build applications with Effect", " ", "", @@ -74,7 +79,7 @@ describe("SkillGuidance", () => { .pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))), ).toMatchObject({ _tag: "Updated", - text: "The following skills are no longer available and must not be used: effect.", + text: "The following skill IDs are no longer available and must not be used: effect.", }) }).pipe(Effect.provide(layer(() => skills))) }) @@ -82,7 +87,8 @@ describe("SkillGuidance", () => { it.effect("announces added and removed skills as deltas without restating the list", () => { const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) const debugging = SkillV2.Info.make({ - name: "debugging", + id: SkillV2.ID.make("debugging"), + name: SkillV2.Name.make("Debugging"), description: "Diagnose hard bugs", location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")), content: "Debugging guidance", @@ -103,7 +109,8 @@ describe("SkillGuidance", () => { text: [ "New skills are available in addition to those previously listed:", " ", - " debugging", + " debugging", + " Debugging", " Diagnose hard bugs", " ", ].join("\n"), @@ -117,7 +124,7 @@ describe("SkillGuidance", () => { ) expect(removed).toMatchObject({ _tag: "Updated", - text: "The following skills are no longer available and must not be used: effect.", + text: "The following skill IDs are no longer available and must not be used: effect.", }) }).pipe(Effect.provide(layer(() => skills))) }) @@ -192,7 +199,7 @@ describe("SkillGuidance", () => { const guidance = yield* SkillGuidance.Service expect( (yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text, - ).toContain("effect") + ).toContain("Effect") }).pipe(Effect.provide(layer(() => [effect]))) }) diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 5e01fefc17..3bc0f34101 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -56,7 +56,7 @@ describe("Snapshot", () => { const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]]) const preview = yield* snapshot.preview({ files: plan, context: 1 }) expect(preview).toHaveLength(1) - expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt")) yield* snapshot.restore({ files: plan }) expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n") expect(yield* read(path.join(location, "added.txt"))).toBe("added\n") diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-patch.test.ts similarity index 96% rename from packages/core/test/tool-apply-patch.test.ts rename to packages/core/test/tool-patch.test.ts index 6aa7c89aa2..43b20d0cbf 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -13,20 +13,20 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch" +import { PatchTool } from "@opencode-ai/core/tool/patch" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" -const applyPatchToolNode = makeLocationNode({ - name: "test/apply-patch-tool-plugin", - layer: Layer.effectDiscard(registerToolPlugin(ApplyPatchTool.Plugin)), +const patchToolNode = makeLocationNode({ + name: "test/patch-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], }) -const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test") +const sessionID = SessionV2.ID.make("ses_patch_tool_test") const assertions: PermissionV2.AssertInput[] = [] let denyAction: string | undefined let failRemoveTarget: string | undefined @@ -116,7 +116,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, - applyPatchToolNode, + patchToolNode, ]), [ [FSUtil.node, filesystem], @@ -129,13 +129,13 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ) } -const call = (patchText: string, id = "call-apply-patch") => ({ +const call = (patchText: string, id = "call-patch") => ({ sessionID, ...toolIdentity, - call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } }, + call: { type: "tool-call" as const, id, name: "patch", input: { patchText } }, }) -// apply_patch is only materialized for OpenAI/GPT models. +// patch is only materialized for OpenAI/GPT models. const model = { id: "gpt-5", provider: "openai" } const exists = (target: string) => @@ -147,7 +147,7 @@ const exists = (target: string) => ) const it = testEffect(Layer.empty) -describe("ApplyPatchTool", () => { +describe("PatchTool", () => { it.live("registers and sequentially applies add, update, and delete hunks", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -162,7 +162,7 @@ describe("ApplyPatchTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([ - "apply_patch", + "patch", ]) const settled = yield* settleTool( registry, @@ -241,7 +241,7 @@ describe("ApplyPatchTool", () => { ), model, ), - ).toEqual({ type: "error", value: "apply_patch moves are not supported yet" }) + ).toEqual({ type: "error", value: "patch moves are not supported yet" }) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) expect(assertions).toEqual([]) }), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index e92465fc3d..6a6a53e171 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -3,6 +3,7 @@ import { realpathSync } from "node:fs" import path from "path" import { describe, expect, test } from "bun:test" import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect" +import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" @@ -104,7 +105,7 @@ const executionNode = makeGlobalNode({ sessionID: id, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, }) }) @@ -442,10 +443,7 @@ describe("ShellTool", () => { reset() return withSession(tmp.path, (registry) => Effect.gen(function* () { - const settled = yield* settleTool( - registry, - call({ command: idleCommand, timeout: 50, background: true }), - ) + const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true })) const structured = settled.output?.structured as Record | undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined expect(settled.output?.structured).toMatchObject({ truncated: false }) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 6a49fa22fa..8d81917d8c 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -26,7 +26,7 @@ const skillToolNode = makeLocationNode({ const sessionID = SessionV2.ID.make("ses_skill_tool_test") describe("SkillTool", () => { - it.live("lists available skills, authorizes the selected name, and loads model-facing content", () => + it.live("lists available skills, authorizes the selected ID, and loads model-facing content", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -42,7 +42,8 @@ describe("SkillTool", () => { ) const info: SkillV2.Info = { - name: "effect", + id: SkillV2.ID.make("effect"), + name: SkillV2.Name.make("Effect"), description: "Use Effect", location: AbsolutePath.make(location), content: "# Effect\n\nGuidance", @@ -102,7 +103,7 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } }, + call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } }, }), ).toEqual({ type: "text", @@ -113,11 +114,11 @@ describe("SkillTool", () => { yield* settleTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } }, + call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } }, }), ).toMatchObject({ result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) }, - output: { structured: { name: "effect" } }, + output: { structured: { name: "Effect" } }, }) expect(assertions).toMatchObject([ { sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, @@ -127,7 +128,7 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } }, + call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } }, }), ).toEqual({ type: "error", value: "Unable to load skill missing" }) deny = true @@ -135,12 +136,13 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } }, + call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } }, }), ).toEqual({ type: "error", value: "Unable to load skill effect" }) deny = false const flat = SkillV2.Info.make({ - name: "public", + id: SkillV2.ID.make("public"), + name: SkillV2.Name.make("Public"), description: "Public guidance", location: AbsolutePath.make(path.join(tmp.path, "public.md")), content: "Public", @@ -156,7 +158,7 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } }, + call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } }, }), ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) }).pipe(Effect.provide(skillToolLayer)) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 9f7bf8c7b9..d2e6396710 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Layer, Schema } from "effect" +import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" @@ -70,7 +71,7 @@ const executionNode = makeGlobalNode({ sessionID, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens, }) }) @@ -107,6 +108,11 @@ const withSubagent = (location: Location.Ref) => const locations = yield* LocationServiceMap.Service yield* AgentV2.Service.use((agents) => agents.transform((draft) => { + // The caller identity used by executeTool; subagent permission asserts against it. + draft.update(toolIdentity.agent, (agent) => { + agent.mode = "primary" + agent.permissions.push({ action: "*", resource: "*", effect: "allow" }) + }) draft.update(AgentV2.ID.make("reviewer"), (agent) => { agent.mode = "subagent" agent.model = childModel diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 68a975cf85..e8559d72f6 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -17,7 +17,12 @@ "tabs": [ { "tab": "Docs", - "pages": ["index", "config", "plugins", "troubleshooting"] + "groups": [ + { + "group": "Get started", + "pages": ["index", "config", "plugins", "troubleshooting"] + } + ] }, { "tab": "SDK", @@ -39,19 +44,6 @@ ], "global": {} }, - "navbar": { - "links": [ - { - "label": "Discord", - "href": "https://opencode.ai/discord" - } - ], - "primary": { - "type": "button", - "label": "GitHub", - "href": "https://github.com/anomalyco/opencode" - } - }, "contextual": { "options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"] }, diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index bcad31bd32..c2cd5a1d21 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -27,10 +27,23 @@ "enum": [ true ] + }, + "version": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] } }, "required": [ - "healthy" + "healthy", + "version", + "pid" ], "additionalProperties": false } @@ -210,7 +223,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/AgentV2.Info" + "$ref": "#/components/schemas/Agent.Info" } } }, @@ -582,7 +595,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -699,14 +712,10 @@ "$ref": "#/components/schemas/SessionActive" } } - }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" } }, "required": [ - "data", - "watermarks" + "data" ], "additionalProperties": false } @@ -734,7 +743,7 @@ } } }, - "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.", + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.", "summary": "List active sessions" } }, @@ -769,7 +778,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -820,6 +829,72 @@ }, "description": "Retrieve a session by ID.", "summary": "Get session" + }, + "delete": { + "tags": [ + "session" + ], + "operationId": "v2.session.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Delete a session and its child sessions.", + "summary": "Delete session" } }, "/api/session/{sessionID}/fork": { @@ -853,7 +928,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -1198,6 +1273,119 @@ } } }, + "/api/session/{sessionID}/move": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.move", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Move a session to another project directory, optionally transferring local changes.", + "summary": "Move session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destination": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "moveChanges": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "destination" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": [ @@ -1245,7 +1433,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1397,7 +1592,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1759,6 +1961,16 @@ }, "metadata": { "type": "object" + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1897,8 +2109,24 @@ ], "security": [], "responses": { - "204": { - "description": "" + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Compaction" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } }, "400": { "description": "InvalidRequestError", @@ -1938,38 +2166,46 @@ } }, "409": { - "description": "SessionBusyError", + "description": "ConflictError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionBusyError" - } - } - } - }, - "500": { - "description": "UnknownError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnknownError" - } - } - } - }, - "503": { - "description": "ServiceUnavailableError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceUnavailableError" + "$ref": "#/components/schemas/ConflictError" } } } } }, - "description": "Compact a session conversation.", - "summary": "Compact session" + "description": "Queue a durable session compaction request.", + "summary": "Compact session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } } }, "/api/session/{sessionID}/wait": { @@ -2081,7 +2317,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -2388,7 +2624,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } } }, @@ -2452,12 +2688,12 @@ "summary": "Get session context" } }, - "/api/session/{sessionID}/context-entry": { + "/api/session/{sessionID}/instructions/entries": { "get": { "tags": [ "session" ], - "operationId": "v2.session.context.entry.list", + "operationId": "v2.session.instructions.entry.list", "parameters": [ { "name": "sessionID", @@ -2485,7 +2721,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionContextEntry.Info" + "$ref": "#/components/schemas/InstructionEntry.Info" } } }, @@ -2535,16 +2771,16 @@ } } }, - "description": "List API-managed context entries attached to the session's system context.", - "summary": "List context entries" + "description": "List API-managed instruction entries attached to the session.", + "summary": "List instruction entries" } }, - "/api/session/{sessionID}/context-entry/{key}": { + "/api/session/{sessionID}/instructions/entries/{key}": { "put": { "tags": [ "session" ], - "operationId": "v2.session.context.entry.put", + "operationId": "v2.session.instructions.entry.put", "parameters": [ { "name": "sessionID", @@ -2563,7 +2799,7 @@ "name": "key", "in": "path", "schema": { - "$ref": "#/components/schemas/SessionContextEntry.Key" + "$ref": "#/components/schemas/InstructionEntry.Key" }, "required": true } @@ -2611,8 +2847,8 @@ } } }, - "description": "Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.", - "summary": "Put context entry", + "description": "Attach or replace one durable instruction entry. Changes announce as updates at the next step boundary.", + "summary": "Put instruction entry", "requestBody": { "content": { "application/json": { @@ -2635,7 +2871,7 @@ "tags": [ "session" ], - "operationId": "v2.session.context.entry.remove", + "operationId": "v2.session.instructions.entry.remove", "parameters": [ { "name": "sessionID", @@ -2654,7 +2890,7 @@ "name": "key", "in": "path", "schema": { - "$ref": "#/components/schemas/SessionContextEntry.Key" + "$ref": "#/components/schemas/InstructionEntry.Key" }, "required": true } @@ -2702,11 +2938,11 @@ } } }, - "description": "Remove one context entry; the removal is announced to the model at the next turn boundary.", - "summary": "Remove context entry" + "description": "Remove one instruction entry; the removal is announced to the model at the next step boundary.", + "summary": "Remove instruction entry" } }, - "/api/session/{sessionID}/log": { + "/api/experimental/session/{sessionID}/log": { "get": { "tags": [ "session" @@ -2911,7 +3147,7 @@ } } }, - "description": "Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.", + "description": "Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.", "summary": "Read the session log" } }, @@ -3095,7 +3331,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, "required": [ @@ -3156,7 +3392,7 @@ "tags": [ "session" ], - "operationId": "v2.session.messages", + "operationId": "v2.message.list", "parameters": [ { "name": "sessionID", @@ -3358,7 +3594,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" } } }, @@ -3469,7 +3705,7 @@ "data": { "anyOf": [ { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" }, { "type": "null" @@ -4781,6 +5017,104 @@ "summary": "List MCP servers" } }, + "/api/mcp/resource": { + "get": { + "tags": [ + "mcp" + ], + "operationId": "v2.mcp.resource.catalog", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Mcp.ResourceCatalog" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve resources and resource templates from connected MCP servers.", + "summary": "List MCP resources" + } + }, "/api/credential/{credentialID}": { "patch": { "tags": [ @@ -6978,7 +7312,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/CommandV2.Info" + "$ref": "#/components/schemas/Command.Info" } } }, @@ -7079,7 +7413,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SkillV2.Info" + "$ref": "#/components/schemas/Skill.Info" } } }, @@ -7257,154 +7591,10 @@ } } }, - "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.", + "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.", "summary": "Subscribe to events" } }, - "/api/event/changes": { - "get": { - "tags": [ - "event" - ], - "operationId": "v2.event.changes", - "parameters": [], - "security": [], - "responses": { - "200": { - "description": "Success", - "content": { - "text/event-stream": { - "schema": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "event": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/EventLog.ChangeStream" - } - }, - "required": [ - "id", - "event", - "data" - ], - "additionalProperties": false - }, - "x-effect-stream": { - "encoding": "sse", - "causeSchema": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Fail" - ] - }, - "error": { - "not": {} - } - }, - "required": [ - "_tag", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Die" - ] - }, - "defect": {} - }, - "required": [ - "_tag", - "defect" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Interrupt" - ] - }, - "fiberId": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "fiberId" - ], - "additionalProperties": false - } - ] - } - }, - "errorSchema": { - "not": {} - }, - "failureEvent": "effect/httpapi/stream/failure" - } - } - } - }, - "400": { - "description": "InvalidRequestError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InvalidRequestError" - } - } - } - }, - "401": { - "description": "UnauthorizedError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedError" - } - } - } - } - }, - "description": "Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.", - "summary": "Subscribe to change hints" - } - }, "/api/pty": { "get": { "tags": [ @@ -8022,7 +8212,7 @@ "tags": [ "pty" ], - "operationId": "v2.pty.connectToken", + "operationId": "v2.pty.connect.token", "parameters": [ { "name": "ptyID", @@ -8318,7 +8508,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } } }, @@ -8415,7 +8605,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -8475,7 +8665,8 @@ } }, "required": [ - "command" + "command", + "timeout" ], "additionalProperties": false } @@ -8559,7 +8750,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -8705,6 +8896,151 @@ "summary": "Remove shell command" } }, + "/api/shell/{id}/timeout": { + "patch": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.timeout", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell1" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Replace a running shell command's timeout from now, or clear it with zero.", + "summary": "Update shell timeout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "timeout" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/shell/{id}/output": { "get": { "tags": [ @@ -9864,7 +10200,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -9907,7 +10243,7 @@ "tags": [ "debug" ], - "operationId": "v2.debug.location", + "operationId": "v2.debug.location.list", "parameters": [], "security": [], "responses": { @@ -9947,6 +10283,82 @@ }, "description": "List locations currently loaded by the server.", "summary": "List loaded locations" + }, + "delete": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location.evict", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Dispose the requested location's cached services so its next use boots them fresh.", + "summary": "Evict a loaded location" } } }, @@ -10150,12 +10562,15 @@ "$ref": "#/components/schemas/PermissionV2.Rule" } }, - "AgentV2.Info": { + "Agent.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "name": { + "type": "string" + }, "model": { "$ref": "#/components/schemas/Model.Ref" }, @@ -10196,6 +10611,7 @@ }, "required": [ "id", + "name", "request", "mode", "hidden", @@ -10215,6 +10631,46 @@ ], "additionalProperties": false }, + "Money.USD": { + "type": "number" + }, + "TokenUsage.Info": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, "Location.Ref": { "type": "object", "properties": { @@ -10235,19 +10691,14 @@ ], "additionalProperties": false }, - "File.Diff": { + "FileDiff.Info": { "type": "object", "properties": { - "path": { + "file": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "added", - "modified", - "deleted" - ] + "patch": { + "type": "string" }, "additions": { "type": "integer", @@ -10265,20 +10716,25 @@ } ] }, - "patch": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] } }, "required": [ - "path", - "status", + "file", + "patch", "additions", "deletions", - "patch" + "status" ], "additionalProperties": false }, - "Revert.State": { + "Session.Revert": { "type": "object", "properties": { "messageID": { @@ -10295,13 +10751,10 @@ "snapshot": { "type": "string" }, - "diff": { - "type": "string" - }, "files": { "type": "array", "items": { - "$ref": "#/components/schemas/File.Diff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -10310,7 +10763,7 @@ ], "additionalProperties": false }, - "SessionV2.Info": { + "Session.Info": { "type": "object", "properties": { "id": { @@ -10329,6 +10782,31 @@ } ] }, + "fork": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + }, "projectID": { "type": "string" }, @@ -10339,44 +10817,10 @@ "$ref": "#/components/schemas/Model.Ref" }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "time": { "type": "object", @@ -10407,7 +10851,7 @@ "type": "string" }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -10421,32 +10865,15 @@ ], "additionalProperties": false }, - "SessionWatermarks": { - "type": "object", - "patternProperties": { - "^ses": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "description": "Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent." - }, "SessionsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" - }, "cursor": { "type": "object", "properties": { @@ -10476,7 +10903,6 @@ }, "required": [ "data", - "watermarks", "cursor" ], "additionalProperties": false @@ -10604,7 +11030,7 @@ ], "additionalProperties": false }, - "Prompt.Source": { + "Prompt.Mention": { "type": "object", "properties": { "start": { @@ -10636,8 +11062,8 @@ "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10651,8 +11077,8 @@ "name": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10684,28 +11110,78 @@ ], "additionalProperties": false }, + "Prompt.Base64": { + "type": "string", + "allOf": [ + { + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + } + ] + }, + "Prompt.FileSource": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inline" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "uri" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "additionalProperties": false + } + ] + }, "Prompt.FileAttachment": { "type": "object", "properties": { - "uri": { - "type": "string" + "data": { + "$ref": "#/components/schemas/Prompt.Base64" }, "mime": { "type": "string" }, + "source": { + "$ref": "#/components/schemas/Prompt.FileSource" + }, "name": { "type": "string" }, "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ - "uri", - "mime" + "data", + "mime", + "source" ], "additionalProperties": false }, @@ -10890,26 +11366,57 @@ ], "additionalProperties": false }, - "SessionBusyError": { + "SessionInput.Compaction": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", "enum": [ - "SessionBusyError" + "compaction" + ] + }, + "admittedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } ] }, "sessionID": { - "type": "string" + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, - "message": { - "type": "string" + "timeCreated": { + "type": "number" + }, + "handledSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ - "_tag", + "type", + "admittedSeq", + "id", "sessionID", - "message" + "timeCreated" ], "additionalProperties": false }, @@ -10942,6 +11449,29 @@ ], "additionalProperties": false }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionBusyError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, "UnknownError": { "type": "object", "properties": { @@ -11144,14 +11674,6 @@ ], "additionalProperties": false }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, "text": { "type": "string" }, @@ -11168,7 +11690,6 @@ "required": [ "id", "time", - "sessionID", "text", "type" ], @@ -11250,6 +11771,9 @@ "skill" ] }, + "skill": { + "type": "string" + }, "name": { "type": "string" }, @@ -11261,187 +11785,12 @@ "id", "time", "type", + "skill", "name", "text" ], "additionalProperties": false }, - "Shell": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^sh_" - } - ] - }, - "status": { - "type": "string", - "enum": [ - "running", - "exited", - "timeout", - "killed" - ] - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string" - }, - "shell": { - "type": "string" - }, - "file": { - "type": "string" - }, - "pid": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "exit": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "started": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "completed": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - } - }, - "required": [ - "started" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "status", - "command", - "cwd", - "shell", - "file", - "metadata", - "time" - ], - "additionalProperties": false - }, "Session.Message.Shell": { "type": "object", "properties": { @@ -11477,8 +11826,62 @@ "shell" ] }, - "shell": { - "$ref": "#/components/schemas/Shell" + "shellID": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "command": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] }, "output": { "type": "object", @@ -11519,7 +11922,9 @@ "id", "time", "type", - "shell" + "shellID", + "command", + "status" ], "additionalProperties": false }, @@ -11532,25 +11937,18 @@ "text" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" } }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "LLM.ProviderMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState": { + "type": "object" }, "Session.Message.Assistant.Reasoning": { "type": "object", @@ -11561,14 +11959,11 @@ "reasoning" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "time": { "type": "object", @@ -11588,18 +11983,17 @@ }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "Session.Message.ToolState.Pending": { + "Session.Message.ToolState.Streaming": { "type": "object", "properties": { "status": { "type": "string", "enum": [ - "pending" + "streaming" ] }, "input": { @@ -11709,24 +12103,12 @@ "input": { "type": "object" }, - "attachments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prompt.FileAttachment" - } - }, "content": { "type": "array", "items": { "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "structured": { "type": "object" }, @@ -11740,14 +12122,11 @@ ], "additionalProperties": false }, - "Session.Error.Unknown": { + "Session.StructuredError": { "type": "object", "properties": { "type": { - "type": "string", - "enum": [ - "unknown" - ] + "type": "string" }, "message": { "type": "string" @@ -11781,7 +12160,7 @@ "type": "object" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {} }, @@ -11809,28 +12188,19 @@ "name": { "type": "string" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - }, - "resultMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" + }, + "providerResultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "state": { "anyOf": [ { - "$ref": "#/components/schemas/Session.Message.ToolState.Pending" + "$ref": "#/components/schemas/Session.Message.ToolState.Streaming" }, { "$ref": "#/components/schemas/Session.Message.ToolState.Running" @@ -11854,9 +12224,6 @@ }, "completed": { "type": "number" - }, - "pruned": { - "type": "number" } }, "required": [ @@ -11874,6 +12241,31 @@ ], "additionalProperties": false }, + "Session.Message.Assistant.Retry": { + "type": "object", + "properties": { + "attempt": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "attempt", + "at", + "error" + ], + "additionalProperties": false + }, "Session.Message.Assistant": { "type": "object", "properties": { @@ -11950,50 +12342,27 @@ "additionalProperties": false }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "retry": { + "$ref": "#/components/schemas/Session.Message.Assistant.Retry" } }, "required": [ @@ -12006,7 +12375,7 @@ ], "additionalProperties": false }, - "Session.Message.Compaction": { + "Session.Message.Compaction.Running": { "type": "object", "properties": { "type": { @@ -12015,19 +12384,6 @@ "compaction" ] }, - "reason": { - "type": "string", - "enum": [ - "auto", - "manual" - ] - }, - "summary": { - "type": "string" - }, - "recent": { - "type": "string" - }, "id": { "type": "string", "allOf": [ @@ -12050,19 +12406,174 @@ "created" ], "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" } }, "required": [ "type", + "id", + "time", + "status", "reason", "summary", - "recent", - "id", - "time" + "recent" ], "additionalProperties": false }, - "Session.Message": { + "Session.Message.Compaction.Completed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "summary", + "recent" + ], + "additionalProperties": false + }, + "Session.Message.Compaction.Failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "error" + ], + "additionalProperties": false + }, + "Session.Message.Compaction": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.Compaction.Running" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Failed" + } + ] + }, + "Session.Message.Info": { "anyOf": [ { "$ref": "#/components/schemas/Session.Message.AgentSelected" @@ -12093,20 +12604,20 @@ } ] }, - "SessionContextEntry.Key": { + "InstructionEntry.Key": { "type": "string", "allOf": [ { "pattern": "^[a-z0-9][a-z0-9._-]*$", - "description": "Context entry key (lowercase alphanumerics plus . _ -)" + "description": "Instruction entry key (lowercase alphanumerics plus . _ -)" } ] }, - "SessionContextEntry.Info": { + "InstructionEntry.Info": { "type": "object", "properties": { "key": { - "$ref": "#/components/schemas/SessionContextEntry.Key" + "$ref": "#/components/schemas/InstructionEntry.Key" }, "value": {} }, @@ -12154,11 +12665,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12241,11 +12750,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12328,11 +12835,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12418,11 +12923,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12467,6 +12970,87 @@ ], "additionalProperties": false }, + "session.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.deleted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 2 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.forked": { "type": "object", "properties": { @@ -12505,11 +13089,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12605,11 +13187,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12697,11 +13277,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12763,7 +13341,7 @@ ], "additionalProperties": false }, - "session.context.updated": { + "session.execution.started": { "type": "object", "properties": { "id": { @@ -12783,7 +13361,7 @@ "type": { "type": "string", "enum": [ - "session.context.updated" + "session.execution.started" ] }, "durable": { @@ -12801,12 +13379,347 @@ ] }, "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.succeeded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.succeeded" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { "type": "integer", "allOf": [ { - "minimum": 1 + "minimum": 0 } ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "sessionID", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.interrupted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.interrupted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "user", + "shutdown", + "superseded" + ] + } + }, + "required": [ + "sessionID", + "reason" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.instructions.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.instructions.updated" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] } }, "required": [ @@ -12888,11 +13801,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12981,11 +13892,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13010,6 +13919,9 @@ } ] }, + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -13019,6 +13931,7 @@ }, "required": [ "sessionID", + "id", "name", "text" ], @@ -13034,7 +13947,7 @@ ], "additionalProperties": false }, - "Shell1": { + "Shell": { "type": "object", "properties": { "id": { @@ -13212,11 +14125,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13242,7 +14153,7 @@ ] }, "shell": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -13299,11 +14210,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13329,7 +14238,7 @@ ] }, "shell": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" }, "output": { "type": "object", @@ -13421,11 +14330,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13524,11 +14431,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13562,47 +14467,21 @@ ] }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "snapshot": { "type": "string" @@ -13671,11 +14550,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13709,7 +14586,13 @@ ] }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ @@ -13767,11 +14650,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13804,14 +14685,19 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ "sessionID", "assistantMessageID", - "textID" + "ordinal" ], "additionalProperties": false } @@ -13863,11 +14749,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13900,8 +14784,13 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "text": { "type": "string" @@ -13910,7 +14799,7 @@ "required": [ "sessionID", "assistantMessageID", - "textID", + "ordinal", "text" ], "additionalProperties": false @@ -13925,11 +14814,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata3": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState3": { + "type": "object" }, "session.reasoning.started": { "type": "object", @@ -13969,11 +14855,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14006,17 +14890,22 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata3" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState3" } }, "required": [ "sessionID", "assistantMessageID", - "reasoningID" + "ordinal" ], "additionalProperties": false } @@ -14030,11 +14919,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata4": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState4": { + "type": "object" }, "session.reasoning.ended": { "type": "object", @@ -14074,11 +14960,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14111,20 +14995,25 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "text": { "type": "string" }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata4" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState4" } }, "required": [ "sessionID", "assistantMessageID", - "reasoningID", + "ordinal", "text" ], "additionalProperties": false @@ -14177,11 +15066,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14277,11 +15164,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14339,11 +15224,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata5": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState5": { + "type": "object" }, "session.tool.called": { "type": "object", @@ -14383,11 +15265,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14423,35 +15303,22 @@ "callID": { "type": "string" }, - "tool": { - "type": "string" - }, "input": { "type": "object" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata5" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState5" } }, "required": [ "sessionID", "assistantMessageID", "callID", - "tool", "input", - "provider" + "executed" ], "additionalProperties": false } @@ -14503,11 +15370,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14572,11 +15437,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata6": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState6": { + "type": "object" }, "session.tool.success": { "type": "object", @@ -14616,11 +15478,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14665,27 +15525,12 @@ "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata6" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState6" } }, "required": [ @@ -14694,7 +15539,7 @@ "callID", "structured", "content", - "provider" + "executed" ], "additionalProperties": false } @@ -14708,11 +15553,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata7": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState7": { + "type": "object" }, "session.tool.failed": { "type": "object", @@ -14752,11 +15594,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14793,23 +15633,14 @@ "type": "string" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata7" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState7" } }, "required": [ @@ -14817,7 +15648,7 @@ "assistantMessageID", "callID", "error", - "provider" + "executed" ], "additionalProperties": false } @@ -14831,41 +15662,7 @@ ], "additionalProperties": false }, - "session.retry.error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { - "type": "number" - }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "message", - "isRetryable" - ], - "additionalProperties": false - }, - "session.retried": { + "session.retry.scheduled": { "type": "object", "properties": { "id": { @@ -14885,7 +15682,7 @@ "type": { "type": "string", "enum": [ - "session.retried" + "session.retry.scheduled" ] }, "durable": { @@ -14903,11 +15700,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14932,16 +15727,39 @@ } ] }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, "attempt": { - "type": "number" + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "error": { - "$ref": "#/components/schemas/session.retry.error" + "$ref": "#/components/schemas/Session.StructuredError" } }, "required": [ "sessionID", + "assistantMessageID", "attempt", + "at", "error" ], "additionalProperties": false @@ -14956,6 +15774,96 @@ ], "additionalProperties": false }, + "session.compaction.admitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.admitted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.compaction.started": { "type": "object", "properties": { @@ -14994,11 +15902,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15029,11 +15935,23 @@ "auto", "manual" ] + }, + "recent": { + "type": "string" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] } }, "required": [ "sessionID", - "reason" + "reason", + "recent" ], "additionalProperties": false } @@ -15085,11 +16003,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15146,6 +16062,107 @@ ], "additionalProperties": false }, + "session.compaction.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "number", + "enum": [ + 1 + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "reason", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.revert.staged": { "type": "object", "properties": { @@ -15184,11 +16201,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15214,7 +16229,7 @@ ] }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -15271,11 +16286,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15354,11 +16367,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15383,7 +16394,7 @@ } ] }, - "messageID": { + "to": { "type": "string", "allOf": [ { @@ -15394,7 +16405,7 @@ }, "required": [ "sessionID", - "messageID" + "to" ], "additionalProperties": false } @@ -15408,7 +16419,7 @@ ], "additionalProperties": false }, - "SessionDurableEvent": { + "Session.Event.Durable": { "oneOf": [ { "$ref": "#/components/schemas/session.agent.selected" @@ -15422,6 +16433,9 @@ { "$ref": "#/components/schemas/session.renamed" }, + { + "$ref": "#/components/schemas/session.deleted" + }, { "$ref": "#/components/schemas/session.forked" }, @@ -15432,7 +16446,19 @@ "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.context.updated" + "$ref": "#/components/schemas/session.execution.started" + }, + { + "$ref": "#/components/schemas/session.execution.succeeded" + }, + { + "$ref": "#/components/schemas/session.execution.failed" + }, + { + "$ref": "#/components/schemas/session.execution.interrupted" + }, + { + "$ref": "#/components/schemas/session.instructions.updated" }, { "$ref": "#/components/schemas/session.synthetic" @@ -15486,7 +16512,10 @@ "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.retried" + "$ref": "#/components/schemas/session.retry.scheduled" + }, + { + "$ref": "#/components/schemas/session.compaction.admitted" }, { "$ref": "#/components/schemas/session.compaction.started" @@ -15494,6 +16523,9 @@ { "$ref": "#/components/schemas/session.compaction.ended" }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, { "$ref": "#/components/schemas/session.revert.staged" }, @@ -15536,7 +16568,7 @@ "SessionLogItem": { "anyOf": [ { - "$ref": "#/components/schemas/SessionDurableEvent" + "$ref": "#/components/schemas/Session.Event.Durable" }, { "$ref": "#/components/schemas/EventLog.Synced" @@ -15556,17 +16588,9 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, - "watermark": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "cursor": { "type": "object", "properties": { @@ -15600,65 +16624,6 @@ ], "additionalProperties": false }, - "Model.Api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "package" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "settings" - ], - "additionalProperties": false - } - ] - }, "Model.Capabilities": { "type": "object", "properties": { @@ -15685,6 +16650,33 @@ ], "additionalProperties": false }, + "Model.Variant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "Money.USDPerMillionTokens": { + "type": "number" + }, "Model.Cost": { "type": "object", "properties": { @@ -15708,19 +16700,19 @@ "additionalProperties": false }, "input": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "output": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "cache": { "type": "object", "properties": { "read": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "write": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" } }, "required": [ @@ -15737,12 +16729,15 @@ ], "additionalProperties": false }, - "ModelV2.Info": { + "Model.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "modelID": { + "type": "string" + }, "providerID": { "type": "string" }, @@ -15752,66 +16747,28 @@ "name": { "type": "string" }, - "api": { - "$ref": "#/components/schemas/Model.Api" + "package": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" }, "capabilities": { "$ref": "#/components/schemas/Model.Capabilities" }, - "request": { - "type": "object", - "properties": { - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "settings", - "headers", - "body" - ], - "additionalProperties": false - }, "variants": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": [ - "id", - "settings", - "headers", - "body" - ], - "additionalProperties": false + "$ref": "#/components/schemas/Model.Variant" } }, "time": { @@ -15866,11 +16823,10 @@ }, "required": [ "id", + "modelID", "providerID", "name", - "api", "capabilities", - "request", "variants", "time", "cost", @@ -15901,63 +16857,6 @@ ], "additionalProperties": false }, - "Provider.AISDK": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "package" - ], - "additionalProperties": false - }, - "Provider.Native": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "settings" - ], - "additionalProperties": false - }, - "Provider.Api": { - "anyOf": [ - { - "$ref": "#/components/schemas/Provider.AISDK" - }, - { - "$ref": "#/components/schemas/Provider.Native" - } - ] - }, "ProviderV2.Info": { "type": "object", "properties": { @@ -15973,18 +16872,26 @@ "disabled": { "type": "boolean" }, - "api": { - "$ref": "#/components/schemas/Provider.Api" + "package": { + "type": "string" }, - "request": { - "$ref": "#/components/schemas/Provider.Request" + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" } }, "required": [ "id", "name", - "api", - "request" + "package" ], "additionalProperties": false }, @@ -16941,6 +17848,80 @@ ], "additionalProperties": false }, + "Mcp.Resource": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uri" + ], + "additionalProperties": false + }, + "Mcp.ResourceTemplate": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uriTemplate": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uriTemplate" + ], + "additionalProperties": false + }, + "Mcp.ResourceCatalog": { + "type": "object", + "properties": { + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.Resource" + } + }, + "templates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.ResourceTemplate" + } + } + }, + "required": [ + "resources", + "templates" + ], + "additionalProperties": false + }, "Project.Vcs": { "type": "string", "enum": [ @@ -18158,7 +19139,7 @@ ], "additionalProperties": false }, - "CommandV2.Info": { + "Command.Info": { "type": "object", "properties": { "name": { @@ -18186,9 +19167,12 @@ ], "additionalProperties": false }, - "SkillV2.Info": { + "Skill.Info": { "type": "object", "properties": { + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -18209,6 +19193,7 @@ } }, "required": [ + "id", "name", "location", "content" @@ -18442,7 +19427,7 @@ ], "additionalProperties": false }, - "SnapshotFileDiff": { + "FileDiff.LegacyInfo": { "type": "object", "properties": { "file": { @@ -18506,7 +19491,7 @@ "$ref": "#/components/schemas/PermissionRule" } }, - "Session": { + "SessionV1.Info": { "type": "object", "properties": { "id": { @@ -18560,7 +19545,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -18775,11 +19760,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18805,7 +19788,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18862,11 +19845,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18892,7 +19873,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -18911,7 +19892,7 @@ ], "additionalProperties": false }, - "session.deleted": { + "session.deleted1": { "type": "object", "properties": { "id": { @@ -18949,11 +19930,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -18979,7 +19958,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -19141,7 +20120,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -19809,11 +20788,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -19896,11 +20873,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -21366,11 +22341,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -21457,11 +22430,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -21520,7 +22491,7 @@ ], "additionalProperties": false }, - "session.execution.settled": { + "session.usage.updated": { "type": "object", "properties": { "id": { @@ -21540,7 +22511,7 @@ "type": { "type": "string", "enum": [ - "session.execution.settled" + "session.usage.updated" ] }, "location": { @@ -21557,21 +22528,17 @@ } ] }, - "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "interrupted" - ] + "cost": { + "$ref": "#/components/schemas/Money.USD" }, - "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ "sessionID", - "outcome" + "cost", + "tokens" ], "additionalProperties": false } @@ -21629,8 +22596,13 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" @@ -21639,7 +22611,7 @@ "required": [ "sessionID", "assistantMessageID", - "textID", + "ordinal", "delta" ], "additionalProperties": false @@ -21698,8 +22670,13 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" @@ -21708,7 +22685,7 @@ "required": [ "sessionID", "assistantMessageID", - "reasoningID", + "ordinal", "delta" ], "additionalProperties": false @@ -22669,7 +23646,7 @@ "type": "object", "properties": { "info": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -24592,6 +25569,53 @@ ], "additionalProperties": false }, + "mcp.resources.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "mcp.resources.changed" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, "permission.asked": { "type": "object", "properties": { @@ -25239,7 +26263,7 @@ "$ref": "#/components/schemas/session.updated" }, { - "$ref": "#/components/schemas/session.deleted" + "$ref": "#/components/schemas/session.deleted1" }, { "$ref": "#/components/schemas/message.updated" @@ -25265,6 +26289,12 @@ { "$ref": "#/components/schemas/session.renamed" }, + { + "$ref": "#/components/schemas/session.usage.updated" + }, + { + "$ref": "#/components/schemas/session.deleted" + }, { "$ref": "#/components/schemas/session.forked" }, @@ -25275,10 +26305,19 @@ "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.execution.settled" + "$ref": "#/components/schemas/session.execution.started" }, { - "$ref": "#/components/schemas/session.context.updated" + "$ref": "#/components/schemas/session.execution.succeeded" + }, + { + "$ref": "#/components/schemas/session.execution.failed" + }, + { + "$ref": "#/components/schemas/session.execution.interrupted" + }, + { + "$ref": "#/components/schemas/session.instructions.updated" }, { "$ref": "#/components/schemas/session.synthetic" @@ -25341,7 +26380,10 @@ "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.retried" + "$ref": "#/components/schemas/session.retry.scheduled" + }, + { + "$ref": "#/components/schemas/session.compaction.admitted" }, { "$ref": "#/components/schemas/session.compaction.started" @@ -25352,6 +26394,9 @@ { "$ref": "#/components/schemas/session.compaction.ended" }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, { "$ref": "#/components/schemas/session.revert.staged" }, @@ -25463,6 +26508,9 @@ { "$ref": "#/components/schemas/mcp.status.changed" }, + { + "$ref": "#/components/schemas/mcp.resources.changed" + }, { "$ref": "#/components/schemas/permission.asked" }, @@ -25493,68 +26541,6 @@ }, "contentMediaType": "application/json" }, - "EventLog.Hint": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.hint" - ] - }, - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "type", - "aggregateID", - "seq" - ], - "additionalProperties": false, - "description": "Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee." - }, - "EventLog.SweepRequired": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.sweep_required" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false, - "description": "Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe." - }, - "EventLog.Change": { - "anyOf": [ - { - "$ref": "#/components/schemas/EventLog.Hint" - }, - { - "$ref": "#/components/schemas/EventLog.SweepRequired" - } - ] - }, - "EventLog.ChangeStream": { - "type": "string", - "contentSchema": { - "$ref": "#/components/schemas/EventLog.Change" - }, - "contentMediaType": "application/json" - }, "PtyNotFoundError": { "type": "object", "properties": { @@ -25618,6 +26604,182 @@ ], "additionalProperties": false }, + "Shell1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "completed": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" + ], + "additionalProperties": false + }, "ShellNotFoundError": { "type": "object", "properties": { @@ -25946,7 +27108,7 @@ }, { "name": "mcp", - "description": "MCP server status routes." + "description": "MCP server and resource routes." }, { "name": "credential" diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index 781bcd5cbe..ce429323d8 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,4 +1,4 @@ -import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import { FileDiffInfo, Message, Model, Part, Session } from "@opencode-ai/sdk/v2" import { iife } from "@opencode-ai/core/util/iife" import z from "zod" import { Storage } from "./storage" @@ -30,7 +30,7 @@ export namespace Share { }), z.object({ type: z.literal("session_diff"), - data: z.custom(), + data: z.custom(), }), z.object({ type: z.literal("model"), diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 8c8ac59f7a..91cb50891d 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -1,4 +1,4 @@ -import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" +import { FileDiffInfo, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" import { SessionTurn } from "@opencode-ai/session-ui/session-turn" import { SessionReview } from "@opencode-ai/session-ui/session-review" import { DataProvider } from "@opencode-ai/session-ui/context" @@ -65,7 +65,7 @@ const getData = query(async (shareID) => { shareID: string session: Session[] session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } session_status: { [sessionID: string]: SessionStatus diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index f6ad6b02d7..30822e57bb 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -1,7 +1,7 @@ import { isAbsolute, join } from "node:path" -import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" +import { Context, Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" import { HttpMethod, type HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { format } from "prettier" export type InputField = { @@ -35,8 +35,6 @@ export type Contract = { readonly groups: ReadonlyArray } -export type EndpointName = string | readonly [string, ...Array] - export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { reason: Schema.String, }) { @@ -89,7 +87,6 @@ export function compile( api: HttpApi.HttpApi, options?: { readonly groupNames?: Readonly> - readonly endpointNames?: Readonly> readonly omitEndpoints?: ReadonlySet }, ): Contract { @@ -151,8 +148,11 @@ export function compile( for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) } - const clientPath = normalizeClientPath( - options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name), + const clientPath = clientEndpointPath( + group.identifier, + Context.getOrElse(endpoint.annotations, OpenApi.Identifier, () => + group.topLevel ? endpoint.name : `${group.identifier}.${endpoint.name}`, + ), ) endpoints.push({ group: groupName, @@ -699,21 +699,17 @@ function clientOperationKey(group: Group, endpoint: Endpoint) { return [group.identifier, ...endpoint.clientPath].join(".") } -function clientEndpointName(name: string) { - return name.slice(name.lastIndexOf(".") + 1) -} - -function normalizeClientPath(path: EndpointName) { - const result = typeof path === "string" ? [path] : [...path] +function clientEndpointPath(group: string, name: string) { + const parts = name.split(".") + if (parts[0] === "v2") parts.shift() + const index = parts.lastIndexOf(group.slice(group.lastIndexOf(".") + 1)) + const result = index < 0 ? parts : parts.slice(index + 1) if (result.length === 0 || result.some((part) => part.length === 0)) { throw new GenerationError({ reason: "Client endpoint path must contain non-empty names" }) } if (result.some((part) => part === "__proto__")) { throw new GenerationError({ reason: "Client endpoint path cannot contain __proto__" }) } - if (typeof path !== "string" && result.some((part) => part.includes("."))) { - throw new GenerationError({ reason: "Nested client endpoint path segments cannot contain dots" }) - } return result as [string, ...Array] } diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index f6a55e73a0..f949f70d46 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { format } from "prettier" import { compile as compileContract, @@ -139,48 +139,50 @@ describe("HttpApiCodegen.generate", () => { expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" }) }) - test("supports explicit public endpoint names", () => { + test("derives nested paths from OpenAPI operation IDs", () => { const source = HttpApi.make("test").add( - HttpApiGroup.make("server.permission") - .add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String })) - .add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })), + HttpApiGroup.make("server.session").add( + HttpApiEndpoint.get("internal.stage", "/session/revert/stage", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.revert.stage" }), + ), + ), ) - const contract = compileContract(source, { - endpointNames: { "permission.request.list": "listRequests" }, - }) + const contract = compileContract(source, { groupNames: { "server.session": "session" } }) - expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"]) + expect(contract.groups[0]?.endpoints[0]?.clientPath).toEqual(["revert", "stage"]) + expect(OpenApi.fromApi(source).paths["/session/revert/stage"]?.get?.operationId).toBe("v2.session.revert.stage") }) - test("supports explicit nested endpoint paths while string aliases remain flat", () => { + test("uses nested OpenAPI operation IDs across emitters", () => { const source = HttpApi.make("test").add( HttpApiGroup.make("server.session") - .add(HttpApiEndpoint.get("session.instructions.list", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.put("session.instructions.put", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.delete("session.instructions.remove", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.get("session.messages", "/session/message", { success: Schema.String })), + .add( + HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.instructions.list" }), + ), + ) + .add( + HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.instructions.put" }), + ), + ) + .add( + HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.instructions.remove" }), + ), + ), ) - const contract = compileContract(source, { - groupNames: { "server.session": "session" }, - endpointNames: { - "session.instructions.list": ["instructions", "list"], - "session.instructions.put": ["instructions", "put"], - "session.instructions.remove": ["instructions", "remove"], - "session.messages": "instructions.flat", - }, - }) + const contract = compileContract(source, { groupNames: { "server.session": "session" } }) expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([ ["instructions", "list"], ["instructions", "put"], ["instructions", "remove"], - ["instructions.flat"], ]) expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([ "instructions.list", "instructions.put", "instructions.remove", - "instructions.flat", ]) const promise = emitPromise(contract, { @@ -196,7 +198,6 @@ describe("HttpApiCodegen.generate", () => { expect(promiseClient).toContain('"session": { "instructions": { "list": (requestOptions?: RequestOptions)') expect(promiseClient).toContain('"put": (requestOptions?: RequestOptions)') expect(promiseClient).toContain('"remove": (requestOptions?: RequestOptions)') - expect(promiseClient).toContain('"instructions.flat": (requestOptions?: RequestOptions)') expect(promiseTypes).toContain('import type { InstructionListWire } from "./instruction-list-wire"') expect(promiseTypes).toContain("export type SessionInstructionsListOutput = InstructionListWire") expect(promiseTypes).toContain("export type SessionInstructionsPutOutput = string") @@ -204,12 +205,12 @@ describe("HttpApiCodegen.generate", () => { const effect = emitEffect(contract) expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain( - '"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }, "instructions.flat": Endpoint3(raw)', + '"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }', ) const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" }) expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain( - '"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }, "instructions.flat": Endpoint0_3(raw)', + '"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }', ) const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" }) @@ -217,25 +218,28 @@ describe("HttpApiCodegen.generate", () => { expect(apiShape).toContain('readonly "instructions": { readonly "list": SessionInstructionsListOperation') expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation') expect(apiShape).toContain('readonly "remove": SessionInstructionsRemoveOperation') - expect(apiShape).toContain('readonly "instructions.flat": SessionInstructionsFlatOperation') }) - test("executes nested Promise endpoint aliases", async () => { + test("executes nested Promise operation IDs", async () => { const source = HttpApi.make("test").add( HttpApiGroup.make("session") - .add(HttpApiEndpoint.get("instructions.list", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.put("instructions.put", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.delete("instructions.remove", "/session/instructions", { success: Schema.String })), - ) - const output = emitPromise( - compileContract(source, { - endpointNames: { - "instructions.list": ["instructions", "list"], - "instructions.put": ["instructions", "put"], - "instructions.remove": ["instructions", "remove"], - }, - }), + .add( + HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.list" }), + ), + ) + .add( + HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.put" }), + ), + ) + .add( + HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.remove" }), + ), + ), ) + const output = emitPromise(compileContract(source)) const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) const methods: Array = [] @@ -262,59 +266,67 @@ describe("HttpApiCodegen.generate", () => { test("rejects duplicate and leaf-namespace endpoint paths", () => { const source = HttpApi.make("test").add( HttpApiGroup.make("session") - .add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })) - .add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })), + .add( + HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.list" }), + ), + ) + .add( + HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.list" }), + ), + ), ) - expect(() => - compileContract(source, { endpointNames: { first: ["instructions", "list"], second: ["instructions", "list"] } }), - ).toThrow("Client endpoint name collision: session.instructions.list") - expect(() => - compileContract(source, { endpointNames: { first: "instructions", second: ["instructions", "list"] } }), - ).toThrow("Client endpoint name collision: session.instructions.list") + expect(() => compileContract(source)).toThrow("Client endpoint name collision: session.instructions.list") }) test("rejects nested root collisions across top-level groups", () => { const source = HttpApi.make("test") .add( HttpApiGroup.make("first", { topLevel: true }).add( - HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }), + HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "instructions.list" }), + ), ), ) .add( HttpApiGroup.make("second", { topLevel: true }).add( - HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }), + HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "instructions.put" }), + ), ), ) - expect(() => - compileContract(source, { - endpointNames: { "first.list": ["instructions", "list"], "second.put": ["instructions", "put"] }, - }), - ).toThrow("Client name collision: instructions") + expect(() => compileContract(source)).toThrow("Client name collision: instructions") }) test("rejects nested paths that collide after type-name normalization", () => { const source = HttpApi.make("test").add( HttpApiGroup.make("session") - .add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })) - .add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })), + .add( + HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.foo.bar" }), + ), + ) + .add( + HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.foo-bar" }), + ), + ), ) - expect(() => compileContract(source, { endpointNames: { first: ["foo", "bar"], second: "foo-bar" } })).toThrow( - "Client endpoint type collision: SessionFooBar", - ) + expect(() => compileContract(source)).toThrow("Client endpoint type collision: SessionFooBar") }) test("rejects ambiguous and prototype-mutating nested path segments", () => { - const source = api(HttpApiEndpoint.get("get", "/session", { success: Schema.String })) + const source = api( + HttpApiEndpoint.get("get", "/session", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.__proto__.get" }), + ), + ) - expect(() => compileContract(source, { endpointNames: { get: ["a.b", "get"] } })).toThrow( - "Nested client endpoint path segments cannot contain dots", - ) - expect(() => compileContract(source, { endpointNames: { get: ["__proto__", "get"] } })).toThrow( - "Client endpoint path cannot contain __proto__", - ) + expect(() => compileContract(source)).toThrow("Client endpoint path cannot contain __proto__") }) test("rejects normalized group, operation-key, and group prototype collisions", () => { @@ -324,20 +336,38 @@ describe("HttpApiCodegen.generate", () => { expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar") const endpointType = HttpApi.make("test") - .add(HttpApiGroup.make("foo").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))) - .add(HttpApiGroup.make("fooBar").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))) - expect(() => - compileContract(endpointType, { - endpointNames: { first: ["bar", "baz"], second: ["baz"] }, - }), - ).toThrow("Client endpoint type collision: FooBarBaz") + .add( + HttpApiGroup.make("foo").add( + HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "foo.bar.baz" }), + ), + ), + ) + .add( + HttpApiGroup.make("fooBar").add( + HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "fooBar.baz" }), + ), + ), + ) + expect(() => compileContract(endpointType)).toThrow("Client endpoint type collision: FooBarBaz") const operationKey = HttpApi.make("test") - .add(HttpApiGroup.make("a.b").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String }))) - .add(HttpApiGroup.make("a").add(HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }))) - expect(() => compileContract(operationKey, { endpointNames: { get: "c", "b.c": ["b", "c"] } })).toThrow( - "Client operation key collision: a.b.c", - ) + .add( + HttpApiGroup.make("a.b").add( + HttpApiEndpoint.get("get", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "a.b.c" }), + ), + ), + ) + .add( + HttpApiGroup.make("a").add( + HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "a.b.c" }), + ), + ), + ) + expect(() => compileContract(operationKey)).toThrow("Client operation key collision: a.b.c") const prototype = HttpApi.make("test").add( HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })), diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index f626827292..6117cd5c20 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -876,6 +876,7 @@ export const protocol = Protocol.make({ export const route = Route.make({ id: ADAPTER, provider: "anthropic", + providerMetadataKey: "anthropic", protocol, endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), auth: Auth.none, diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 0c6b0598ff..4984e32365 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -657,6 +657,7 @@ export const protocol = Protocol.make({ export const route = Route.make({ id: ADAPTER, provider: "bedrock", + providerMetadataKey: "bedrock", protocol, // Bedrock's URL embeds the region in the route endpoint host and the // validated modelId in the path. We read the validated body so the URL diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index c4bb9476a4..82a69b059e 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -500,6 +500,7 @@ export const protocol = Protocol.make({ export const route = Route.make({ id: ADAPTER, provider: "google", + providerMetadataKey: "google", protocol, // Gemini's path embeds the model id and pins SSE framing at the URL level. endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, { diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index eb3b7141c6..cba67ce0a9 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -493,6 +493,7 @@ export const httpTransport = HttpTransport.sseJson.with() export const route = Route.make({ id: ADAPTER, provider: "openai", + providerMetadataKey: "openai", protocol, endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), auth: Auth.none, diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/llm/src/protocols/openai-compatible-chat.ts index ce3f0a83d7..9ae9a53b43 100644 --- a/packages/llm/src/protocols/openai-compatible-chat.ts +++ b/packages/llm/src/protocols/openai-compatible-chat.ts @@ -16,6 +16,7 @@ export type OpenAICompatibleChatModelInput = RouteRoutedModelInput */ export const route = Route.make({ id: ADAPTER, + providerMetadataKey: "openai", protocol: OpenAIChat.protocol, endpoint: Endpoint.path("/chat/completions"), framing: Framing.sse, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index e0546f47ed..e2889d5b6d 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -990,6 +990,7 @@ export const httpTransport = HttpTransport.sseJson.with() export const route = Route.make({ id: ADAPTER, provider: "openai", + providerMetadataKey: "openai", protocol, endpoint, auth, @@ -1018,6 +1019,7 @@ export const webSocketTransport = WebSocketTransport.jsonTransport.with< export const webSocketRoute = Route.make({ id: `${ADAPTER}-websocket`, provider: "openai", + providerMetadataKey: "openai", protocol, endpoint, auth, diff --git a/packages/llm/src/provider-error.ts b/packages/llm/src/provider-error.ts index 8d3f2a8f63..321bd7927e 100644 --- a/packages/llm/src/provider-error.ts +++ b/packages/llm/src/provider-error.ts @@ -6,6 +6,7 @@ const patterns = [ /input is too long for requested model/i, /exceeds the context window/i, /input token count.*exceeds the maximum/i, + /tokens in request more than max tokens allowed/i, /maximum prompt length is \d+/i, /reduce the length of the messages/i, /maximum context length is \d+ tokens/i, diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index d3b41f5817..a258aae944 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -36,6 +36,8 @@ export interface RouteBody { export interface Route { readonly id: string readonly provider?: ProviderID + /** ProviderMetadata namespace emitted and consumed by this route. */ + readonly providerMetadataKey?: string readonly protocol: ProtocolID readonly endpoint: Endpoint readonly auth: AuthDef @@ -184,6 +186,8 @@ export interface MakeInput { readonly id: string /** Provider identity for route-owned model construction. */ readonly provider?: string | ProviderID + /** ProviderMetadata namespace emitted and consumed by this route. */ + readonly providerMetadataKey?: string /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Where the request is sent. */ @@ -203,6 +207,8 @@ export interface MakeTransportInput { readonly id: string /** Provider identity for route-owned model construction. */ readonly provider?: string | ProviderID + /** ProviderMetadata namespace emitted and consumed by this route. */ + readonly providerMetadataKey?: string /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Where the request is sent. */ @@ -248,6 +254,7 @@ function makeFromTransport( const route: Route = { id: routeInput.id, provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider), + providerMetadataKey: routeInput.providerMetadataKey, protocol: protocol.id, endpoint: routeInput.endpoint, auth: routeInput.auth ?? Auth.none, @@ -329,6 +336,7 @@ export function make( return makeFromTransport({ id: input.id, provider: input.provider, + providerMetadataKey: input.providerMetadataKey, protocol, endpoint: input.endpoint, auth: input.auth, diff --git a/packages/llm/test/provider-error.test.ts b/packages/llm/test/provider-error.test.ts new file mode 100644 index 0000000000..3622c89454 --- /dev/null +++ b/packages/llm/test/provider-error.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from "bun:test" +import { isContextOverflow } from "../src" + +describe("provider error classification", () => { + test("classifies Z.AI GLM token limit messages as context overflow", () => { + expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true) + }) +}) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 65e02f0763..95dc7f0b18 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -40,7 +40,6 @@ export class Service extends ConfigService.Service()("@opencode/Runtime enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"), enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"), experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"), - experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"), experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"), experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 18d64188a4..2e2292f1af 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -3,7 +3,6 @@ import { Agent } from "@/agent/agent" import { Job } from "@/job" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" -import { RuntimeFlags } from "@/effect/runtime-flags" import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" @@ -34,10 +33,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const worktreeSvc = yield* Worktree.Service const sessions = yield* Session.Service const jobs = yield* Job.Service - const flags = yield* RuntimeFlags.Service const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () { - return { backgroundSubagents: flags.experimentalBackgroundSubagents } + return { backgroundSubagents: true } }) const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { @@ -159,7 +157,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const sessionBackground = Effect.fn("ExperimentalHttpApi.sessionBackground")(function* (ctx: { params: { sessionID: SessionID } }) { - if (!flags.experimentalBackgroundSubagents) return false return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0 }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 641ae2ddf8..2ce86abe43 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -64,7 +64,7 @@ export function fromRow(row: SessionRow): Info { messageID: MessageID.make(row.revert.messageID), partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined, snapshot: row.revert.snapshot, - diff: row.revert.diff, + diff: "diff" in row.revert ? row.revert.diff : undefined, } : undefined return { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 60112e10d9..a42c367ec2 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -51,7 +51,7 @@ type State = { type Data = | { type: "session" - data: SDK.Session + data: SDK.SessionV1Info } | { type: "message" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 4da9bc3ca8..f393caa1a7 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" -import { Info } from "@opencode-ai/schema/file-diff" +import { LegacyInfo } from "@opencode-ai/schema/file-diff" export const Patch = Schema.Struct({ hash: Schema.String, @@ -17,7 +17,7 @@ export const Patch = Schema.Struct({ }) export type Patch = typeof Patch.Type -export const FileDiff = Info +export const FileDiff = LegacyInfo export type FileDiff = typeof FileDiff.Type const prune = "7.days" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f61cea1048..4039fd36fa 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,6 +1,5 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" -import { ToolJsonSchema } from "./json-schema" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Job } from "@/job" import { Session } from "@/session/session" @@ -12,7 +11,6 @@ import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" -import { RuntimeFlags } from "@/effect/runtime-flags" import { Database } from "@opencode-ai/core/database/database" export interface TaskPromptOps { @@ -51,8 +49,6 @@ const BaseParameterFields = { command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), } -const BaseParameters = Schema.Struct(BaseParameterFields) - export const Parameters = Schema.Struct({ ...BaseParameterFields, background: Schema.optional(Schema.Boolean).annotate({ @@ -86,7 +82,6 @@ export const TaskTool = Tool.define( const config = yield* Config.Service const sessions = yield* Session.Service const scope = yield* Scope.Scope - const flags = yield* RuntimeFlags.Service const database = yield* Database.Service const run = Effect.fn("TaskTool.execute")(function* ( @@ -95,11 +90,6 @@ export const TaskTool = Tool.define( ) { const cfg = yield* config.get() const runInBackground = params.background === true - if (runInBackground && !flags.experimentalBackgroundSubagents) { - return yield* Effect.fail( - new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"), - ) - } if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ @@ -333,11 +323,8 @@ export const TaskTool = Tool.define( }) return { - description: flags.experimentalBackgroundSubagents - ? [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n") - : DESCRIPTION, + description: [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n"), parameters: Parameters, - jsonSchema: flags.experimentalBackgroundSubagents ? undefined : ToolJsonSchema.fromSchema(BaseParameters), execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 4c3139ce9c..f4f29cc453 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -161,7 +161,6 @@ async function renderFooter( currentModel?: RunInput["model"] currentVariant?: string subagents?: FooterSubagentState - backgroundSubagents?: boolean width?: number height?: number state?: Partial @@ -199,7 +198,6 @@ async function renderFooter( subagent={subagents} theme={input.theme ?? (() => RUN_THEME_FALLBACK)} tuiConfig={config} - backgroundSubagents={input.backgroundSubagents ?? true} agent="opencode" onSubmit={input.onSubmit ?? (() => true)} onPermissionReply={() => {}} @@ -998,7 +996,6 @@ test("direct footer shows editable prompts and additional queued work while runn ]} theme={() => RUN_THEME_FALLBACK} tuiConfig={tuiConfig} - backgroundSubagents={true} agent="opencode" onSubmit={() => true} onPermissionReply={() => {}} @@ -1071,7 +1068,7 @@ test("direct footer shows editable prompts and additional queued work while runn } }) -test("direct footer separates a lone context hint from model and command hint", async () => { +test("direct footer always offers backgrounding for a foreground subagent", async () => { const app = await renderFooter({ providers: [provider()], currentModel: { providerID: "opencode", modelID: "gpt-5" }, @@ -1082,7 +1079,6 @@ test("direct footer separates a lone context hint from model and command hint", permissions: [], questions: [], }, - backgroundSubagents: false, width: 160, }) @@ -1091,8 +1087,8 @@ test("direct footer separates a lone context hint from model and command hint", const frame = app.captureCharFrame() expect(frame).toContain("GPT-5") - expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd") - expect(frame).not.toContain("ctrl+b background") + expect(frame).toContain("xhigh · ctrl+b background · ctrl+x down subagents · ctrl+p cmd") + expect(frame).toContain("ctrl+b background") expect(frame).not.toContain("queued") } finally { app.cleanup() @@ -1110,7 +1106,6 @@ test("direct footer hides the subagent hint when only completed subagents remain permissions: [], questions: [], }, - backgroundSubagents: false, width: 160, }) diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index fcba5842fe..5eafd1266c 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -134,7 +134,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async () => { @@ -233,7 +232,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async () => { @@ -406,7 +404,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: true, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { @@ -496,7 +493,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { @@ -557,7 +553,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async () => { @@ -603,7 +598,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { @@ -716,7 +710,6 @@ describe("run interactive runtime", () => { variant: "low", files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index be248d3a3d..5d0894c0d7 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -53,7 +53,13 @@ function connected(id = "evt_connected") { return { id, type: "server.connected", data: {} } satisfies RunV2Event } -function durable(sessionID: string, seq = 0, version = 1) { +function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 } +function durable( + sessionID: string, + seq: number, + version: Version, +): { aggregateID: string; seq: number; version: Version } +function durable(sessionID: string, seq = 0, version: 1 | 2 = 1) { return { aggregateID: sessionID, seq, version } } @@ -1311,17 +1317,10 @@ describe("V2 mini transport", () => { { id: "msg_shell", type: "shell" as const, - shell: { - id: "sh_1", - status: "exited", - command: "ls", - cwd: "/tmp", - shell: "/bin/sh", - file: "/tmp/opencode-shell", - exit: 0, - metadata: {}, - time: { started: 0, completed: 1 }, - }, + shellID: "sh_1", + status: "exited", + command: "ls", + exit: 0, output: { output: "file.txt", cursor: 8, size: 8, truncated: false }, time: { created: 1, completed: 2 }, }, @@ -1378,17 +1377,10 @@ describe("V2 mini transport", () => { { id: "msg_failed_shell", type: "shell" as const, - shell: { - id: "sh_failed", - status: "exited", - command: "false", - cwd: "/tmp", - shell: "/bin/sh", - file: "/tmp/failed", - exit: 7, - metadata: {}, - time: { started: 0, completed: 1 }, - }, + shellID: "sh_failed", + status: "exited", + command: "false", + exit: 7, output: { output: "failure output", cursor: 14, size: 14, truncated: false }, time: { created: 1, completed: 2 }, }, @@ -1551,6 +1543,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: input.skill ?? "tigerstyle", name: input.skill ?? "tigerstyle", text: "skill instructions", }, @@ -1633,6 +1626,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: "other", name: "other", text: "other instructions", }, @@ -1655,6 +1649,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: "tigerstyle", name: "tigerstyle", text: "skill instructions", }, @@ -1722,6 +1717,7 @@ describe("V2 mini transport", () => { { id: "msg_skill", type: "skill" as const, + skill: "tigerstyle", name: "tigerstyle", text: "skill instructions", time: { created: 2 }, @@ -1745,6 +1741,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: "tigerstyle", name: "tigerstyle", text: "skill instructions", }, diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index ca5ca1d7b0..51633f57b7 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -51,7 +51,6 @@ describe("RuntimeFlags", () => { expect(flags.enableExperimentalModels).toBe(true) expect(flags.enableQuestionTool).toBe(true) expect(flags.experimentalReferences).toBe(true) - expect(flags.experimentalBackgroundSubagents).toBe(true) expect(flags.experimentalLspTy).toBe(false) expect(flags.experimentalLspTool).toBe(true) expect(flags.experimentalOxfmt).toBe(true) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index fa3b1e670e..195699f9c9 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -579,8 +579,10 @@ const scenarios: Scenario[] = [ .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) .json(200, array), http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => { - check(typeof body === "object" && body !== null, "capabilities should be an object") - check("backgroundSubagents" in body, "capabilities should report background subagents") + check( + typeof body === "object" && body !== null && "backgroundSubagents" in body && body.backgroundSubagents === true, + "capabilities should report background subagents as available", + ) }), http.protected .post("/experimental/session/{sessionID}/background", "experimental.session.background") diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 9d7643cb33..a5677d5274 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -28,6 +28,7 @@ import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from ". import { Database } from "@opencode-ai/core/database/database" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionMessage } from "@opencode-ai/core/session/message" +import { Agent } from "@opencode-ai/schema/agent" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" @@ -76,7 +77,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) { id: MessageID.ascending(), role: "user", sessionID, - agent: "build", + agent: Agent.ID.make("build"), model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, time: { created: Date.now() }, }) @@ -123,7 +124,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = const message = SessionMessage.Assistant.make({ id: SessionMessage.ID.create(), type: "assistant", - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -380,7 +381,7 @@ describe("session HttpApi", () => { yield* insertLegacyAssistantMessage(parent.id) expect( - (yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { + (yield* requestJson<{ data: SessionMessage.Info[] }>(`/api/session/${parent.id}/message`, { headers, })).data, ).toMatchObject([{ type: "assistant" }]) @@ -474,7 +475,7 @@ describe("session HttpApi", () => { }) const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers }) - const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage) + const messageBody = yield* json<{ data: SessionMessage.Info[]; cursor: { next?: string } }>(messagePage) const messageCursor = messageBody.cursor.next expect(messageCursor).toBeTruthy() expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id]) @@ -488,7 +489,7 @@ describe("session HttpApi", () => { headers, }) expect( - (yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id), + (yield* json<{ data: SessionMessage.Info[] }>(nextMessagePage)).data.map((message) => message.id), ).toEqual([firstMessage.id]) const legacyMessageCursor = Buffer.from( @@ -498,7 +499,7 @@ describe("session HttpApi", () => { headers, }) expect( - (yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id), + (yield* json<{ data: SessionMessage.Info[] }>(legacyMessagePage)).data.map((message) => message.id), ).toEqual([firstMessage.id]) const messageCursorWithOrder = yield* request( @@ -625,7 +626,7 @@ describe("session HttpApi", () => { }) expect(wake.status).toBe(200) const message = yield* pollWithTimeout( - requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe( + requestJson<{ data: SessionMessage.Info[] }>(`/api/session/${session.id}/message`, { headers }).pipe( Effect.map(({ data }) => data.find((message) => message.id === wakeID)), ), "V2 prompt was not promoted after wake", @@ -637,28 +638,25 @@ describe("session HttpApi", () => { ) it.instance( - "returns v2 public unavailable errors for unfinished session mutations", + "supports current session compact and wait endpoints", () => Effect.gen(function* () { const test = yield* TestInstance const headers = { "x-opencode-directory": test.directory } const session = yield* createSession({ title: "v2 unavailable" }) - const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers }) - expect(compact.status).toBe(503) - expect(yield* responseJson(compact)).toEqual({ - _tag: "ServiceUnavailableError", - message: "Session compact is not available yet", - service: "session.compact", + const compact = yield* request(`/api/session/${session.id}/compact`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(compact.status).toBe(200) + expect(yield* responseJson(compact)).toMatchObject({ + data: { type: "compaction", sessionID: session.id }, }) const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers }) - expect(wait.status).toBe(503) - expect(yield* responseJson(wait)).toEqual({ - _tag: "ServiceUnavailableError", - message: "Session wait is not available yet", - service: "session.wait", - }) + expect(wait.status).toBe(204) }), { git: true, config: { formatter: false, lsp: false } }, ) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index cf27c74cbe..4aa750c39d 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,12 +1,13 @@ import { afterEach, describe, expect, mock } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Layer } from "effect" +import { Effect, Fiber, Layer } from "effect" import { Session as SessionNs } from "@/session/session" +import { Job } from "@/job" import { disposeAllInstances, TestInstance } from "../fixture/fixture" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(Job.node), httpApiLayer)) afterEach(async () => { mock.restore() @@ -14,6 +15,19 @@ afterEach(async () => { }) describe("session action routes", () => { + it.instance( + "reports background subagents as available", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const res = yield* requestInDirectory("/experimental/capabilities", test.directory) + + expect(res.status).toBe(200) + expect(yield* res.json).toEqual({ backgroundSubagents: true }) + }), + { git: true }, + ) + it.instance( "session routes expose metadata on create, update, get, and fork", () => @@ -107,4 +121,33 @@ describe("session action routes", () => { }), { git: true }, ) + + it.instance( + "experimental background route backgrounds a synchronous subagent", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) => + SessionNs.use.remove(created.id).pipe(Effect.ignore), + ) + const jobs = yield* Job.Service + const job = yield* jobs.start({ type: "task", run: Effect.never }) + const waiting = yield* jobs.block({ id: job.id, sessionID: session.id }).pipe(Effect.forkChild) + + const backgrounded = yield* pollWithTimeout( + requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, { + method: "POST", + }).pipe( + Effect.flatMap((res) => res.json), + Effect.map((value) => (value === true ? true : undefined)), + ), + "background route never released the synchronous subagent", + ) + + expect(backgrounded).toBe(true) + expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } }) + yield* jobs.cancel(job.id) + }), + { git: true }, + ) }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1de84c9dd9..9bb688aedd 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1448,6 +1448,7 @@ describe("session.message-v2.fromError", () => { "prompt is too long: 213462 tokens > 200000 maximum", "Your input exceeds the context window of this model", "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)", + "tokens in request more than max tokens allowed", "Please reduce the length of the messages or completion", "400 status code (no body)", "413 status code (no body)", diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c8c5fac595..456d665950 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -150,7 +150,7 @@ describe("tool.registry", () => { }), ) - it.instance("hides task background parameter unless experimental background subagents are enabled", () => + it.instance("exposes the task background parameter by default", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service const agent = yield* Agent.Service @@ -162,8 +162,9 @@ describe("tool.registry", () => { agent: build, })).find((tool) => tool.id === "task") - expect(task?.jsonSchema).toBeDefined() - expect((task?.jsonSchema?.properties as Record | undefined)?.background).toBeUndefined() + if (!task) throw new Error("task tool not found") + const jsonSchema = ToolJsonSchema.fromTool(task) + expect((jsonSchema.properties as Record | undefined)?.background).toBeDefined() }), ) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index f62c8b4d31..32171789c8 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -34,7 +34,7 @@ const ref = { modelID: ModelV2.ID.make("test-model"), } -const layer = (flags: Partial = {}) => +const layer = () => LayerNode.compile( LayerNode.group([ Agent.node, @@ -52,11 +52,10 @@ const layer = (flags: Partial = {}) => RuntimeFlags.node, Ripgrep.node, ]), - [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], + [[RuntimeFlags.node, RuntimeFlags.layer()]], ) const it = testEffect(layer()) -const background = testEffect(layer({ experimentalBackgroundSubagents: true })) function defer() { let resolve!: (value: T | PromiseLike) => void @@ -456,37 +455,6 @@ describe("tool.task", () => { }, ) - it.instance("rejects background execution when the experiment is disabled", () => - Effect.gen(function* () { - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - - const exit = yield* def - .execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - background: true, - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps: stubOps() }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - it.instance("backgrounds a running foreground task without restarting it", () => Effect.gen(function* () { const jobs = yield* Job.Service @@ -558,7 +526,7 @@ describe("tool.task", () => { }), ) - background.instance("execute launches background tasks without waiting for completion", () => + it.instance("execute launches background tasks without waiting for completion", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -596,7 +564,7 @@ describe("tool.task", () => { }), ) - background.instance("running task_id reports the existing background task", () => + it.instance("running task_id reports the existing background task", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -661,7 +629,7 @@ describe("tool.task", () => { }), ) - background.instance("background tasks complete through the job service", () => + it.instance("background tasks complete through the job service", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -694,7 +662,7 @@ describe("tool.task", () => { }), ) - background.instance("background task completion does not wait for the parent async prompt", () => + it.instance("background task completion does not wait for the parent async prompt", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -732,7 +700,7 @@ describe("tool.task", () => { }), ) - background.instance("removing the parent session cancels running background tasks", () => + it.instance("removing the parent session cancels running background tasks", () => Effect.gen(function* () { const jobs = yield* Job.Service const sessions = yield* Session.Service @@ -771,7 +739,7 @@ describe("tool.task", () => { }), ) - background.instance("removing the child task session cancels its running background task", () => + it.instance("removing the child task session cancels its running background task", () => Effect.gen(function* () { const jobs = yield* Job.Service const sessions = yield* Session.Service @@ -810,7 +778,7 @@ describe("tool.task", () => { }), ) - background.instance("cancelling the parent run cancels running background tasks", () => + it.instance("cancelling the parent run cancels running background tasks", () => Effect.gen(function* () { const jobs = yield* Job.Service const runState = yield* SessionRunState.Service diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 8f8286d562..d29a7398f4 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test" -import { Effect } from "effect" -import * as DateTime from "effect/DateTime" +import { DateTime, Effect } from "effect" import { SessionID } from "../../src/session/schema" import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" @@ -8,6 +7,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionMessage } from "@opencode-ai/core/session/message" +import { Agent } from "@opencode-ai/schema/agent" +import { Money } from "@opencode-ai/schema/money" +import { Snapshot } from "@opencode-ai/schema/snapshot" function durable(sessionID: SessionID, seq = 0, version = 1) { return { aggregateID: sessionID, seq: EventV2.Seq.make(seq), version: EventV2.Version.make(version) } @@ -27,13 +29,13 @@ test.skip("step snapshots carry over to assistant messages", () => { data: { sessionID, assistantMessageID, - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), variant: ModelV2.VariantID.make("default"), }, - snapshot: "before", + snapshot: Snapshot.ID.make("before"), }, } satisfies SessionEvent.Event), ) @@ -50,21 +52,24 @@ test.skip("step snapshots carry over to assistant messages", () => { sessionID, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 }, }, - snapshot: "after", + snapshot: Snapshot.ID.make("after"), }, } satisfies SessionEvent.Event), ) expect(state.messages[0]?.type).toBe("assistant") if (state.messages[0]?.type !== "assistant") return - expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" }) + expect(state.messages[0].snapshot).toEqual({ + start: Snapshot.ID.make("before"), + end: Snapshot.ID.make("after"), + }) expect(state.messages[0].finish).toBe("stop") }) @@ -82,7 +87,7 @@ test.skip("text ended populates assistant text content", () => { data: { sessionID, assistantMessageID, - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -141,7 +146,7 @@ test.skip("tool completion stores completed timestamp", () => { data: { sessionID, assistantMessageID, - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -213,7 +218,7 @@ test.skip("tool completion stores completed timestamp", () => { }) }) -test("compaction events reduce to compaction message only when completed", () => { +test("compaction events reduce to a compaction message through completion", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") const id = EventV2.ID.create() @@ -224,15 +229,25 @@ test("compaction events reduce to compaction message only when completed", () => id, created: DateTime.makeUnsafe(0), type: "session.compaction.started", - durable: durable(sessionID), + durable: durable(sessionID, 0, 2), data: { sessionID, reason: "auto", + recent: "recent context", }, } satisfies SessionEvent.Event), ) - expect(state.messages).toEqual([]) + expect(state.messages).toMatchObject([ + { + id: SessionMessage.ID.fromEvent(id), + type: "compaction", + reason: "auto", + recent: "recent context", + status: "running", + summary: "", + }, + ]) Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { @@ -263,7 +278,7 @@ test("compaction events reduce to compaction message only when completed", () => id: endedID, created: DateTime.makeUnsafe(0), type: "session.compaction.ended", - durable: durable(sessionID, 1), + durable: durable(sessionID, 3), data: { sessionID, reason: "auto", @@ -275,9 +290,10 @@ test("compaction events reduce to compaction message only when completed", () => expect(state.messages).toHaveLength(1) expect(state.messages[0]).toMatchObject({ - id: SessionMessage.ID.fromEvent(endedID), + id: SessionMessage.ID.fromEvent(id), type: "compaction", reason: "auto", + status: "completed", summary: "final summary", recent: "recent context", time: { created: DateTime.makeUnsafe(0) }, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d93db3e5b0..e590557044 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -14,10 +14,9 @@ "./tool": "./src/tool.ts", "./tui": "./src/tui.ts", "./v2/effect": "./src/v2/effect/index.ts", - "./v2/effect/integration": "./src/v2/effect/integration.ts", - "./v2/effect/plugin": "./src/v2/effect/plugin.ts", - "./v2/effect/tool": "./src/v2/effect/tool.ts", - "./v2/promise": "./src/v2/promise/index.ts" + "./v2/effect/*": "./src/v2/effect/*.ts", + "./v2": "./src/v2/promise/index.ts", + "./v2/*": "./src/v2/promise/*.ts" }, "files": [ "dist" diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index a9d8d7dcd3..8e2fee5908 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -475,6 +475,7 @@ export type TuiHostSlotMap = { } sidebar_footer: { session_id: string + directory: string } } diff --git a/packages/plugin/src/v2/effect/PLAN.md b/packages/plugin/src/v2/effect/PLAN.md index 71fa07bd7b..284d1ffe09 100644 --- a/packages/plugin/src/v2/effect/PLAN.md +++ b/packages/plugin/src/v2/effect/PLAN.md @@ -176,7 +176,7 @@ Both use the same low-level scoped registration registry, but consumers invoke t ```ts ctx.tool.transform(...) // replayed to build effective tool registry state -ctx.tool.hook(...) // invoked at a live tool operation boundary +ctx.tool.hook(...) // invoked at a live tool operation boundary ``` The shared low-level machinery owns registration order, scope cleanup, disposal, and snapshots. Each domain owns when its transforms or runtime hooks execute. diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index 3da1d7b566..f2526ec860 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -5,15 +5,13 @@ The Effect plugin API grants plugins two in-process capabilities: - `hook` installs behavior at an OpenCode extension point. - `reload` reruns every transform hook for a stateful domain. -The public server client will be exposed separately. It is intentionally not part of `PluginContext` yet. - ## Defining A Plugin ```ts -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export const Plugin = define({ +export default Plugin.define({ id: "example", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((catalog) => { @@ -25,7 +23,7 @@ export const Plugin = define({ }) ``` -Plugin setup registers hooks imperatively. It does not return a hook object. +Plugin setup registers hooks imperatively through each domain's `hook` method. Configuration supplied for the plugin is available as `ctx.options`. @@ -64,7 +62,8 @@ Runtime hooks intercept live operations rather than rebuilding domain state: ```ts yield * - ctx.aisdk.sdk( + ctx.aisdk.hook( + "sdk", Effect.fn(function* (event) { if (event.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) @@ -73,7 +72,7 @@ yield * ) yield * - ctx.aisdk.language((event) => { + ctx.aisdk.hook("language", (event) => { if (event.model.providerID !== "xai") return event.language = event.sdk.responses(event.model.api.id) }) @@ -81,6 +80,16 @@ yield * Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. +Session request context is mutable immediately before provider dispatch: + +```ts +yield * + ctx.session.hook("request", (event) => { + event.tools.read.description = "Read a file using narrow line ranges." + delete event.tools.write + }) +``` + ## Reloading A Domain When data captured by a transform changes, reload the affected domain: diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index aadacf99d5..d5125cca2a 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -1,17 +1,17 @@ import type { AgentApi } from "@opencode-ai/client/effect/api" -import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" +import type { AgentInfo } from "@opencode-ai/sdk/v2/types" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface AgentDraft { - list(): readonly AgentV2Info[] - get(id: string): AgentV2Info | undefined + list(): readonly AgentInfo[] + get(id: string): AgentInfo | undefined default(id: string | undefined): void - update(id: string, update: (agent: AgentV2Info) => void): void + update(id: string, update: (agent: AgentInfo) => void): void remove(id: string): void } -export interface AgentHooks extends AgentApi { - readonly transform: TransformHook +export interface AgentDomain extends AgentApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/aisdk.ts b/packages/plugin/src/v2/effect/aisdk.ts index 2934edc27d..9539e7b987 100644 --- a/packages/plugin/src/v2/effect/aisdk.ts +++ b/packages/plugin/src/v2/effect/aisdk.ts @@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { Model } from "@opencode-ai/schema/model" import type { Hooks } from "./registration.js" -export type AISDKHooks = Hooks<{ +export interface AISDKHooks { sdk: { readonly model: Model.Info readonly package: string @@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{ readonly options: Record language?: LanguageModelV3 } -}> +} + +export interface AISDKDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 4cc5e55227..b96ceec4a8 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,11 +1,11 @@ -import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" +import type { ModelInfo, ProviderV2Info } from "@opencode-ai/sdk/v2/types" import type { CatalogApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface CatalogProviderRecord { readonly provider: ProviderV2Info - readonly models: ReadonlyMap + readonly models: ReadonlyMap } export interface CatalogDraft { @@ -16,8 +16,8 @@ export interface CatalogDraft { remove(providerID: string): void } readonly model: { - get(providerID: string, modelID: string): ModelV2Info | undefined - update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void + get(providerID: string, modelID: string): ModelInfo | undefined + update(providerID: string, modelID: string, update: (model: ModelInfo) => void): void remove(providerID: string, modelID: string): void readonly default: { get(): { providerID: string; modelID: string } | undefined @@ -26,7 +26,7 @@ export interface CatalogDraft { } } -export interface CatalogHooks extends CatalogApi { - readonly transform: TransformHook +export interface CatalogDomain extends CatalogApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts index 2faaa91fa9..6e4764a485 100644 --- a/packages/plugin/src/v2/effect/command.ts +++ b/packages/plugin/src/v2/effect/command.ts @@ -1,16 +1,16 @@ -import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" +import type { CommandInfo } from "@opencode-ai/sdk/v2/types" import type { CommandApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface CommandDraft { - list(): readonly CommandV2Info[] - get(name: string): CommandV2Info | undefined - update(name: string, update: (command: CommandV2Info) => void): void + list(): readonly CommandInfo[] + get(name: string): CommandInfo | undefined + update(name: string, update: (command: CommandInfo) => void): void remove(name: string): void } -export interface CommandHooks extends CommandApi { - readonly transform: TransformHook +export interface CommandDomain extends CommandApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts deleted file mode 100644 index 219dc16581..0000000000 --- a/packages/plugin/src/v2/effect/context.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PluginOptions } from "../options.js" -import type { AgentHooks } from "./agent.js" -import type { AISDKHooks } from "./aisdk.js" -import type { CatalogHooks } from "./catalog.js" -import type { CommandHooks } from "./command.js" -import type { EventHooks } from "./event.js" -import type { IntegrationHooks } from "./integration.js" -import type { PluginDomain } from "./plugin.js" -import type { ReferenceHooks } from "./reference.js" -import type { SkillHooks } from "./skill.js" -import type { ToolDomain } from "./tool.js" -import type { SessionHooks } from "./runtime.js" - -export interface PluginContext { - readonly options: PluginOptions - readonly agent: AgentHooks - readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks - readonly command: CommandHooks - readonly event: EventHooks - readonly integration: IntegrationHooks - readonly plugin: PluginDomain - readonly reference: ReferenceHooks - readonly skill: SkillHooks - readonly tool: ToolDomain - readonly session: SessionHooks -} diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts index 49ad375d67..283d4109f0 100644 --- a/packages/plugin/src/v2/effect/event.ts +++ b/packages/plugin/src/v2/effect/event.ts @@ -1,3 +1,3 @@ import type { EventApi } from "@opencode-ai/client/effect/api" -export interface EventHooks extends Pick, "subscribe"> {} +export interface EventDomain extends Pick, "subscribe"> {} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 75a87dae1c..e00e302b7a 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,25 +1,4 @@ -export type { PluginContext } from "./context.js" -export { define } from "./plugin.js" -export type { Plugin, PluginDomain } from "./plugin.js" -export type { AgentDraft, AgentHooks } from "./agent.js" -export type { AISDKHooks } from "./aisdk.js" -export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" -export type { CommandDraft, CommandHooks } from "./command.js" -export type { EventHooks } from "./event.js" -export type { - IntegrationDraft, - IntegrationDefinition, - IntegrationHooks, - IntegrationMethodDefinition, - IntegrationMethodRegistration, - IntegrationOAuthMethodDefinition, - IntegrationSearchDefinition, -} from "./integration.js" -export type { ReferenceDraft, ReferenceHooks } from "./reference.js" -export type { SkillDraft, SkillHooks } from "./skill.js" -export * as Tool from "./tool.js" -export type { ToolDomain, ToolDraft, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js" -export type { SessionHooks } from "./runtime.js" +export * as Plugin from "./plugin.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 30c9d98fb1..f1658a7fbc 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -12,7 +12,7 @@ import type { import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { Search } from "@opencode-ai/schema/search" import type { Effect, Scope } from "effect" -import type { Registration, TransformHook } from "./registration.js" +import type { Registration, Transform } from "./registration.js" export type IntegrationOAuthAuthorization = { readonly url: string @@ -80,9 +80,9 @@ export interface IntegrationDraft { } } -export interface IntegrationHooks extends IntegrationApi { +export interface IntegrationDomain extends IntegrationApi { readonly register: (definition: IntegrationDefinition) => Effect.Effect - readonly transform: TransformHook + readonly transform: Transform readonly reload: () => Effect.Effect readonly connection: { readonly active: (integrationID: string) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 66f57caf60..3b0b6eb918 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,14 +1,37 @@ import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" -import type { PluginContext } from "./context.js" +import type { PluginOptions } from "../options.js" +import type { AgentDomain } from "./agent.js" +import type { AISDKDomain } from "./aisdk.js" +import type { CatalogDomain } from "./catalog.js" +import type { CommandDomain } from "./command.js" +import type { EventDomain } from "./event.js" +import type { IntegrationDomain } from "./integration.js" +import type { ReferenceDomain } from "./reference.js" +import type { SessionDomain } from "./session.js" +import type { SkillDomain } from "./skill.js" +import type { ToolDomain } from "./tool.js" + +export interface Context { + readonly options: PluginOptions + readonly agent: AgentDomain + readonly aisdk: AISDKDomain + readonly catalog: CatalogDomain + readonly command: CommandDomain + readonly event: EventDomain + readonly integration: IntegrationDomain + readonly plugin: PluginApi + readonly reference: ReferenceDomain + readonly session: SessionDomain + readonly skill: SkillDomain + readonly tool: ToolDomain +} export interface Plugin { readonly id: string - readonly effect: (context: PluginContext) => Effect.Effect + readonly effect: (context: Context) => Effect.Effect } export function define(plugin: Plugin) { return plugin } - -export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts index 1216d3c0b8..085ae0de5e 100644 --- a/packages/plugin/src/v2/effect/reference.ts +++ b/packages/plugin/src/v2/effect/reference.ts @@ -1,7 +1,7 @@ import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" import type { ReferenceApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface ReferenceDraft { add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void @@ -9,7 +9,7 @@ export interface ReferenceDraft { list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] } -export interface ReferenceHooks extends ReferenceApi { - readonly transform: TransformHook +export interface ReferenceDomain extends ReferenceApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts index 1ddb9e3489..916b034a73 100644 --- a/packages/plugin/src/v2/effect/registration.ts +++ b/packages/plugin/src/v2/effect/registration.ts @@ -4,10 +4,9 @@ export interface Registration { readonly dispose: Effect.Effect } -export type Hooks = { - readonly [Name in keyof Spec]: ( - callback: (input: Spec[Name]) => Effect.Effect | void, - ) => Effect.Effect -} +export type Hooks = ( + name: Name, + callback: (input: Spec[Name]) => Effect.Effect, +) => Effect.Effect -export type TransformHook = (callback: (input: Input) => void) => Effect.Effect +export type Transform = (callback: (input: Input) => void) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/runtime.ts b/packages/plugin/src/v2/effect/runtime.ts deleted file mode 100644 index 0358330210..0000000000 --- a/packages/plugin/src/v2/effect/runtime.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { SessionApi } from "@opencode-ai/client/effect/api" - -export interface SessionHooks - extends Pick, "create" | "get" | "prompt" | "command" | "interrupt"> {} diff --git a/packages/plugin/src/v2/effect/session.ts b/packages/plugin/src/v2/effect/session.ts new file mode 100644 index 0000000000..00ee8b9125 --- /dev/null +++ b/packages/plugin/src/v2/effect/session.ts @@ -0,0 +1,25 @@ +import type { SessionApi } from "@opencode-ai/client/effect/api" +import type { Message, SystemPart } from "@opencode-ai/llm" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" + +export interface SessionRequestBeforeEvent { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly request: SessionRequestBeforeEvent +} + +export interface SessionDomain + extends Pick, "create" | "get" | "prompt" | "command" | "interrupt"> { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts index b01b81bc3b..32daf0fb0a 100644 --- a/packages/plugin/src/v2/effect/skill.ts +++ b/packages/plugin/src/v2/effect/skill.ts @@ -1,14 +1,14 @@ -import type { SkillV2Source } from "@opencode-ai/sdk/v2/types" +import type { SkillSource } from "@opencode-ai/sdk/v2/types" import type { SkillApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface SkillDraft { - source(source: SkillV2Source): void - list(): readonly SkillV2Source[] + source(source: SkillSource): void + list(): readonly SkillSource[] } -export interface SkillHooks extends SkillApi { - readonly transform: TransformHook +export interface SkillDomain extends SkillApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index bb5931af16..db22b18b4f 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -5,7 +5,7 @@ import { Agent } from "@opencode-ai/schema/agent" import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Effect, JsonSchema, Schema, type Scope } from "effect" -import type { Hooks } from "./registration.js" +import type { Hooks, Transform } from "./registration.js" export interface Context { readonly sessionID: Session.ID @@ -253,7 +253,12 @@ export interface ToolDraft { add(name: string, tool: AnyTool, options?: RegisterOptions): void } -export interface ToolDomain { - readonly transform: (callback: (draft: ToolDraft) => void) => Effect.Effect - readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }> +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks } diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md index e91b93fbdf..3e96cefc27 100644 --- a/packages/plugin/src/v2/promise/README.md +++ b/packages/plugin/src/v2/promise/README.md @@ -1,6 +1,6 @@ # OpenCode V2 Promise Plugin API -The Promise plugin API is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities: +The Promise plugin API at `@opencode-ai/plugin/v2` is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities: - `hook` installs behavior at an OpenCode extension point. - `reload` reruns every transform hook for a stateful domain. @@ -10,9 +10,9 @@ The only difference from the Effect API is the async boundary: hook callbacks, h ## Defining A Plugin ```ts -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export const Plugin = define({ +export default Plugin.define({ id: "example", setup: async (ctx) => { await ctx.catalog.transform((catalog) => { @@ -24,7 +24,7 @@ export const Plugin = define({ }) ``` -Plugin setup registers hooks imperatively. It does not return a hook object. +Plugin setup registers hooks imperatively through each domain's `hook` method. Configuration supplied for the plugin is available as `ctx.options`. @@ -64,18 +64,43 @@ ctx.skill.transform Runtime hooks intercept live operations: ```ts -await ctx.aisdk.sdk(async (event) => { +await ctx.aisdk.hook("sdk", async (event) => { if (event.package !== "@ai-sdk/xai") return const mod = await import("@ai-sdk/xai") event.sdk = mod.createXai(event.options) }) -await ctx.aisdk.language((event) => { +await ctx.aisdk.hook("language", (event) => { if (event.model.providerID !== "xai") return event.language = event.sdk.responses(event.model.api.id) }) ``` +Session request context is mutable immediately before provider dispatch: + +```ts +await ctx.session.hook("request", (event) => { + event.tools.read.description = "Read a file using narrow line ranges." + delete event.tools.write +}) +``` + +Promise tools use the same schemas and registration model as Effect tools, with async executors: + +```ts +import { Schema } from "effect" +import { Tool } from "@opencode-ai/plugin/v2/tool" + +const echo = Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: async ({ text }) => ({ text }), +}) + +await ctx.tool.transform((tools) => tools.add("echo", echo)) +``` + ## Reloading A Domain When data captured by a transform changes, reload the affected domain: diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts index d9313838cb..bfffdebb4e 100644 --- a/packages/plugin/src/v2/promise/agent.ts +++ b/packages/plugin/src/v2/promise/agent.ts @@ -1,10 +1,10 @@ import type { AgentApi } from "@opencode-ai/client/promise/api" import type { AgentDraft } from "../effect/agent.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { AgentDraft } -export interface AgentHooks extends AgentApi { - readonly transform: TransformHook +export interface AgentDomain extends AgentApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/aisdk.ts b/packages/plugin/src/v2/promise/aisdk.ts index 2934edc27d..9539e7b987 100644 --- a/packages/plugin/src/v2/promise/aisdk.ts +++ b/packages/plugin/src/v2/promise/aisdk.ts @@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { Model } from "@opencode-ai/schema/model" import type { Hooks } from "./registration.js" -export type AISDKHooks = Hooks<{ +export interface AISDKHooks { sdk: { readonly model: Model.Info readonly package: string @@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{ readonly options: Record language?: LanguageModelV3 } -}> +} + +export interface AISDKDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts index f6b0f649f8..e0a51f5e52 100644 --- a/packages/plugin/src/v2/promise/catalog.ts +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -1,10 +1,10 @@ import type { CatalogApi } from "@opencode-ai/client/promise/api" import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { CatalogDraft, CatalogProviderRecord } -export interface CatalogHooks extends CatalogApi { - readonly transform: TransformHook +export interface CatalogDomain extends CatalogApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/command.ts b/packages/plugin/src/v2/promise/command.ts index d042259c47..2c675e2856 100644 --- a/packages/plugin/src/v2/promise/command.ts +++ b/packages/plugin/src/v2/promise/command.ts @@ -1,10 +1,10 @@ import type { CommandApi } from "@opencode-ai/client/promise/api" import type { CommandDraft } from "../effect/command.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { CommandDraft } -export interface CommandHooks extends CommandApi { - readonly transform: TransformHook +export interface CommandDomain extends CommandApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts deleted file mode 100644 index 5e67e44961..0000000000 --- a/packages/plugin/src/v2/promise/context.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { PluginOptions } from "../options.js" -import type { AgentHooks } from "./agent.js" -import type { AISDKHooks } from "./aisdk.js" -import type { CatalogHooks } from "./catalog.js" -import type { CommandHooks } from "./command.js" -import type { EventHooks } from "./event.js" -import type { IntegrationHooks } from "./integration.js" -import type { PluginDomain } from "./plugin.js" -import type { ReferenceHooks } from "./reference.js" -import type { SessionHooks } from "./runtime.js" -import type { SkillHooks } from "./skill.js" - -export interface PluginContext { - readonly options: PluginOptions - readonly agent: AgentHooks - readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks - readonly command: CommandHooks - readonly event: EventHooks - readonly integration: IntegrationHooks - readonly plugin: PluginDomain - readonly reference: ReferenceHooks - readonly session: SessionHooks - readonly skill: SkillHooks -} diff --git a/packages/plugin/src/v2/promise/event.ts b/packages/plugin/src/v2/promise/event.ts index 5330f70c7c..344f5d6f30 100644 --- a/packages/plugin/src/v2/promise/event.ts +++ b/packages/plugin/src/v2/promise/event.ts @@ -1,3 +1,3 @@ import type { EventApi } from "@opencode-ai/client/promise/api" -export interface EventHooks extends Pick {} +export interface EventDomain extends Pick {} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 7575d1a266..ae8e6d18a1 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -1,25 +1,5 @@ -export type { PluginContext } from "./context.js" export type { PluginOptions } from "../options.js" -export { define } from "./plugin.js" -export type { Plugin, PluginDomain } from "./plugin.js" -export type { AgentDraft, AgentHooks } from "./agent.js" -export type { AISDKHooks } from "./aisdk.js" -export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" -export type { CommandDraft, CommandHooks } from "./command.js" -export type { EventHooks } from "./event.js" -export type { - IntegrationDraft, - IntegrationDefinition, - IntegrationHooks, - IntegrationMethodDefinition, - IntegrationMethodRegistration, - IntegrationOAuthAuthorization, - IntegrationOAuthMethodDefinition, - IntegrationSearchDefinition, -} from "./integration.js" -export type { ReferenceDraft, ReferenceHooks } from "./reference.js" -export type { SessionHooks } from "./runtime.js" -export type { SkillDraft, SkillHooks } from "./skill.js" +export * as Plugin from "./plugin.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index 4016442cc7..fc126ec9b0 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,5 +1,5 @@ import type { IntegrationApi } from "@opencode-ai/client/promise/api" -import type { IntegrationMethodRegistration } from "../effect/integration.js" +import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" import type { CredentialOAuth, CredentialValue, @@ -9,9 +9,9 @@ import type { IntegrationOAuthMethod, } from "@opencode-ai/sdk/v2/types" import type { Search } from "@opencode-ai/schema/search" -import type { Registration, TransformHook } from "./registration.js" +import type { Registration, Transform } from "./registration.js" -export type { IntegrationMethodRegistration } +export type { IntegrationDraft, IntegrationMethodRegistration } export type IntegrationOAuthAuthorization = { readonly url: string @@ -50,11 +50,9 @@ export interface IntegrationDefinition { readonly search?: IntegrationSearchDefinition } -export type IntegrationDraft = import("../effect/integration.js").IntegrationDraft - -export interface IntegrationHooks extends IntegrationApi { +export interface IntegrationDomain extends IntegrationApi { readonly register: (definition: IntegrationDefinition) => Promise - readonly transform: TransformHook + readonly transform: Transform readonly reload: () => Promise readonly connection: { readonly active: (integrationID: string) => Promise diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index eb91b9df53..37bce3cc03 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,13 +1,36 @@ import type { PluginApi } from "@opencode-ai/client/promise/api" -import type { PluginContext } from "./context.js" +import type { PluginOptions } from "../options.js" +import type { AgentDomain } from "./agent.js" +import type { AISDKDomain } from "./aisdk.js" +import type { CatalogDomain } from "./catalog.js" +import type { CommandDomain } from "./command.js" +import type { EventDomain } from "./event.js" +import type { IntegrationDomain } from "./integration.js" +import type { ReferenceDomain } from "./reference.js" +import type { SessionDomain } from "./session.js" +import type { SkillDomain } from "./skill.js" +import type { ToolDomain } from "./tool.js" + +export interface Context { + readonly options: PluginOptions + readonly agent: AgentDomain + readonly aisdk: AISDKDomain + readonly catalog: CatalogDomain + readonly command: CommandDomain + readonly event: EventDomain + readonly integration: IntegrationDomain + readonly plugin: PluginApi + readonly reference: ReferenceDomain + readonly session: SessionDomain + readonly skill: SkillDomain + readonly tool: ToolDomain +} export interface Plugin { readonly id: string - readonly setup: (context: PluginContext) => Promise | void + readonly setup: (context: Context) => Promise | void } export function define(plugin: Plugin) { return plugin } - -export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/promise/reference.ts b/packages/plugin/src/v2/promise/reference.ts index 66bd5b4874..05542b2baf 100644 --- a/packages/plugin/src/v2/promise/reference.ts +++ b/packages/plugin/src/v2/promise/reference.ts @@ -1,10 +1,10 @@ import type { ReferenceApi } from "@opencode-ai/client/promise/api" import type { ReferenceDraft } from "../effect/reference.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { ReferenceDraft } -export interface ReferenceHooks extends ReferenceApi { - readonly transform: TransformHook +export interface ReferenceDomain extends ReferenceApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/registration.ts b/packages/plugin/src/v2/promise/registration.ts index 76be0982b8..0537c1b98e 100644 --- a/packages/plugin/src/v2/promise/registration.ts +++ b/packages/plugin/src/v2/promise/registration.ts @@ -2,8 +2,9 @@ export interface Registration { readonly dispose: () => Promise } -export type Hooks = { - readonly [Name in keyof Spec]: (callback: (input: Spec[Name]) => Promise | void) => Promise -} +export type Hooks = ( + name: Name, + callback: (input: Spec[Name]) => Promise | void, +) => Promise -export type TransformHook = (callback: (input: Input) => void) => Promise +export type Transform = (callback: (input: Input) => void) => Promise diff --git a/packages/plugin/src/v2/promise/runtime.ts b/packages/plugin/src/v2/promise/runtime.ts deleted file mode 100644 index b89b6e8abf..0000000000 --- a/packages/plugin/src/v2/promise/runtime.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { SessionApi } from "@opencode-ai/client/promise/api" - -export interface SessionHooks extends Pick {} diff --git a/packages/plugin/src/v2/promise/session.ts b/packages/plugin/src/v2/promise/session.ts new file mode 100644 index 0000000000..41bf598f46 --- /dev/null +++ b/packages/plugin/src/v2/promise/session.ts @@ -0,0 +1,24 @@ +import type { SessionApi } from "@opencode-ai/client/promise/api" +import type { Message, SystemPart } from "@opencode-ai/llm" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" + +export interface SessionRequestBeforeEvent { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly request: SessionRequestBeforeEvent +} + +export interface SessionDomain extends Pick { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/skill.ts b/packages/plugin/src/v2/promise/skill.ts index a1e62fc544..cbc6459a33 100644 --- a/packages/plugin/src/v2/promise/skill.ts +++ b/packages/plugin/src/v2/promise/skill.ts @@ -1,10 +1,10 @@ import type { SkillApi } from "@opencode-ai/client/promise/api" import type { SkillDraft } from "../effect/skill.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { SkillDraft } -export interface SkillHooks extends SkillApi { - readonly transform: TransformHook +export interface SkillDomain extends SkillApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts new file mode 100644 index 0000000000..da4ee1b200 --- /dev/null +++ b/packages/plugin/src/v2/promise/tool.ts @@ -0,0 +1,110 @@ +export * as Tool from "./tool.js" + +import { Tool } from "../effect/tool.js" +import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Session } from "@opencode-ai/schema/session" +import type { SessionMessage } from "@opencode-ai/schema/session-message" +import { Effect, type JsonSchema, type Schema } from "effect" +import type { Hooks, Transform } from "./registration.js" + +export type Context = Tool.Context +export type SchemaType = Tool.SchemaType +export type Definition, Output extends SchemaType> = Tool.Definition +export type AnyTool = Tool.AnyTool +export const Failure = Tool.Failure +export type Failure = Tool.Failure +export const RegistrationError = Tool.RegistrationError +export type RegistrationError = Tool.RegistrationError +export type Content = Tool.Content +export type DynamicOutput = Tool.DynamicOutput + +type Config< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { + readonly description: string + readonly input: Input + readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => Schema.Schema.Type + readonly execute: ( + input: Schema.Schema.Type, + context: Context, + ) => Promise> + readonly toModelOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => ReadonlyArray +} + +type DynamicConfig = { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema + readonly execute: (input: unknown, context: Context) => Promise +} + +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition +export function make(config: DynamicConfig): AnyTool +export function make(config: Config | DynamicConfig): AnyTool { + if ("jsonSchema" in config) + return Tool.make({ + ...config, + execute: (input, context) => Effect.promise(() => config.execute(input, context)), + }) + return Tool.make({ + ...config, + execute: (input, context) => Effect.promise(() => config.execute(input, context)), + }) +} + +export const withPermission = Tool.withPermission + +export interface ToolExecuteBeforeEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + input: unknown +} + +export interface ToolExecuteAfterEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + readonly input: unknown + result: ToolResultValue + output?: ToolOutput + outputPaths?: ReadonlyArray +} + +export interface RegisterOptions { + readonly group?: string + readonly deferred?: boolean +} + +export interface ToolDraft { + add(name: string, tool: AnyTool, options?: RegisterOptions): void +} + +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks +} diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 00efbd2aff..e0262760dc 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -15,7 +15,7 @@ const PromisePlugin = await import("../src/v2/promise/index") test.each([ ["effect", Plugin], ["promise", PromisePlugin], -])("%s entrypoint exposes its canonical Schema contracts", (name, entrypoint) => { +])("%s entrypoint exposes its canonical Schema contracts", (_name, entrypoint) => { expect(entrypoint.Agent).toBe(Agent) expect(entrypoint.Command).toBe(Command) expect(entrypoint.Connection).toBe(Connection) @@ -32,10 +32,9 @@ test.each([ "Credential", "Integration", "Model", + "Plugin", "Provider", "Reference", "Skill", - ...(name === "effect" ? ["Tool"] : []), - "define", ]) }) diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 3f054fe49d..74ef8e0ed5 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -61,27 +61,5 @@ export const groupNames = { "server.vcs": "vcs", } as const -export const endpointNames = { - "session.messages": "list", - "integration.connect.key": "connectKey", - "search.provider.get": "provider", - "search.provider.select": "selectProvider", - "integration.connect.oauth": "connectOauth", - "integration.attempt.status": "attemptStatus", - "integration.attempt.complete": "attemptComplete", - "integration.attempt.cancel": "attemptCancel", - "session.instructions.entry.list": ["instructions", "entry", "list"], - "session.instructions.entry.put": ["instructions", "entry", "put"], - "session.instructions.entry.remove": ["instructions", "entry", "remove"], - "session.revert.stage": "revertStage", - "session.revert.clear": "revertClear", - "session.revert.commit": "revertCommit", - "permission.request.list": "listRequests", - "permission.saved.list": "listSaved", - "permission.saved.remove": "removeSaved", - "form.request.list": "listRequests", - "question.request.list": "listRequests", -} as const - export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"]) export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index a21d3d86d2..d6d1482f30 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { Skill } from "@opencode-ai/schema/skill" export class InvalidRequestError extends Schema.TaggedErrorClass()( "InvalidRequestError", @@ -83,7 +84,7 @@ export class MessageNotFoundError extends Schema.TaggedErrorClass()( "SkillNotFoundError", { - skill: Schema.String, + skill: Skill.ID, message: Schema.String, }, { httpApiStatus: 404 }, diff --git a/packages/protocol/src/groups/debug.ts b/packages/protocol/src/groups/debug.ts index b43e5bc639..fd7cd9def5 100644 --- a/packages/protocol/src/groups/debug.ts +++ b/packages/protocol/src/groups/debug.ts @@ -1,6 +1,7 @@ import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location.js" export const DebugGroup = HttpApiGroup.make("server.debug") .add( @@ -8,10 +9,24 @@ export const DebugGroup = HttpApiGroup.make("server.debug") success: Schema.Array(Location.Ref), }).annotateMerge( OpenApi.annotations({ - identifier: "v2.debug.location", + identifier: "v2.debug.location.list", summary: "List loaded locations", description: "List locations currently loaded by the server.", }), ), ) + .add( + HttpApiEndpoint.delete("debug.location.evict", "/api/debug/location", { + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.debug.location.evict", + summary: "Evict a loaded location", + description: "Dispose the requested location's cached services so its next use boots them fresh.", + }), + ), + ) .annotateMerge(OpenApi.annotations({ title: "debug" })) diff --git a/packages/protocol/src/groups/mcp.ts b/packages/protocol/src/groups/mcp.ts index d29749ab15..5f9c03935d 100644 --- a/packages/protocol/src/groups/mcp.ts +++ b/packages/protocol/src/groups/mcp.ts @@ -19,4 +19,18 @@ export const McpGroup = HttpApiGroup.make("server.mcp") }), ), ) - .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server status routes." })) + .add( + HttpApiEndpoint.get("mcp.resource.catalog", "/api/mcp/resource", { + query: LocationQuery, + success: Location.response(Mcp.ResourceCatalog), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.mcp.resource.catalog", + summary: "List MCP resources", + description: "Retrieve resources and resource templates from connected MCP servers.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server and resource routes." })) diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts index b8c387576f..5b47146316 100644 --- a/packages/protocol/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -27,7 +27,7 @@ export const MessageGroup = HttpApiGroup.make("server.message") params: { sessionID: Session.ID }, query: SessionMessagesQuery, success: Schema.Struct({ - data: Schema.Array(SessionMessage.Message), + data: Schema.Array(SessionMessage.Info), cursor: Schema.Struct({ previous: Schema.String.pipe(Schema.optional), next: Schema.String.pipe(Schema.optional), @@ -36,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("server.message") error: [InvalidCursorError, SessionNotFoundError, UnknownError], }).annotateMerge( OpenApi.annotations({ - identifier: "v2.session.messages", + identifier: "v2.message.list", summary: "Get session messages", description: "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts index 309868189f..a8fd1a3bc9 100644 --- a/packages/protocol/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -107,7 +107,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.pty.connectToken", + identifier: "v2.pty.connect.token", summary: "Create PTY WebSocket token", description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", }), diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 090df62eb6..e595716873 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -23,9 +23,9 @@ import { UnknownError, } from "../errors.js" import { Agent } from "@opencode-ai/schema/agent" +import { Skill } from "@opencode-ai/schema/skill" import { Model } from "@opencode-ai/schema/model" import { Location } from "@opencode-ai/schema/location" -import { Revert } from "@opencode-ai/schema/revert" import { SessionEvent } from "@opencode-ai/schema/session-event" import { EventLog } from "@opencode-ai/schema/event-log" @@ -269,6 +269,25 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + destination: Schema.Struct({ directory: AbsolutePath }), + moveChanges: Schema.Boolean.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, InvalidRequestError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.move", + summary: "Move session", + description: "Move a session to another project directory, optionally transferring local changes.", + }), + ), + ) .add( HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { params: { sessionID: Session.ID }, @@ -297,7 +316,7 @@ export const makeSessionGroup = (sessionLo id: SessionMessage.ID.pipe(Schema.optional), command: Schema.String, arguments: Schema.String.pipe(Schema.optional), - agent: Schema.String.pipe(Schema.optional), + agent: Agent.ID.pipe(Schema.optional), model: Model.Ref.pipe(Schema.optional), files: PromptInput.Prompt.fields.files, agents: PromptInput.Prompt.fields.agents, @@ -322,7 +341,7 @@ export const makeSessionGroup = (sessionLo params: { sessionID: Session.ID }, payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional), - skill: Schema.String, + skill: Skill.ID, resume: Schema.Boolean.pipe(Schema.optional), }), success: HttpApiSchema.NoContent, @@ -344,6 +363,7 @@ export const makeSessionGroup = (sessionLo text: Schema.String, description: Schema.String.pipe(Schema.optional), metadata: SessionMessage.Synthetic.fields.metadata, + resume: Schema.Boolean.pipe(Schema.optional), }), success: HttpApiSchema.NoContent, error: SessionNotFoundError, @@ -412,7 +432,7 @@ export const makeSessionGroup = (sessionLo HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { params: { sessionID: Session.ID }, payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), - success: Schema.Struct({ data: Revert.State }), + success: Schema.Struct({ data: Session.Revert }), error: [MessageNotFoundError, SessionNotFoundError, SessionBusyError, UnknownError], }) .middleware(sessionLocationMiddleware) @@ -447,7 +467,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { params: { sessionID: Session.ID }, - success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), + success: Schema.Struct({ data: Schema.Array(SessionMessage.Info) }), error: [SessionNotFoundError, UnknownError], }) .middleware(sessionLocationMiddleware) @@ -563,7 +583,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", { params: { sessionID: Session.ID, messageID: SessionMessage.ID }, - success: Schema.Struct({ data: SessionMessage.Message }), + success: Schema.Struct({ data: SessionMessage.Info }), error: [SessionNotFoundError, MessageNotFoundError], }) .middleware(sessionLocationMiddleware) diff --git a/packages/protocol/test/event.test.ts b/packages/protocol/test/event.test.ts index 33ece777c4..56213ef4cc 100644 --- a/packages/protocol/test/event.test.ts +++ b/packages/protocol/test/event.test.ts @@ -4,5 +4,6 @@ import { isOpenCodeEvent } from "../src/groups/event.js" test("classifies public events by type", () => { expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true) + expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false) }) diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index eb84cf500b..81bdfe8c98 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -10,9 +10,12 @@ import { PositiveInt, statics } from "./schema.js" const Updated = ephemeral({ type: "agent.updated", schema: {} }) -export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +export const ID = Schema.String.pipe(Schema.brand("Agent.ID")) export type ID = typeof ID.Type +export const Name = Schema.String.pipe(Schema.brand("Agent.Name")) +export type Name = typeof Name.Type + export const Color = Schema.Union([ Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), @@ -22,6 +25,7 @@ export type Color = typeof Color.Type export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, + name: Name, model: Model.Ref.pipe(optional), request: Provider.Request, system: Schema.String.pipe(optional), @@ -32,12 +36,13 @@ export const Info = Schema.Struct({ steps: PositiveInt.pipe(optional), permissions: Permission.Ruleset, }) - .annotate({ identifier: "AgentV2.Info" }) + .annotate({ identifier: "Agent.Info" }) .pipe( statics((schema) => ({ empty: (id: ID) => schema.make({ id, + name: Name.make(id), request: { settings: {}, headers: {}, body: {} }, mode: "all", hidden: false, diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index 81e37157c6..ef32acb821 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -4,6 +4,7 @@ import { Schema } from "effect" import { ephemeral, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" +import { Agent } from "./agent.js" const Updated = ephemeral({ type: "command.updated", schema: {} }) @@ -12,10 +13,10 @@ export const Info = Schema.Struct({ name: Schema.String, template: Schema.String, description: Schema.String.pipe(optional), - agent: Schema.String.pipe(optional), + agent: Agent.ID.pipe(optional), model: Model.Ref.pipe(optional), subtask: Schema.Boolean.pipe(optional), -}).annotate({ identifier: "CommandV2.Info" }) +}).annotate({ identifier: "Command.Info" }) export const Event = { Updated, diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 23ed3fd044..035f72568e 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -80,6 +80,7 @@ export const ServerDefinitions = Event.inventory( ...InstallationEvent.Definitions, ...VcsEvent.Definitions, McpEvent.StatusChanged, + McpEvent.ResourcesChanged, // Shared transitional: V1 contracts the current TUI still consumes during // the migration (permission.asked/replied, question.asked, session.error). // Remove when the TUI moves to the current permission/question surfaces. diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 34e4495807..e4f2f9b813 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -1,6 +1,6 @@ export * as Event from "./event.js" -import { Schema } from "effect" +import { Schema, SchemaTransformation } from "effect" import { optional } from "./schema.js" import { ascending } from "./identifier.js" import { Location } from "./location.js" @@ -72,6 +72,7 @@ export type Payload = D extends DurableDefini type Input>>> = { readonly type: Type + readonly identifier?: string readonly durable?: { readonly version: number readonly aggregate: string @@ -84,16 +85,29 @@ export function durable< const Fields extends Readonly>>, >(input: Input & { readonly durable: NonNullable["durable"]> }) { const data = Schema.Struct(input.schema) + const durable = Schema.Struct({ + aggregateID: DurableEnvelope.fields.aggregateID, + seq: DurableEnvelope.fields.seq, + version: Schema.Literal(input.durable.version).pipe( + Schema.decodeTo( + Schema.toType(Version), + SchemaTransformation.transform({ + decode: () => Version.make(input.durable.version), + encode: () => input.durable.version, + }), + ), + ), + }) return Schema.Struct({ id: ID, created: DateTimeUtcFromMillis, metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), type: Schema.Literal(input.type), - durable: DurableEnvelope, + durable, location: optional(Location.Ref), data, }) - .annotate({ identifier: input.type }) + .annotate({ identifier: input.identifier ?? input.type }) .pipe( statics(() => ({ type: input.type, @@ -117,7 +131,7 @@ export function ephemeral< location: optional(Location.Ref), data, }) - .annotate({ identifier: input.type }) + .annotate({ identifier: input.identifier ?? input.type }) .pipe( statics(() => ({ type: input.type, diff --git a/packages/schema/src/file-diff.ts b/packages/schema/src/file-diff.ts index 9847a10574..ff467c58e4 100644 --- a/packages/schema/src/file-diff.ts +++ b/packages/schema/src/file-diff.ts @@ -1,13 +1,23 @@ export * as FileDiff from "./file-diff.js" import { Schema } from "effect" -import { optional } from "./schema.js" +import { NonNegativeInt, optional } from "./schema.js" export const Info = Schema.Struct({ - file: optional(Schema.String), - patch: optional(Schema.String), + file: Schema.String, + patch: Schema.String, + additions: NonNegativeInt, + deletions: NonNegativeInt, + status: Schema.Literals(["added", "deleted", "modified"]), +}).annotate({ identifier: "FileDiff.Info" }) +export interface Info extends Schema.Schema.Type {} + +/** V1 snapshot and persisted session diff shape. */ +export const LegacyInfo = Schema.Struct({ + file: Schema.String.pipe(optional), + patch: Schema.String.pipe(optional), additions: Schema.Finite, deletions: Schema.Finite, - status: optional(Schema.Literals(["added", "deleted", "modified"])), -}).annotate({ identifier: "SnapshotFileDiff" }) -export interface Info extends Schema.Schema.Type {} + status: Schema.Literals(["added", "deleted", "modified"]).pipe(optional), +}).annotate({ identifier: "FileDiff.LegacyInfo" }) +export interface LegacyInfo extends Schema.Schema.Type {} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 90118b481e..8f1bc0183c 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -9,7 +9,9 @@ export { Form } from "./form.js" export { Integration } from "./integration.js" export { LLM } from "./llm.js" export { Location } from "./location.js" +export { Mcp } from "./mcp.js" export { Model } from "./model.js" +export { Money } from "./money.js" export { Permission } from "./permission.js" export { PermissionSaved } from "./permission-saved.js" export { Project } from "./project.js" @@ -17,14 +19,15 @@ export { ProjectCopy } from "./project-copy.js" export { Provider } from "./provider.js" export { Reference } from "./reference.js" export { Search } from "./search.js" -export { Revert } from "./revert.js" export { Session } from "./session.js" export { Vcs } from "./vcs.js" export { SessionInput } from "./session-input.js" export { SessionError } from "./session-error.js" export { SessionMessage } from "./session-message.js" +export { Snapshot } from "./snapshot.js" export { Shell } from "./shell.js" export { Skill } from "./skill.js" +export { TokenUsage } from "./token-usage.js" export { Pty } from "./pty.js" export { PtyTicket } from "./pty-ticket.js" export { Question } from "./question.js" diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index ae1e82656d..d41f5f0c4a 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -10,6 +10,13 @@ export const ToolsChanged = Event.ephemeral({ }, }) +export const ResourcesChanged = Event.ephemeral({ + type: "mcp.resources.changed", + schema: { + server: Schema.String, + }, +}) + export const BrowserOpenFailed = Event.ephemeral({ type: "mcp.browser.open.failed", schema: { @@ -27,4 +34,4 @@ export const StatusChanged = Event.ephemeral({ }, }) -export const Definitions = Event.inventory(ToolsChanged, StatusChanged) +export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, StatusChanged) diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index 4a7ce15618..a86ffe6d67 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -25,14 +25,9 @@ const NeedsClientRegistration = Schema.Struct({ }).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" }) export type Status = typeof Status.Type -export const Status = Schema.Union([ - Connected, - Pending, - Disabled, - Failed, - NeedsAuth, - NeedsClientRegistration, -]).pipe(Schema.toTaggedUnion("status")) +export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe( + Schema.toTaggedUnion("status"), +) export interface Server extends Schema.Schema.Type {} export const Server = Schema.Struct({ @@ -42,3 +37,50 @@ export const Server = Schema.Struct({ // without matching by name, which could collide with provider or plugin integrations. integrationID: optional(IntegrationID), }).annotate({ identifier: "Mcp.Server" }) + +export interface Resource extends Schema.Schema.Type {} +export const Resource = Schema.Struct({ + server: Schema.String, + name: Schema.String, + uri: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), +}).annotate({ identifier: "Mcp.Resource" }) + +export interface ResourceTemplate extends Schema.Schema.Type {} +export const ResourceTemplate = Schema.Struct({ + server: Schema.String, + name: Schema.String, + uriTemplate: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), +}).annotate({ identifier: "Mcp.ResourceTemplate" }) + +export interface ResourceCatalog extends Schema.Schema.Type {} +export const ResourceCatalog = Schema.Struct({ + resources: Schema.Array(Resource), + templates: Schema.Array(ResourceTemplate), +}).annotate({ identifier: "Mcp.ResourceCatalog" }) + +export const ResourceContentPart = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("text"), + uri: Schema.String, + text: Schema.String, + mimeType: optional(Schema.String), + }), + Schema.Struct({ + type: Schema.Literal("blob"), + uri: Schema.String, + blob: Schema.String, + mimeType: optional(Schema.String), + }), +]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Mcp.ResourceContentPart" })) +export type ResourceContentPart = typeof ResourceContentPart.Type + +export interface ResourceContent extends Schema.Schema.Type {} +export const ResourceContent = Schema.Struct({ + server: Schema.String, + uri: Schema.String, + contents: Schema.Array(ResourceContentPart), +}).annotate({ identifier: "Mcp.ResourceContent" }) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index fc6b64712f..3539c87f77 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -3,11 +3,12 @@ export * as Model from "./model.js" import { Schema } from "effect" import { optional, statics } from "./schema.js" import { Provider } from "./provider.js" +import { Money } from "./money.js" -export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export const ID = Schema.String.pipe(Schema.brand("Model.ID")) export type ID = typeof ID.Type -export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export const VariantID = Schema.String.pipe(Schema.brand("Model.VariantID")) export type VariantID = typeof VariantID.Type export const Ref = Schema.Struct({ @@ -17,7 +18,7 @@ export const Ref = Schema.Struct({ }).annotate({ identifier: "Model.Ref" }) export interface Ref extends Schema.Schema.Type {} -export const Family = Schema.String.pipe(Schema.brand("Family")) +export const Family = Schema.String.pipe(Schema.brand("Model.Family")) export type Family = typeof Family.Type export interface Capabilities extends Schema.Schema.Type {} @@ -30,14 +31,14 @@ export const Capabilities = Schema.Struct({ export interface Cost extends Schema.Schema.Type {} export const Cost = Schema.Struct({ tier: Schema.Struct({ - type: Schema.Literal("context"), + type: Schema.tag("context"), size: Schema.Int, }).pipe(optional), - input: Schema.Finite, - output: Schema.Finite, + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, + read: Money.USDPerMillionTokens, + write: Money.USDPerMillionTokens, }), }).annotate({ identifier: "Model.Cost" }) @@ -70,7 +71,7 @@ export const Info = Schema.Struct({ output: Schema.Int, }), }) - .annotate({ identifier: "ModelV2.Info" }) + .annotate({ identifier: "Model.Info" }) .pipe( statics((schema) => ({ empty: (providerID: Provider.ID, id: ID) => diff --git a/packages/schema/src/money.ts b/packages/schema/src/money.ts new file mode 100644 index 0000000000..f21a390be8 --- /dev/null +++ b/packages/schema/src/money.ts @@ -0,0 +1,18 @@ +export * as Money from "./money.js" + +import { Schema } from "effect" +import { statics } from "./schema.js" + +export const USD = Schema.Finite.pipe( + Schema.brand("Money.USD"), + Schema.annotate({ identifier: "Money.USD" }), + statics((schema) => ({ zero: schema.make(0) })), +) +export type USD = typeof USD.Type + +export const USDPerMillionTokens = Schema.Finite.pipe( + Schema.brand("Money.USDPerMillionTokens"), + Schema.annotate({ identifier: "Money.USDPerMillionTokens" }), + statics((schema) => ({ zero: schema.make(0) })), +) +export type USDPerMillionTokens = typeof USDPerMillionTokens.Type diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts deleted file mode 100644 index ab211a60f3..0000000000 --- a/packages/schema/src/revert.ts +++ /dev/null @@ -1,24 +0,0 @@ -export * as Revert from "./revert.js" - -import { Schema } from "effect" -import { optional } from "./schema.js" -import { NonNegativeInt, RelativePath } from "./schema.js" -import { SessionMessage } from "./session-message.js" - -export const FileDiff = Schema.Struct({ - path: RelativePath, - status: Schema.Literals(["added", "modified", "deleted"]), - additions: NonNegativeInt, - deletions: NonNegativeInt, - patch: Schema.String, -}).annotate({ identifier: "File.Diff" }) -export interface FileDiff extends Schema.Schema.Type {} - -export const State = Schema.Struct({ - messageID: SessionMessage.ID, - partID: Schema.String.pipe(optional), - snapshot: Schema.String.pipe(optional), - diff: Schema.String.pipe(optional), - files: Schema.Array(FileDiff).pipe(optional), -}).annotate({ identifier: "Revert.State" }) -export interface State extends Schema.Schema.Type {} diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 8e96321ce2..9c2417e053 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -12,9 +12,14 @@ import { FileAttachment, Prompt } from "./prompt.js" import { SessionID } from "./session-id.js" import { Location } from "./location.js" import { SessionMessage } from "./session-message.js" -import { Revert } from "./revert.js" +import { Revert } from "./session-revert.js" import { Shell as ShellSchema } from "./shell.js" import { SessionError } from "./session-error.js" +import { Agent } from "./agent.js" +import { Skill as SkillSchema } from "./skill.js" +import { Money } from "./money.js" +import { Snapshot } from "./snapshot.js" +import { TokenUsage } from "./token-usage.js" export { FileAttachment } @@ -23,7 +28,7 @@ export const Source = Schema.Struct({ end: NonNegativeInt, text: Schema.String, }).annotate({ - identifier: "session.event.source", + identifier: "Session.Event.Source", }) export interface Source extends Schema.Schema.Type {} @@ -48,7 +53,7 @@ export const AgentSelected = Event.durable({ ...options, schema: { ...Base, - agent: Schema.String, + agent: Agent.ID, }, }) export type AgentSelected = typeof AgentSelected.Type @@ -84,6 +89,16 @@ export const Renamed = Event.durable({ }) export type Renamed = typeof Renamed.Type +export const UsageUpdated = Event.ephemeral({ + type: "session.usage.updated", + schema: { + ...Base, + cost: Money.USD, + tokens: TokenUsage.Info, + }, +}) +export type UsageUpdated = typeof UsageUpdated.Type + export const Deleted = Event.durable({ type: "session.deleted", durable: { @@ -172,7 +187,8 @@ export namespace Skill { ...options, schema: { ...Base, - name: Schema.String, + id: SkillSchema.ID, + name: SkillSchema.Name, text: Schema.String, }, }) @@ -209,9 +225,9 @@ export namespace Step { schema: { ...Base, assistantMessageID: SessionMessage.ID, - agent: Schema.String, + agent: Agent.ID, model: Model.Ref, - snapshot: Schema.String.pipe(optional), + snapshot: Snapshot.ID.pipe(optional), }, }) export type Started = typeof Started.Type @@ -223,17 +239,9 @@ export namespace Step { ...Base, assistantMessageID: SessionMessage.ID, finish: FinishReason, - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - snapshot: Schema.String.pipe(optional), + cost: Money.USD, + tokens: TokenUsage.Info, + snapshot: Snapshot.ID.pipe(optional), files: Schema.Array(RelativePath).pipe(optional), }, }) @@ -246,6 +254,8 @@ export namespace Step { ...Base, assistantMessageID: SessionMessage.ID, error: SessionError.Error, + cost: Money.USD.pipe(optional), + tokens: TokenUsage.Info.pipe(optional), }, }) export type Failed = typeof Failed.Type @@ -400,7 +410,6 @@ export namespace Tool { ...ToolBase, structured: Schema.Record(Schema.String, Schema.Unknown), content: Schema.Array(ToolContent), - outputPaths: Schema.Array(Schema.String).pipe(optional), result: Schema.Unknown.pipe(optional), executed: Schema.Boolean, resultState: SessionMessage.ProviderState.pipe(optional), @@ -451,7 +460,9 @@ export namespace Compaction { ...options, schema: { ...Base, - reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), + reason: Schema.Literals(["auto", "manual"]), + recent: Schema.String, + inputID: SessionMessage.ID.pipe(optional), }, }) export type Started = typeof Started.Type @@ -480,7 +491,12 @@ export namespace Compaction { export const Failed = Event.durable({ type: "session.compaction.failed", ...options, - schema: Base, + schema: { + ...Base, + reason: Started.data.fields.reason, + error: SessionError.Error, + inputID: SessionMessage.ID.pipe(optional), + }, }) export type Failed = typeof Failed.Type } @@ -489,7 +505,7 @@ export namespace RevertEvent { export const Staged = Event.durable({ type: "session.revert.staged", ...options, - schema: { ...Base, revert: Revert.State }, + schema: { ...Base, revert: Revert }, }) export const Cleared = Event.durable({ type: "session.revert.cleared", ...options, schema: Base }) export const Committed = Event.durable({ @@ -504,6 +520,7 @@ export const Definitions = Event.inventory( ModelSelected, Moved, Renamed, + UsageUpdated, Deleted, Forked, PromptPromoted, @@ -550,7 +567,7 @@ export const DurableDefinitions = Event.inventory( export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "SessionDurableEvent" }) + .annotate({ identifier: "Session.Event.Durable" }) export type DurableEvent = typeof Durable.Type export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts index a59c00b40e..d1837ba08f 100644 --- a/packages/schema/src/session-input.ts +++ b/packages/schema/src/session-input.ts @@ -24,13 +24,13 @@ export const Admitted = Schema.Struct({ export interface PromptEntry extends Schema.Schema.Type {} export const PromptEntry = Schema.Struct({ - type: Schema.Literal("prompt"), + type: Schema.tag("prompt"), ...Admitted.fields, }).annotate({ identifier: "SessionInput.PromptEntry" }) export interface Compaction extends Schema.Schema.Type {} export const Compaction = Schema.Struct({ - type: Schema.Literal("compaction"), + type: Schema.tag("compaction"), admittedSeq: NonNegativeInt, id: SessionMessage.ID, sessionID: SessionID, @@ -38,5 +38,8 @@ export const Compaction = Schema.Struct({ handledSeq: NonNegativeInt.pipe(optional), }).annotate({ identifier: "SessionInput.Compaction" }) -export const Entry = Schema.Union([PromptEntry, Compaction]).pipe(Schema.toTaggedUnion("type")) -export type Entry = typeof Entry.Type +export const Info = Schema.Union([PromptEntry, Compaction]).pipe( + Schema.toTaggedUnion("type"), + Schema.annotate({ identifier: "SessionInput.Info" }), +) +export type Info = typeof Info.Type diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 62e1a1038d..3d45e283e6 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -4,14 +4,18 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { ToolContent } from "./llm.js" import { Model } from "./model.js" -import { FileAttachment, Prompt } from "./prompt.js" +import { Prompt } from "./prompt.js" import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js" -import { SessionID } from "./session-id.js" import { ascending } from "./identifier.js" import { Event } from "./event.js" import { Shell as ShellSchema } from "./shell.js" import { FinishReason } from "./llm.js" import { SessionError } from "./session-error.js" +import { Agent } from "./agent.js" +import { Skill as SkillSchema } from "./skill.js" +import { Money } from "./money.js" +import { Snapshot } from "./snapshot.js" +import { TokenUsage } from "./token-usage.js" export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( Schema.brand("Session.Message.ID"), @@ -36,14 +40,14 @@ export type ProviderState = typeof ProviderState.Type export interface AgentSelected extends Schema.Schema.Type {} export const AgentSelected = Schema.Struct({ ...Base, - type: Schema.Literal("agent-switched"), - agent: Schema.String, + type: Schema.tag("agent-switched"), + agent: Agent.ID, }).annotate({ identifier: "Session.Message.AgentSelected" }) export interface ModelSelected extends Schema.Schema.Type {} export const ModelSelected = Schema.Struct({ ...Base, - type: Schema.Literal("model-switched"), + type: Schema.tag("model-switched"), model: Model.Ref, previous: Model.Ref.pipe(optional), }).annotate({ identifier: "Session.Message.ModelSelected" }) @@ -54,38 +58,41 @@ export const User = Schema.Struct({ text: Prompt.fields.text, files: Prompt.fields.files, agents: Prompt.fields.agents, - type: Schema.Literal("user"), + type: Schema.tag("user"), }).annotate({ identifier: "Session.Message.User" }) export interface Synthetic extends Schema.Schema.Type {} export const Synthetic = Schema.Struct({ ...Base, - sessionID: SessionID, text: Schema.String, description: Schema.String.pipe(optional), - type: Schema.Literal("synthetic"), + type: Schema.tag("synthetic"), }).annotate({ identifier: "Session.Message.Synthetic" }) export interface System extends Schema.Schema.Type {} export const System = Schema.Struct({ ...Base, - type: Schema.Literal("system"), + type: Schema.tag("system"), text: Schema.String, }).annotate({ identifier: "Session.Message.System" }) export interface Skill extends Schema.Schema.Type {} export const Skill = Schema.Struct({ ...Base, - type: Schema.Literal("skill"), - name: Schema.String, + type: Schema.tag("skill"), + skill: SkillSchema.ID, + name: SkillSchema.Name, text: Schema.String, }).annotate({ identifier: "Session.Message.Skill" }) export interface Shell extends Schema.Schema.Type {} export const Shell = Schema.Struct({ ...Base, - type: Schema.Literal("shell"), - shell: ShellSchema.Info, + type: Schema.tag("shell"), + shellID: ShellSchema.ID, + command: Schema.String, + status: ShellSchema.Status, + exit: Schema.Number.pipe(optional), output: ShellSchema.Output.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, @@ -93,15 +100,15 @@ export const Shell = Schema.Struct({ }), }).annotate({ identifier: "Session.Message.Shell" }) -export interface ToolStatePending extends Schema.Schema.Type {} -export const ToolStatePending = Schema.Struct({ - status: Schema.Literal("pending"), +export interface ToolStateStreaming extends Schema.Schema.Type {} +export const ToolStateStreaming = Schema.Struct({ + status: Schema.tag("streaming"), input: Schema.String, -}).annotate({ identifier: "Session.Message.ToolState.Pending" }) +}).annotate({ identifier: "Session.Message.ToolState.Streaming" }) export interface ToolStateRunning extends Schema.Schema.Type {} export const ToolStateRunning = Schema.Struct({ - status: Schema.Literal("running"), + status: Schema.tag("running"), input: Schema.Record(Schema.String, Schema.Unknown), structured: Schema.Record(Schema.String, Schema.Unknown), content: ToolContent.pipe(Schema.Array), @@ -109,18 +116,16 @@ export const ToolStateRunning = Schema.Struct({ export interface ToolStateCompleted extends Schema.Schema.Type {} export const ToolStateCompleted = Schema.Struct({ - status: Schema.Literal("completed"), + status: Schema.tag("completed"), input: Schema.Record(Schema.String, Schema.Unknown), - attachments: FileAttachment.pipe(Schema.Array, optional), content: ToolContent.pipe(Schema.Array), - outputPaths: Schema.Array(Schema.String).pipe(optional), structured: Schema.Record(Schema.String, Schema.Unknown), result: Schema.Unknown.pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Completed" }) export interface ToolStateError extends Schema.Schema.Type {} export const ToolStateError = Schema.Struct({ - status: Schema.Literal("error"), + status: Schema.tag("error"), input: Schema.Record(Schema.String, Schema.Unknown), content: ToolContent.pipe(Schema.Array), structured: Schema.Record(Schema.String, Schema.Unknown), @@ -128,14 +133,14 @@ export const ToolStateError = Schema.Struct({ result: Schema.Unknown.pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Error" }) -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( +export const ToolState = Schema.Union([ToolStateStreaming, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( Schema.toTaggedUnion("status"), ) -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError +export type ToolState = ToolStateStreaming | ToolStateRunning | ToolStateCompleted | ToolStateError export interface AssistantTool extends Schema.Schema.Type {} export const AssistantTool = Schema.Struct({ - type: Schema.Literal("tool"), + type: Schema.tag("tool"), id: Schema.String, name: Schema.String, executed: Schema.Boolean.pipe(optional), @@ -146,19 +151,18 @@ export const AssistantTool = Schema.Struct({ created: DateTimeUtcFromMillis, ran: DateTimeUtcFromMillis.pipe(optional), completed: DateTimeUtcFromMillis.pipe(optional), - pruned: DateTimeUtcFromMillis.pipe(optional), }), }).annotate({ identifier: "Session.Message.Assistant.Tool" }) export interface AssistantText extends Schema.Schema.Type {} export const AssistantText = Schema.Struct({ - type: Schema.Literal("text"), + type: Schema.tag("text"), text: Schema.String, }).annotate({ identifier: "Session.Message.Assistant.Text" }) export interface AssistantReasoning extends Schema.Schema.Type {} export const AssistantReasoning = Schema.Struct({ - type: Schema.Literal("reasoning"), + type: Schema.tag("reasoning"), text: Schema.String, state: ProviderState.pipe(optional), time: Schema.Struct({ @@ -182,23 +186,18 @@ export const AssistantRetry = Schema.Struct({ export interface Assistant extends Schema.Schema.Type {} export const Assistant = Schema.Struct({ ...Base, - type: Schema.Literal("assistant"), - agent: Schema.String, + type: Schema.tag("assistant"), + agent: Agent.ID, model: Model.Ref, content: AssistantContent.pipe(Schema.Array), snapshot: Schema.Struct({ - start: Schema.String.pipe(optional), - end: Schema.String.pipe(optional), + start: Snapshot.ID.pipe(optional), + end: Snapshot.ID.pipe(optional), files: Schema.Array(RelativePath).pipe(optional), }).pipe(optional), finish: FinishReason.pipe(optional), - cost: Schema.Finite.pipe(optional), - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }), - }).pipe(optional), + cost: Money.USD.pipe(optional), + tokens: TokenUsage.Info.pipe(optional), error: SessionError.Error.pipe(optional), retry: AssistantRetry.pipe(optional), time: Schema.Struct({ @@ -207,17 +206,41 @@ export const Assistant = Schema.Struct({ }), }).annotate({ identifier: "Session.Message.Assistant" }) -export interface Compaction extends Schema.Schema.Type {} -export const Compaction = Schema.Struct({ - type: Schema.Literal("compaction"), - status: Schema.Literals(["queued", "running", "completed", "failed"]), +const CompactionBase = { type: Schema.tag("compaction"), ...Base } + +export interface CompactionRunning extends Schema.Schema.Type {} +export const CompactionRunning = Schema.Struct({ + ...CompactionBase, + status: Schema.tag("running"), reason: Schema.Literals(["auto", "manual"]), summary: Schema.String, recent: Schema.String, - ...Base, -}).annotate({ identifier: "Session.Message.Compaction" }) +}).annotate({ identifier: "Session.Message.Compaction.Running" }) -export const Message = Schema.Union([ +export interface CompactionCompleted extends Schema.Schema.Type {} +export const CompactionCompleted = Schema.Struct({ + ...CompactionBase, + status: Schema.tag("completed"), + reason: Schema.Literals(["auto", "manual"]), + summary: Schema.String, + recent: Schema.String, +}).annotate({ identifier: "Session.Message.Compaction.Completed" }) + +export interface CompactionFailed extends Schema.Schema.Type {} +export const CompactionFailed = Schema.Struct({ + ...CompactionBase, + status: Schema.tag("failed"), + reason: Schema.Literals(["auto", "manual"]), + error: SessionError.Error, +}).annotate({ identifier: "Session.Message.Compaction.Failed" }) + +export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted, CompactionFailed]).pipe( + Schema.toTaggedUnion("status"), + Schema.annotate({ identifier: "Session.Message.Compaction" }), +) +export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed + +export const Info = Schema.Union([ AgentSelected, ModelSelected, User, @@ -229,6 +252,6 @@ export const Message = Schema.Union([ Compaction, ]) .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Session.Message" }) -export type Message = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction -export type Type = Message["type"] + .annotate({ identifier: "Session.Message.Info" }) +export type Info = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction +export type Type = Info["type"] diff --git a/packages/schema/src/session-revert.ts b/packages/schema/src/session-revert.ts new file mode 100644 index 0000000000..bbbc3b0f9b --- /dev/null +++ b/packages/schema/src/session-revert.ts @@ -0,0 +1,86 @@ +import { Schema, SchemaTransformation } from "effect" +import { FileDiff } from "./file-diff.js" +import { optional } from "./schema.js" +import { SessionMessage } from "./session-message.js" +import { Snapshot } from "./snapshot.js" + +export interface Revert extends Schema.Schema.Type {} +export const Revert = Schema.Struct({ + messageID: SessionMessage.ID, + /** Legacy V1 compatibility state. */ + partID: Schema.String.pipe(optional), + snapshot: Snapshot.ID.pipe(optional), + files: Schema.Array(FileDiff.Info).pipe(optional), +}).annotate({ identifier: "Session.Revert" }) + +const FileDiffV1 = Schema.Struct({ + path: Schema.String, + status: Schema.Literals(["added", "modified", "deleted"]), + additions: Schema.Finite, + deletions: Schema.Finite, + patch: Schema.String, +}) + +export interface RevertV1 extends Schema.Schema.Type {} +export const RevertV1 = Schema.Struct({ + messageID: SessionMessage.ID, + partID: Schema.String.pipe(optional), + snapshot: Schema.String.pipe(optional), + diff: Schema.String.pipe(optional), + files: Schema.Array(FileDiffV1).pipe(optional), +}).annotate({ identifier: "Session.RevertV1" }) + +const PersistedCurrent = Revert.pipe( + Schema.decodeTo( + Schema.Struct({ source: Schema.tag("current"), revert: Schema.toType(Revert) }), + SchemaTransformation.transform({ + decode: (revert): { readonly source: "current"; readonly revert: Revert } => ({ + source: "current", + revert, + }), + encode: (value) => value.revert, + }), + ), +) +const PersistedLegacy = RevertV1.pipe( + Schema.decodeTo( + Schema.Struct({ source: Schema.tag("legacy"), revert: Schema.toType(RevertV1) }), + SchemaTransformation.transform({ + decode: (revert): { readonly source: "legacy"; readonly revert: RevertV1 } => ({ + source: "legacy", + revert, + }), + encode: (value) => value.revert, + }), + ), +) + +/** Storage decoder for revert state written before FileDiff became canonical. */ +export const PersistedRevert = Schema.Union([PersistedCurrent, PersistedLegacy]).pipe( + Schema.toTaggedUnion("source"), + Schema.decodeTo( + Schema.toType(Revert), + SchemaTransformation.transform({ + decode: (persisted): Revert => { + if (persisted.source === "current") return persisted.revert + return Revert.make({ + messageID: persisted.revert.messageID, + partID: persisted.revert.partID, + snapshot: persisted.revert.snapshot ? Snapshot.ID.make(persisted.revert.snapshot) : undefined, + files: persisted.revert.files?.map((file) => ({ + file: file.path, + status: file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })), + }) + }, + encode: (revert): { readonly source: "current"; readonly revert: Revert } => ({ + source: "current", + revert, + }), + }), + ), + Schema.annotate({ identifier: "Session.Revert.Persisted" }), +) diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index 1d5a5ffef0..fffb4b7b3d 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -9,34 +9,31 @@ import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js" import { SessionEvent } from "./session-event.js" import { SessionID } from "./session-id.js" import { SessionMessage } from "./session-message.js" -import { Revert } from "./revert.js" +import { Money } from "./money.js" +import { TokenUsage } from "./token-usage.js" +import { Revert } from "./session-revert.js" export const ID = SessionID export type ID = SessionID export const Event = SessionEvent +export { Revert } + export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, parentID: ID.pipe(optional), fork: Schema.Struct({ sessionID: ID, + /** Messages before this exclusive boundary are copied into the fork. */ messageID: SessionMessage.ID.pipe(optional), }).pipe(optional), projectID: Project.ID, agent: Agent.ID.pipe(optional), model: Model.Ref.pipe(optional), - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), + cost: Money.USD, + tokens: TokenUsage.Info, time: Schema.Struct({ created: DateTimeUtcFromMillis, updated: DateTimeUtcFromMillis, @@ -45,8 +42,8 @@ export const Info = Schema.Struct({ title: Schema.String, location: Location.Ref, subpath: RelativePath.pipe(optional), - revert: Revert.State.pipe(optional), -}).annotate({ identifier: "SessionV2.Info" }) + revert: Revert.pipe(optional), +}).annotate({ identifier: "Session.Info" }) export const ListAnchor = Schema.Struct({ id: ID, diff --git a/packages/schema/src/shell.ts b/packages/schema/src/shell.ts index 1826752d37..59ac25decf 100644 --- a/packages/schema/src/shell.ts +++ b/packages/schema/src/shell.ts @@ -6,7 +6,7 @@ import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, statics } from "./schema.js" -const IDSchema = Schema.String.check(Schema.isStartsWith("sh_")).pipe(Schema.brand("ShellID")) +const IDSchema = Schema.String.check(Schema.isStartsWith("sh_")).pipe(Schema.brand("Shell.ID")) export const ID = IDSchema.pipe( statics((schema: typeof IDSchema) => { diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts index 184266f2ad..69819eee26 100644 --- a/packages/schema/src/skill.ts +++ b/packages/schema/src/skill.ts @@ -5,49 +5,56 @@ import { optional } from "./schema.js" import { AbsolutePath } from "./schema.js" import { ephemeral, inventory } from "./event.js" +export const ID = Schema.String.pipe(Schema.brand("Skill.ID")) +export type ID = typeof ID.Type + +export const Name = Schema.String.pipe(Schema.brand("Skill.Name")) +export type Name = typeof Name.Type + export interface DirectorySource extends Schema.Schema.Type {} export const DirectorySource = Schema.Struct({ - type: Schema.Literal("directory"), + type: Schema.tag("directory"), path: AbsolutePath, -}).annotate({ identifier: "SkillV2.DirectorySource" }) +}).annotate({ identifier: "Skill.DirectorySource" }) export interface UrlSource extends Schema.Schema.Type {} export const UrlSource = Schema.Struct({ - type: Schema.Literal("url"), + type: Schema.tag("url"), url: Schema.String, -}).annotate({ identifier: "SkillV2.UrlSource" }) +}).annotate({ identifier: "Skill.UrlSource" }) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ - name: Schema.String, + id: ID, + name: Name, description: Schema.String.pipe(optional), slash: Schema.Boolean.pipe(optional), autoinvoke: Schema.Boolean.pipe(optional), location: AbsolutePath, content: Schema.String, -}).annotate({ identifier: "SkillV2.Info" }) +}).annotate({ identifier: "Skill.Info" }) const Updated = ephemeral({ type: "skill.updated", schema: {} }) export const Event = { Updated, Definitions: inventory(Updated) } export interface EmbeddedSource extends Schema.Schema.Type {} export const EmbeddedSource = Schema.Struct({ - type: Schema.Literal("embedded"), + type: Schema.tag("embedded"), skill: Schema.suspend(() => Info), -}).annotate({ identifier: "SkillV2.EmbeddedSource" }) +}).annotate({ identifier: "Skill.EmbeddedSource" }) export type Source = DirectorySource | UrlSource | EmbeddedSource export const Source = Object.assign( Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe( Schema.toTaggedUnion("type"), - Schema.annotate({ identifier: "SkillV2.Source" }), + Schema.annotate({ identifier: "Skill.Source" }), ), { equals: (a: Source, b: Source) => { if (a.type !== b.type) return false if (a.type === "directory" && b.type === "directory") return a.path === b.path if (a.type === "url" && b.type === "url") return a.url === b.url - if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name + if (a.type === "embedded" && b.type === "embedded") return a.skill.id === b.skill.id return false }, key: (source: Source) => @@ -55,6 +62,6 @@ export const Source = Object.assign( ? `directory:${source.path}` : source.type === "url" ? `url:${source.url}` - : `embedded:${source.skill.name}`, + : `embedded:${source.skill.id}`, }, ) diff --git a/packages/schema/src/snapshot.ts b/packages/schema/src/snapshot.ts new file mode 100644 index 0000000000..375981b415 --- /dev/null +++ b/packages/schema/src/snapshot.ts @@ -0,0 +1,6 @@ +export * as Snapshot from "./snapshot.js" + +import { Schema } from "effect" + +export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) +export type ID = typeof ID.Type diff --git a/packages/schema/src/token-usage.ts b/packages/schema/src/token-usage.ts new file mode 100644 index 0000000000..6f08add0cc --- /dev/null +++ b/packages/schema/src/token-usage.ts @@ -0,0 +1,14 @@ +export * as TokenUsage from "./token-usage.js" + +import { Schema } from "effect" + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}).annotate({ identifier: "TokenUsage.Info" }) diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 1c2827f0d8..3932cae88a 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -2,7 +2,6 @@ export * as SessionV1 from "./session.js" import { Effect, Schema, Types } from "effect" import { durable, ephemeral, inventory } from "../event.js" -import { FileDiff } from "../file-diff.js" import { Project } from "../project.js" import { Provider } from "../provider.js" import { Model } from "../model.js" @@ -11,6 +10,7 @@ import { ascending } from "../identifier.js" import { SessionID } from "../session-id.js" import { WorkspaceID } from "../workspace-id.js" import { PermissionV1 } from "./permission.js" +import { FileDiff } from "../file-diff.js" const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) @@ -340,7 +340,7 @@ export const User = Schema.Struct({ Schema.Struct({ title: Schema.optional(Schema.String), body: Schema.optional(Schema.String), - diffs: Schema.Array(FileDiff.Info), + diffs: Schema.Array(FileDiff.LegacyInfo), }), ), agent: Schema.String, @@ -510,7 +510,7 @@ const SessionSummary = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, files: Schema.Finite, - diffs: optional(Schema.Array(FileDiff.Info)), + diffs: optional(Schema.Array(FileDiff.LegacyInfo)), }) const SessionTokens = Schema.Struct({ @@ -565,7 +565,7 @@ export const SessionInfo = Schema.Struct({ }), permission: optional(PermissionV1.Ruleset), revert: optional(SessionRevert), -}).annotate({ identifier: "Session" }) +}).annotate({ identifier: "SessionV1.Info" }) export type SessionInfo = typeof SessionInfo.Type const events = { @@ -644,7 +644,7 @@ export const Diff = ephemeral({ type: "session.diff", schema: { sessionID: SessionID, - diff: Schema.Array(FileDiff.Info), + diff: Schema.Array(FileDiff.LegacyInfo), }, }) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index 236e15b91f..f5f310d8d7 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { DateTime, Schema } from "effect" import { Agent } from "../src/agent.js" import { FileSystem } from "../src/filesystem.js" +import { Mcp } from "../src/mcp.js" import { Model } from "../src/model.js" import { Project } from "../src/project.js" import { Provider } from "../src/provider.js" @@ -9,10 +10,30 @@ import { Pty } from "../src/pty.js" import { Question } from "../src/question.js" import { Session } from "../src/session.js" import { SessionMessage } from "../src/session-message.js" +import { SessionInput } from "../src/session-input.js" +import { FileDiff } from "../src/file-diff.js" +import { Money } from "../src/money.js" +import { Skill } from "../src/skill.js" +import { Shell } from "../src/shell.js" +import { PersistedRevert } from "../src/session-revert.js" import { SessionTodo } from "../src/session-todo.js" import { optional } from "../src/schema.js" describe("contract hygiene", () => { + test("keeps absolute costs distinct from model rates", () => { + const usd = Money.USD.make(1) + const rate = Money.USDPerMillionTokens.make(1) + // @ts-expect-error Model rates are not absolute costs. + const invalidUSD: Money.USD = rate + // @ts-expect-error Absolute costs are not model rates. + const invalidRate: Money.USDPerMillionTokens = usd + + expect(invalidUSD).toBe(Money.USD.make(1)) + expect(invalidRate).toBe(Money.USDPerMillionTokens.make(1)) + expect(Money.USD.zero).toBe(Money.USD.make(0)) + expect(Money.USDPerMillionTokens.zero).toBe(Money.USDPerMillionTokens.make(0)) + }) + test("optional properties preserve transformations and omit undefined while encoding", () => { const Value = Schema.Struct({ value: optional(Schema.FiniteFromString) }) expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) @@ -51,6 +72,11 @@ describe("contract hygiene", () => { const identifiers = [ Agent.Color, FileSystem.Submatch, + Mcp.Resource, + Mcp.ResourceTemplate, + Mcp.ResourceCatalog, + Mcp.ResourceContentPart, + Mcp.ResourceContent, Model.Ref, Model.Capabilities, Model.Cost, @@ -65,6 +91,7 @@ describe("contract hygiene", () => { Project.Info, Pty.Info, Session.ListAnchor, + Session.Revert, ].map((schema) => schema.ast.annotations?.identifier) expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) @@ -98,9 +125,76 @@ describe("contract hygiene", () => { name: "search", executed: true, providerState: { itemId: "item_1" }, - state: { status: "pending", input: "" }, + state: { status: "streaming", input: "" }, time: { created: DateTime.makeUnsafe(0) }, }), ).not.toHaveProperty("provider") }) + + test("reviewed session contracts use their canonical current shapes", () => { + expect(SessionMessage.Info.ast.annotations?.identifier).toBe("Session.Message.Info") + expect(SessionInput.Info.ast.annotations?.identifier).toBe("SessionInput.Info") + expect(Money.USD).not.toBe(Money.USDPerMillionTokens) + expect( + FileDiff.Info.make({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }), + ).toEqual({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }) + expect( + SessionMessage.Shell.make({ + id: SessionMessage.ID.make("msg_shell"), + type: "shell", + shellID: Shell.ID.make("sh_test"), + command: "pwd", + status: "exited", + exit: 0, + time: { created: DateTime.makeUnsafe(0) }, + }), + ).not.toHaveProperty("shell") + expect( + SessionMessage.Skill.make({ + id: SessionMessage.ID.make("msg_skill"), + type: "skill", + skill: Skill.ID.make("effect"), + name: Skill.Name.make("Effect"), + text: "Use Effect", + time: { created: DateTime.makeUnsafe(0) }, + }), + ).toMatchObject({ skill: "effect", name: "Effect" }) + expect( + SessionMessage.CompactionFailed.make({ + id: SessionMessage.ID.make("msg_compaction"), + type: "compaction", + status: "failed", + reason: "manual", + error: { type: "compaction.failed", message: "failed" }, + time: { created: DateTime.makeUnsafe(0) }, + }), + ).not.toHaveProperty("summary") + }) + + test("keeps shared persisted revert compatibility", () => { + expect( + Schema.decodeUnknownSync(Session.Revert)({ + messageID: "msg_legacy", + snapshot: "tree", + diff: "legacy patch", + }), + ).not.toHaveProperty("diff") + + const revert = Schema.decodeUnknownSync(PersistedRevert)({ + messageID: "msg_legacy", + snapshot: "tree", + diff: "legacy patch", + files: [{ path: "src/index.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }], + }) + expect(String(revert.messageID)).toBe("msg_legacy") + expect(String(revert.snapshot)).toBe("tree") + expect(revert.files).toEqual([ + { file: "src/index.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }, + ]) + expect(Schema.encodeSync(PersistedRevert)(revert)).toEqual({ + messageID: "msg_legacy", + snapshot: "tree", + files: [{ file: "src/index.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }], + }) + }) }) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 1a5cbe7fa7..971ac68f24 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -51,6 +51,7 @@ describe("public event manifest", () => { expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated) expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) + expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged) expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted) expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false) expect(Agent.Event.Updated.durable).toBeUndefined() @@ -76,7 +77,7 @@ describe("public event manifest", () => { expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated]) - expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged]) + expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged]) expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) @@ -143,9 +144,18 @@ describe("public event manifest", () => { expect(SessionEvent.DurableDefinitions).toEqual( SessionEvent.Definitions.filter((definition) => definition.durability === "durable"), ) + expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral") + expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral") + expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false) + expect(EventManifest.ServerDefinitions).toContain(SessionEvent.UsageUpdated) expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true) }) + test("uses the current Session skill event as durable version 1", () => { + expect(EventManifest.Durable.get("session.skill.activated.1")).toBe(SessionEvent.Skill.Activated) + expect(EventManifest.Latest.get("session.skill.activated")).toBe(SessionEvent.Skill.Activated) + }) + test("keeps simplified session fragment and tool payloads on durable version 1", () => { const sessionID = SessionID.make("ses_test") const assistantMessageID = SessionMessage.ID.make("msg_test") diff --git a/packages/schema/test/mcp.test.ts b/packages/schema/test/mcp.test.ts new file mode 100644 index 0000000000..75daa7fe1c --- /dev/null +++ b/packages/schema/test/mcp.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Mcp } from "../src/mcp.js" + +describe("Mcp resources", () => { + test("decodes resource catalogs and omits absent metadata", () => { + const value = Schema.decodeUnknownSync(Mcp.ResourceCatalog)({ + resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], + templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }], + }) + + expect(Schema.encodeSync(Mcp.ResourceCatalog)(value)).toEqual({ + resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], + templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }], + }) + }) + + test("preserves text and base64 blob contents", () => { + expect( + Schema.decodeUnknownSync(Mcp.ResourceContent)({ + server: "docs", + uri: "docs://readme", + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }), + ).toEqual({ + server: "docs", + uri: "docs://readme", + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }) + }) +}) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index bcb06f5319..8ebe230791 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,47 +1,23 @@ import { OpenCode } from "@opencode-ai/client/effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { EventV2 } from "@opencode-ai/core/event" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" -import { Project } from "@opencode-ai/core/project" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" -import { Context, Effect, Layer, Scope } from "effect" -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" +import { Context, Effect, Layer, ManagedRuntime } from "effect" +import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" export const create = Effect.fn("OpenCode.create")(function* () { - const scope = yield* Scope.Scope - const memoMap = yield* Layer.makeMemoMap - const sdkPlugins = SdkPlugins.makeStore() - const context = yield* Layer.buildWithMemoMap( - AppNodeBuilder.build(LayerNode.group([EventV2.node, PermissionSaved.node, Project.node, SdkPlugins.node]), [ - [SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)], - ]), - memoMap, - scope, + const runtime = yield* Effect.acquireRelease( + Effect.sync(() => ManagedRuntime.make(createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices)))), + (runtime) => runtime.disposeEffect, ) + const context = yield* runtime.contextEffect const plugins = Context.get(context, SdkPlugins.Service) - const permissions = Context.get(context, PermissionSaved.Service) - const project = Context.get(context, Project.Service) - const web = yield* Effect.acquireRelease( - Effect.sync(() => - HttpRouter.toWebHandler( - createEmbeddedRoutes(sdkPlugins).pipe( - HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), - HttpRouter.provideRequest(Layer.succeed(Project.Service, project)), - Layer.provide(HttpServer.layerServices), - ), - { disableLogger: true, memoMap }, - ), - ), - (web) => Effect.promise(web.dispose), - ) - const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), { + const router = Context.get(context, HttpRouter.HttpRouter) + const handler = HttpEffect.toWebHandler(router.asHttpEffect()) + const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), { preconnect: () => undefined, }) satisfies typeof globalThis.fetch const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.provide(FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch)), Layer.fresh)), ) return { ...client, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 5aa44ccfc1..9622324726 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -105,6 +105,50 @@ it.live( 25_000, ) +it.live( + "preserves SDK plugins across Location eviction", + () => + withEmbedded("opencode-embedded-plugin-eviction-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const ref = location(fixture) + const connected = yield* Latch.make(false) + const booted = yield* Deferred.make() + // The rebooted Location commits its second plugin generation. + const recommitted = yield* Deferred.make() + const generations = yield* Ref.make(0) + const id = `evicted-sdk-${crypto.randomUUID()}` + + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => { + if (event.type === "server.connected") return connected.open + if (event.type !== "plugin.updated" || event.location?.directory !== fixture.directory) return Effect.void + return Ref.updateAndGet(generations, (total) => total + 1).pipe( + Effect.flatMap((total) => { + if (total === 1) return Deferred.succeed(booted, undefined) + if (total === 2) return Deferred.succeed(recommitted, undefined) + return Effect.void + }), + Effect.asVoid, + ) + }), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.plugin({ id, effect: () => Effect.void }) + + yield* opencode.plugin.list({ location: ref }) + yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds")) + yield* opencode.debug.location.evict({ location: ref }) + yield* opencode.plugin.list({ location: ref }) + yield* Deferred.await(recommitted).pipe(Effect.timeout("5 seconds")) + + expect((yield* opencode.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).toContain(id) + }), + ), + 15_000, +) + it.live( "keeps SDK plugin registration isolated between embedded hosts", () => diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 4101e0751f..20a4fcd803 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -134,7 +134,7 @@ if (sessionListTypesPatched === logTypesPatched) { throw new Error("Session list numeric query patch did not apply") } const sessionMessagesTypesPatched = sessionListTypesPatched.replace( - /(export type V2SessionMessagesData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/, + /(export type V2MessageListData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/, "$1number$2", ) if (sessionMessagesTypesPatched === sessionListTypesPatched) { diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index c1956cffe0..bd8f984d7f 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -1,5 +1,9 @@ export * from "./gen/types.gen.js" export type { FileSystemEntry as LocationFileSystemEntry } from "./gen/types.gen.js" +import type { UserMessage } from "./gen/types.gen.js" + +/** @deprecated V1 snapshot compatibility. Use FileDiffInfo for current API responses. */ +export type SnapshotFileDiff = NonNullable["diffs"]>[number] import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e37f5b1cca..564cab6323 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -276,8 +276,10 @@ import type { V2CredentialRemoveResponses, V2CredentialUpdateErrors, V2CredentialUpdateResponses, - V2DebugLocationErrors, - V2DebugLocationResponses, + V2DebugLocationEvictErrors, + V2DebugLocationEvictResponses, + V2DebugLocationListErrors, + V2DebugLocationListResponses, V2EventSubscribeErrors, V2EventSubscribeResponses, V2FormRequestListErrors, @@ -310,6 +312,10 @@ import type { V2LocationGetResponses, V2McpListErrors, V2McpListResponses, + V2McpResourceCatalogErrors, + V2McpResourceCatalogResponses, + V2MessageListErrors, + V2MessageListResponses, V2ModelDefaultErrors, V2ModelDefaultResponses, V2ModelListErrors, @@ -404,8 +410,8 @@ import type { V2SessionLogResponses, V2SessionMessageErrors, V2SessionMessageResponses, - V2SessionMessagesErrors, - V2SessionMessagesResponses, + V2SessionMoveErrors, + V2SessionMoveResponses, V2SessionPermissionCreateErrors, V2SessionPermissionCreateResponses, V2SessionPermissionGetErrors, @@ -6101,6 +6107,45 @@ export class Session3 extends HeyApiClient { }) } + /** + * Move session + * + * Move a session to another project directory, optionally transferring local changes. + */ + public move( + parameters: { + sessionID: string + destination?: { + directory: string + } + moveChanges?: boolean | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "destination" }, + { in: "body", key: "moveChanges" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/move", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send message * @@ -6245,6 +6290,7 @@ export class Session3 extends HeyApiClient { metadata?: { [key: string]: unknown } + resume?: boolean | null }, options?: Options, ) { @@ -6257,6 +6303,7 @@ export class Session3 extends HeyApiClient { { in: "body", key: "text" }, { in: "body", key: "description" }, { in: "body", key: "metadata" }, + { in: "body", key: "resume" }, ], }, ], @@ -6485,40 +6532,6 @@ export class Session3 extends HeyApiClient { }) } - /** - * Get session messages - * - * Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. - */ - public messages( - parameters: { - sessionID: string - limit?: number | null - order?: "asc" | "desc" | null - cursor?: string | null - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "limit" }, - { in: "query", key: "order" }, - { in: "query", key: "cursor" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/session/{sessionID}/message", - ...options, - ...params, - }) - } - private _revert?: Revert get revert(): Revert { return (this._revert ??= new Revert({ client: this.client })) @@ -6545,6 +6558,42 @@ export class Session3 extends HeyApiClient { } } +export class Message extends HeyApiClient { + /** + * Get session messages + * + * Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + */ + public list( + parameters: { + sessionID: string + limit?: number | null + order?: "asc" | "desc" | null + cursor?: string | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", + ...options, + ...params, + }) + } +} + export class Model extends HeyApiClient { /** * List models @@ -6975,6 +7024,34 @@ export class Integration extends HeyApiClient { } } +export class Resource2 extends HeyApiClient { + /** + * List MCP resources + * + * Retrieve resources and resource templates from connected MCP servers. + */ + public catalog( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2McpResourceCatalogResponses, + V2McpResourceCatalogErrors, + ThrowOnError + >({ + url: "/api/mcp/resource", + ...options, + ...params, + }) + } +} + export class Mcp2 extends HeyApiClient { /** * List MCP servers @@ -6997,6 +7074,11 @@ export class Mcp2 extends HeyApiClient { ...params, }) } + + private _resource?: Resource2 + get resource(): Resource2 { + return (this._resource ??= new Resource2({ client: this.client })) + } } export class Credential extends HeyApiClient { @@ -7424,6 +7506,41 @@ export class Event2 extends HeyApiClient { } } +export class Connect2 extends HeyApiClient { + /** + * Create PTY WebSocket token + * + * Create a short-lived single-use ticket for opening a PTY WebSocket connection. + */ + public token( + parameters: { + ptyID: string + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } +} + export class Pty2 extends HeyApiClient { /** * List PTY sessions @@ -7606,39 +7723,6 @@ export class Pty2 extends HeyApiClient { }) } - /** - * Create PTY WebSocket token - * - * Create a short-lived single-use ticket for opening a PTY WebSocket connection. - */ - public connectToken( - parameters: { - ptyID: string - location?: { - directory?: string | null - workspace?: string | null - } | null - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "location" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/api/pty/{ptyID}/connect-token", - ...options, - ...params, - }) - } - /** * Connect to PTY session * @@ -7674,6 +7758,11 @@ export class Pty2 extends HeyApiClient { ...params, }) } + + private _connect?: Connect2 + get connect2(): Connect2 { + return (this._connect ??= new Connect2({ client: this.client })) + } } export class Shell extends HeyApiClient { @@ -8123,20 +8212,53 @@ export class Vcs2 extends HeyApiClient { } } -export class Debug extends HeyApiClient { +export class Location2 extends HeyApiClient { + /** + * Evict a loaded location + * + * Dispose the requested location's cached services so its next use boots them fresh. + */ + public evict( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).delete< + V2DebugLocationEvictResponses, + V2DebugLocationEvictErrors, + ThrowOnError + >({ + url: "/api/debug/location", + ...options, + ...params, + }) + } + /** * List loaded locations * * List locations currently loaded by the server. */ - public location(options?: Options) { - return (options?.client ?? this.client).get({ + public list(options?: Options) { + return (options?.client ?? this.client).get({ url: "/api/debug/location", ...options, }) } } +export class Debug extends HeyApiClient { + private _location?: Location2 + get location(): Location2 { + return (this._location ??= new Location2({ client: this.client })) + } +} + export class Provider3 extends HeyApiClient { /** * Get default search provider @@ -8284,6 +8406,11 @@ export class V2 extends HeyApiClient { return (this._session ??= new Session3({ client: this.client })) } + private _message?: Message + get message(): Message { + return (this._message ??= new Message({ client: this.client })) + } + private _model?: Model get model(): Model { return (this._model ??= new Model({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b4b80f839a..e5dbb3c4e5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -21,6 +21,7 @@ export type Event = | EventSessionModelSelected | EventSessionMoved | EventSessionRenamed + | EventSessionUsageUpdated | EventSessionForked | EventSessionPromptPromoted | EventSessionPromptAdmitted @@ -95,6 +96,7 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged + | EventMcpResourcesChanged | EventMcpStatusChanged | EventCommandExecuted | EventFileEdited @@ -169,14 +171,6 @@ export type MoveSessionError = { } } -export type SnapshotFileDiff = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} - export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -187,59 +181,6 @@ export type PermissionRule = { export type PermissionRuleset = Array -export type Session = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - export type OutputFormatText = { type: "text" } @@ -267,7 +208,7 @@ export type UserMessage = { summary?: { title?: string body?: string - diffs: Array + diffs: Array } agent: string model: { @@ -810,7 +751,7 @@ export type GlobalEvent = { type: "session.created" properties: { sessionID: string - info: Session + info: SessionV1Info } } | { @@ -818,7 +759,7 @@ export type GlobalEvent = { type: "session.updated" properties: { sessionID: string - info: Session + info: SessionV1Info } } | { @@ -895,6 +836,15 @@ export type GlobalEvent = { title: string } } + | { + id: string + type: "session.usage.updated" + properties: { + sessionID: string + cost: MoneyUsd + tokens: TokenUsageInfo + } + } | { id: string type: "session.forked" @@ -977,6 +927,7 @@ export type GlobalEvent = { type: "session.skill.activated" properties: { sessionID: string + id: string name: string text: string } @@ -1021,16 +972,8 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -1042,6 +985,8 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } | { @@ -1172,7 +1117,6 @@ export type GlobalEvent = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -1216,6 +1160,8 @@ export type GlobalEvent = { properties: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } | { @@ -1241,6 +1187,9 @@ export type GlobalEvent = { type: "session.compaction.failed" properties: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } | { @@ -1248,7 +1197,7 @@ export type GlobalEvent = { type: "session.revert.staged" properties: { sessionID: string - revert: RevertState + revert: SessionRevert } } | { @@ -1282,7 +1231,7 @@ export type GlobalEvent = { type: "session.diff" properties: { sessionID: string - diff: Array + diff: Array } } | { @@ -1603,6 +1552,13 @@ export type GlobalEvent = { server: string } } + | { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } + } | { id: string type: "mcp.status.changed" @@ -2358,7 +2314,7 @@ export type GlobalSession = { additions: number deletions: number files: number - diffs?: Array + diffs?: Array } cost?: number tokens?: { @@ -2694,6 +2650,59 @@ export type ProviderAuthError1 = { } } +export type Session = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type NotFoundError = { name: "NotFoundError" data: { @@ -2838,7 +2847,7 @@ export type UnauthorizedError = { } export type SessionsResponse = { - data: Array + data: Array cursor: { previous?: string next?: string @@ -2927,52 +2936,12 @@ export type Shell1 = { } } -export type SessionDurableEvent = - | SessionAgentSelected - | SessionModelSelected - | SessionMoved - | SessionRenamed - | SessionDeleted - | SessionForked - | SessionPromptPromoted - | SessionPromptAdmitted - | SessionExecutionStarted - | SessionExecutionSucceeded - | SessionExecutionFailed - | SessionExecutionInterrupted - | SessionInstructionsUpdated - | SessionSynthetic - | SessionSkillActivated - | SessionShellStarted - | SessionShellEnded - | SessionStepStarted - | SessionStepEnded - | SessionStepFailed - | SessionTextStarted - | SessionTextEnded - | SessionReasoningStarted - | SessionReasoningEnded - | SessionToolInputStarted - | SessionToolInputEnded - | SessionToolCalled - | SessionToolProgress - | SessionToolSuccess - | SessionToolFailed - | SessionRetryScheduled - | SessionCompactionAdmitted - | SessionCompactionStarted - | SessionCompactionEnded - | SessionCompactionFailed - | SessionRevertStaged - | SessionRevertCleared - | SessionRevertCommitted - -export type SessionLogItem = SessionDurableEvent | EventLogSynced +export type SessionLogItem = SessionEventDurable | EventLogSynced export type SessionLogItemStream = string export type SessionMessagesResponse = { - data: Array + data: Array cursor: { previous?: string next?: string @@ -2991,6 +2960,14 @@ export type ProviderNotFoundError = { message: string } +export type McpResource2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + export type FormNotFoundError = { _tag: "FormNotFoundError" id: string @@ -3079,6 +3056,7 @@ export type V2Event = | SessionModelSelected | SessionMoved | SessionRenamed + | SessionUsageUpdated | SessionForked | SessionPromptPromoted | SessionPromptAdmitted @@ -3153,6 +3131,7 @@ export type V2Event = | TuiToastShow | TuiSessionSelect | McpToolsChanged + | McpResourcesChanged | McpStatusChanged | CommandExecuted | FileEdited @@ -3291,12 +3270,73 @@ export type IntegrationRef = { name: string } -export type SkillV2Source = SkillV2DirectorySource | SkillV2UrlSource | SkillV2EmbeddedSource +export type SkillSource = SkillDirectorySource | SkillUrlSource | SkillEmbeddedSource export type MoveSessionDestination = { directory: string } +export type FileDiffLegacyInfo = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +export type SessionV1Info = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type ModelRef = { id: string providerID: string @@ -3308,6 +3348,18 @@ export type LocationRef = { workspaceID?: string } +export type MoneyUsd = number + +export type TokenUsageInfo = { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } +} + export type PromptBase64 = string export type PromptFileSource = @@ -3362,20 +3414,19 @@ export type ToolFileContent = { export type LlmToolContent = ToolTextContent | ToolFileContent -export type FileDiff = { - path: string - status: "added" | "modified" | "deleted" +export type FileDiffInfo = { + file: string + patch: string additions: number deletions: number - patch: string + status: "added" | "deleted" | "modified" } -export type RevertState = { +export type SessionRevert = { messageID: string partID?: string snapshot?: string - diff?: string - files?: Array + files?: Array } export type PermissionV2Source = { @@ -3584,7 +3635,7 @@ export type SyncEventSessionCreated = { aggregateID: string data: { sessionID: string - info: Session + info: SessionV1Info } } } @@ -3599,7 +3650,7 @@ export type SyncEventSessionUpdated = { aggregateID: string data: { sessionID: string - info: Session + info: SessionV1Info } } } @@ -3891,6 +3942,7 @@ export type SyncEventSessionSkillActivated = { aggregateID: string data: { sessionID: string + id: string name: string text: string } @@ -3963,16 +4015,8 @@ export type SyncEventSessionStepEnded = { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -3991,6 +4035,8 @@ export type SyncEventSessionStepFailed = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } } @@ -4154,7 +4200,6 @@ export type SyncEventSessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -4226,6 +4271,8 @@ export type SyncEventSessionCompactionStarted = { data: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } } @@ -4257,6 +4304,9 @@ export type SyncEventSessionCompactionFailed = { aggregateID: string data: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } } @@ -4271,7 +4321,7 @@ export type SyncEventSessionRevertStaged = { aggregateID: string data: { sessionID: string - revert: RevertState + revert: SessionRevert } } } @@ -4370,8 +4420,9 @@ export type PermissionV2Rule = { export type PermissionV2Ruleset = Array -export type AgentV2Info = { +export type AgentInfo = { id: string + name: string model?: ModelRef request: ProviderRequest system?: string @@ -4387,7 +4438,7 @@ export type PluginInfo = { id: string } -export type SessionV2Info = { +export type SessionInfo = { id: string parentID?: string fork?: { @@ -4397,16 +4448,8 @@ export type SessionV2Info = { projectID: string agent?: string model?: ModelRef - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo time: { created: number updated: number @@ -4415,7 +4458,7 @@ export type SessionV2Info = { title: string location: LocationRef subpath?: string - revert?: RevertState + revert?: SessionRevert } export type PromptInputFileAttachment = { @@ -4491,7 +4534,6 @@ export type SessionMessageSynthetic = { time: { created: number } - sessionID: string text: string description?: string type: "synthetic" @@ -4518,6 +4560,7 @@ export type SessionMessageSkill = { created: number } type: "skill" + skill: string name: string text: string } @@ -4532,7 +4575,10 @@ export type SessionMessageShell = { completed?: number } type: "shell" - shell: Shell + shellID: string + command: string + status: "running" | "exited" | "timeout" | "killed" + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" output?: { output: string cursor: number @@ -4556,8 +4602,8 @@ export type SessionMessageAssistantReasoning = { } } -export type SessionMessageToolStatePending = { - status: "pending" +export type SessionMessageToolStateStreaming = { + status: "streaming" input: string } @@ -4577,9 +4623,7 @@ export type SessionMessageToolStateCompleted = { input: { [key: string]: unknown } - attachments?: Array content: Array - outputPaths?: Array structured: { [key: string]: unknown } @@ -4607,7 +4651,7 @@ export type SessionMessageAssistantTool = { providerState?: SessionMessageProviderState providerResultState?: SessionMessageProviderState state: - | SessionMessageToolStatePending + | SessionMessageToolStateStreaming | SessionMessageToolStateRunning | SessionMessageToolStateCompleted | SessionMessageToolStateError @@ -4615,7 +4659,6 @@ export type SessionMessageAssistantTool = { created: number ran?: number completed?: number - pruned?: number } } @@ -4644,26 +4687,14 @@ export type SessionMessageAssistant = { files?: Array } finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost?: MoneyUsd + tokens?: TokenUsageInfo error?: SessionStructuredError retry?: SessionMessageAssistantRetry } -export type SessionMessageCompaction = { +export type SessionMessageCompactionRunning = { type: "compaction" - status: "queued" | "running" | "completed" | "failed" - reason: "auto" | "manual" - summary: string - recent: string id: string metadata?: { [key: string]: unknown @@ -4671,9 +4702,47 @@ export type SessionMessageCompaction = { time: { created: number } + status: "running" + reason: "auto" | "manual" + summary: string + recent: string } -export type SessionMessage = +export type SessionMessageCompactionCompleted = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "completed" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionFailed = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "failed" + reason: "auto" | "manual" + error: SessionStructuredError +} + +export type SessionMessageCompaction = + | SessionMessageCompactionRunning + | SessionMessageCompactionCompleted + | SessionMessageCompactionFailed + +export type SessionMessageInfo = | SessionMessageAgentSelected | SessionMessageModelSelected | SessionMessageUser @@ -4701,7 +4770,7 @@ export type SessionAgentSelected = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4720,7 +4789,7 @@ export type SessionModelSelected = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4739,7 +4808,7 @@ export type SessionMoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4759,7 +4828,7 @@ export type SessionRenamed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4778,7 +4847,7 @@ export type SessionDeleted = { durable: { aggregateID: string seq: number - version: number + version: 2 } location?: LocationRef data: { @@ -4796,7 +4865,7 @@ export type SessionForked = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4816,7 +4885,7 @@ export type SessionPromptPromoted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4835,7 +4904,7 @@ export type SessionPromptAdmitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4856,7 +4925,7 @@ export type SessionExecutionStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4874,7 +4943,7 @@ export type SessionExecutionSucceeded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4892,7 +4961,7 @@ export type SessionExecutionFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4911,7 +4980,7 @@ export type SessionExecutionInterrupted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4930,7 +4999,7 @@ export type SessionInstructionsUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4949,7 +5018,7 @@ export type SessionSynthetic = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4972,11 +5041,12 @@ export type SessionSkillActivated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string + id: string name: string text: string } @@ -4992,7 +5062,7 @@ export type SessionShellStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5011,7 +5081,7 @@ export type SessionShellEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5036,7 +5106,7 @@ export type SessionStepStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5058,23 +5128,15 @@ export type SessionStepEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -5090,13 +5152,15 @@ export type SessionStepFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } @@ -5110,7 +5174,7 @@ export type SessionTextStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5130,7 +5194,7 @@ export type SessionTextEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5151,7 +5215,7 @@ export type SessionReasoningStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5172,7 +5236,7 @@ export type SessionReasoningEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5194,7 +5258,7 @@ export type SessionToolInputStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5215,7 +5279,7 @@ export type SessionToolInputEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5236,7 +5300,7 @@ export type SessionToolCalled = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5261,7 +5325,7 @@ export type SessionToolProgress = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5285,7 +5349,7 @@ export type SessionToolSuccess = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5296,7 +5360,6 @@ export type SessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -5313,7 +5376,7 @@ export type SessionToolFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5337,7 +5400,7 @@ export type SessionRetryScheduled = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5359,7 +5422,7 @@ export type SessionCompactionAdmitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5378,12 +5441,14 @@ export type SessionCompactionStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } @@ -5397,7 +5462,7 @@ export type SessionCompactionEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5418,11 +5483,14 @@ export type SessionCompactionFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } @@ -5436,12 +5504,12 @@ export type SessionRevertStaged = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - revert: RevertState + revert: SessionRevert } } @@ -5455,7 +5523,7 @@ export type SessionRevertCleared = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5473,7 +5541,7 @@ export type SessionRevertCommitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5482,6 +5550,46 @@ export type SessionRevertCommitted = { } } +export type SessionEventDurable = + | SessionAgentSelected + | SessionModelSelected + | SessionMoved + | SessionRenamed + | SessionDeleted + | SessionForked + | SessionPromptPromoted + | SessionPromptAdmitted + | SessionExecutionStarted + | SessionExecutionSucceeded + | SessionExecutionFailed + | SessionExecutionInterrupted + | SessionInstructionsUpdated + | SessionSynthetic + | SessionSkillActivated + | SessionShellStarted + | SessionShellEnded + | SessionStepStarted + | SessionStepEnded + | SessionStepFailed + | SessionTextStarted + | SessionTextEnded + | SessionReasoningStarted + | SessionReasoningEnded + | SessionToolInputStarted + | SessionToolInputEnded + | SessionToolCalled + | SessionToolProgress + | SessionToolSuccess + | SessionToolFailed + | SessionRetryScheduled + | SessionCompactionAdmitted + | SessionCompactionStarted + | SessionCompactionEnded + | SessionCompactionFailed + | SessionRevertStaged + | SessionRevertCleared + | SessionRevertCommitted + export type EventLogSynced = { type: "log.synced" aggregateID: string @@ -5507,20 +5615,22 @@ export type ModelVariant = { } } +export type MoneyUsdPerMillionTokens = number + export type ModelCost = { tier?: { type: "context" size: number } - input: number - output: number + input: MoneyUsdPerMillionTokens + output: MoneyUsdPerMillionTokens cache: { - read: number - write: number + read: MoneyUsdPerMillionTokens + write: MoneyUsdPerMillionTokens } } -export type ModelV2Info = { +export type ModelInfo = { id: string modelID: string providerID: string @@ -5716,6 +5826,19 @@ export type McpServer = { integrationID?: string } +export type McpResourceTemplate = { + server: string + name: string + uriTemplate: string + description?: string + mimeType?: string +} + +export type McpResourceCatalog = { + resources: Array + templates: Array +} + export type ProjectCurrent = { id: string directory: string @@ -5770,7 +5893,7 @@ export type FileSystemEntry = { type: "file" | "directory" } -export type CommandV2Info = { +export type CommandInfo = { name: string template: string description?: string @@ -5779,7 +5902,8 @@ export type CommandV2Info = { subtask?: boolean } -export type SkillV2Info = { +export type SkillInfo = { + id: string name: string description?: string slash?: boolean @@ -5863,12 +5987,12 @@ export type SessionCreated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - info: Session + info: SessionV1Info } } @@ -5882,12 +6006,12 @@ export type SessionUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - info: Session + info: SessionV1Info } } @@ -5901,7 +6025,7 @@ export type MessageUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5920,7 +6044,7 @@ export type MessageRemoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5939,7 +6063,7 @@ export type MessagePartUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5959,7 +6083,7 @@ export type MessagePartRemoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5969,6 +6093,21 @@ export type MessagePartRemoved = { } } +export type SessionUsageUpdated = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.usage.updated" + location?: LocationRef + data: { + sessionID: string + cost: MoneyUsd + tokens: TokenUsageInfo + } +} + export type SessionTextDelta = { id: string created: number @@ -6058,7 +6197,7 @@ export type SessionDiff = { location?: LocationRef data: { sessionID: string - diff: Array + diff: Array } } @@ -6622,6 +6761,19 @@ export type McpToolsChanged = { } } +export type McpResourcesChanged = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRef + data: { + server: string + } +} + export type McpStatusChanged = { id: string created: number @@ -6934,7 +7086,7 @@ export type EventSessionCreated = { type: "session.created" properties: { sessionID: string - info: Session + info: SessionV1Info } } @@ -6943,7 +7095,7 @@ export type EventSessionUpdated = { type: "session.updated" properties: { sessionID: string - info: Session + info: SessionV1Info } } @@ -7030,6 +7182,16 @@ export type EventSessionRenamed = { } } +export type EventSessionUsageUpdated = { + id: string + type: "session.usage.updated" + properties: { + sessionID: string + cost: MoneyUsd + tokens: TokenUsageInfo + } +} + export type EventSessionForked = { id: string type: "session.forked" @@ -7121,6 +7283,7 @@ export type EventSessionSkillActivated = { type: "session.skill.activated" properties: { sessionID: string + id: string name: string text: string } @@ -7169,16 +7332,8 @@ export type EventSessionStepEnded = { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -7191,6 +7346,8 @@ export type EventSessionStepFailed = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } @@ -7333,7 +7490,6 @@ export type EventSessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -7381,6 +7537,8 @@ export type EventSessionCompactionStarted = { properties: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } @@ -7409,6 +7567,9 @@ export type EventSessionCompactionFailed = { type: "session.compaction.failed" properties: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } @@ -7417,7 +7578,7 @@ export type EventSessionRevertStaged = { type: "session.revert.staged" properties: { sessionID: string - revert: RevertState + revert: SessionRevert } } @@ -7455,7 +7616,7 @@ export type EventSessionDiff = { type: "session.diff" properties: { sessionID: string - diff: Array + diff: Array } } @@ -7780,6 +7941,14 @@ export type EventMcpToolsChanged = { } } +export type EventMcpResourcesChanged = { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } +} + export type EventMcpStatusChanged = { id: string type: "mcp.status.changed" @@ -7965,19 +8134,19 @@ export type CredentialKey = { } } -export type SkillV2DirectorySource = { +export type SkillDirectorySource = { type: "directory" path: string } -export type SkillV2UrlSource = { +export type SkillUrlSource = { type: "url" url: string } -export type SkillV2EmbeddedSource = { +export type SkillEmbeddedSource = { type: "embedded" - skill: SkillV2Info + skill: SkillInfo } export type BadRequestError = { @@ -7996,7 +8165,7 @@ export type InvalidRequestErrorV2 = { } export type SessionsResponseV2 = { - data: Array + data: Array cursor: { previous?: string | null next?: string | null @@ -8036,77 +8205,24 @@ export type ShellV2 = { shell: string file: string pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + exit?: number | "NaN" | "Infinity" | "-Infinity" metadata: { [key: string]: unknown } time: { - started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + started: number | "NaN" | "Infinity" | "-Infinity" + completed?: number | "NaN" | "Infinity" | "-Infinity" } } export type SessionMessagesResponseV2 = { - data: Array + data: Array cursor: { previous?: string | null next?: string | null } } -export type SessionV2 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - export type OutputFormatV2 = | { type: "text" @@ -8128,7 +8244,7 @@ export type UserMessageV2 = { summary?: { title?: string | null body?: string | null - diffs: Array + diffs: Array } | null agent: string model: { @@ -8545,6 +8661,7 @@ export type V2EventV2 = | SessionModelSelectedV2 | SessionMovedV2 | SessionRenamedV2 + | SessionUsageUpdatedV2 | SessionDeletedV2 | SessionForkedV2 | SessionPromptPromotedV2 @@ -8617,6 +8734,7 @@ export type V2EventV2 = | InstallationUpdateAvailableV2 | VcsBranchUpdatedV2 | McpStatusChangedV2 + | McpResourcesChangedV2 | PermissionAskedV2 | PermissionRepliedV2 | QuestionAskedV2 @@ -8644,8 +8762,9 @@ export type LocationInfoV2 = { export type AgentColorV2 = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" -export type AgentV2InfoV2 = { +export type AgentInfoV2 = { id: string + name: string model?: ModelRef request: ProviderRequest system?: string @@ -8662,23 +8781,22 @@ export type LocationRefV2 = { workspaceID?: string } -export type FileDiffV2 = { - path: string - status: "added" | "modified" | "deleted" +export type FileDiffInfoV2 = { + file: string + patch: string additions: number deletions: number - patch: string + status: "added" | "deleted" | "modified" } -export type RevertStateV2 = { +export type SessionRevertV2 = { messageID: string partID?: string snapshot?: string - diff?: string - files?: Array + files?: Array } -export type SessionV2InfoV2 = { +export type SessionInfoV2 = { id: string parentID?: string fork?: { @@ -8688,16 +8806,8 @@ export type SessionV2InfoV2 = { projectID: string agent?: string model?: ModelRef - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo time: { created: number updated: number @@ -8706,7 +8816,7 @@ export type SessionV2InfoV2 = { title: string location: LocationRefV2 subpath?: string - revert?: RevertStateV2 + revert?: SessionRevertV2 } export type PromptBase64V2 = string @@ -8777,7 +8887,6 @@ export type SessionMessageSyntheticV2 = { time: { created: number } - sessionID: string text: string description?: string type: "synthetic" @@ -8804,6 +8913,7 @@ export type SessionMessageSkillV2 = { created: number } type: "skill" + skill: string name: string text: string } @@ -8818,7 +8928,10 @@ export type SessionMessageShellV2 = { completed?: number } type: "shell" - shell: ShellV2 + shellID: string + command: string + status: "running" | "exited" | "timeout" | "killed" + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" output?: { output: string cursor: number @@ -8852,26 +8965,14 @@ export type SessionMessageAssistantV2 = { files?: Array } finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost?: MoneyUsd + tokens?: TokenUsageInfo error?: SessionStructuredError retry?: SessionMessageAssistantRetryV2 } -export type SessionMessageCompactionV2 = { +export type SessionMessageCompactionRunningV2 = { type: "compaction" - status: "queued" | "running" | "completed" | "failed" - reason: "auto" | "manual" - summary: string - recent: string id: string metadata?: { [key: string]: unknown @@ -8879,6 +8980,39 @@ export type SessionMessageCompactionV2 = { time: { created: number } + status: "running" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionCompletedV2 = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "completed" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionFailedV2 = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "failed" + reason: "auto" | "manual" + error: SessionStructuredError } /** @@ -8896,7 +9030,7 @@ export type SessionAgentSelectedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -8915,7 +9049,7 @@ export type SessionModelSelectedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -8934,7 +9068,7 @@ export type SessionMovedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -8954,7 +9088,7 @@ export type SessionRenamedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -8973,7 +9107,7 @@ export type SessionDeletedV2 = { durable: { aggregateID: string seq: number - version: number + version: 2 } location?: LocationRefV2 data: { @@ -8991,7 +9125,7 @@ export type SessionForkedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9011,7 +9145,7 @@ export type SessionPromptPromotedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9030,7 +9164,7 @@ export type SessionPromptAdmittedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9051,7 +9185,7 @@ export type SessionExecutionStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9069,7 +9203,7 @@ export type SessionExecutionSucceededV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9087,7 +9221,7 @@ export type SessionExecutionFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9106,7 +9240,7 @@ export type SessionExecutionInterruptedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9125,7 +9259,7 @@ export type SessionInstructionsUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9144,7 +9278,7 @@ export type SessionSyntheticV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9167,11 +9301,12 @@ export type SessionSkillActivatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string + id: string name: string text: string } @@ -9187,7 +9322,7 @@ export type SessionShellStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9206,7 +9341,7 @@ export type SessionShellEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9231,7 +9366,7 @@ export type SessionStepStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9253,23 +9388,15 @@ export type SessionStepEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -9285,13 +9412,15 @@ export type SessionStepFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } @@ -9305,7 +9434,7 @@ export type SessionTextStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9325,7 +9454,7 @@ export type SessionTextEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9350,7 +9479,7 @@ export type SessionReasoningStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9375,7 +9504,7 @@ export type SessionReasoningEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9397,7 +9526,7 @@ export type SessionToolInputStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9418,7 +9547,7 @@ export type SessionToolInputEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9443,7 +9572,7 @@ export type SessionToolCalledV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9468,7 +9597,7 @@ export type SessionToolProgressV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9496,7 +9625,7 @@ export type SessionToolSuccessV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9507,7 +9636,6 @@ export type SessionToolSuccessV2 = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState6 @@ -9528,7 +9656,7 @@ export type SessionToolFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9552,7 +9680,7 @@ export type SessionRetryScheduledV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9574,7 +9702,7 @@ export type SessionCompactionAdmittedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9593,12 +9721,14 @@ export type SessionCompactionStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } @@ -9612,7 +9742,7 @@ export type SessionCompactionEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9633,11 +9763,14 @@ export type SessionCompactionFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } @@ -9651,12 +9784,12 @@ export type SessionRevertStagedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - revert: RevertStateV2 + revert: SessionRevertV2 } } @@ -9670,7 +9803,7 @@ export type SessionRevertClearedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9688,7 +9821,7 @@ export type SessionRevertCommittedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9706,6 +9839,19 @@ export type EventLogSyncedV2 = { seq?: number } +export type McpResourceV2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + +export type McpResourceCatalogV2 = { + resources: Array + templates: Array +} + export type ProjectTimeV2 = { created: number updated: number @@ -9890,6 +10036,59 @@ export type AgentUpdatedV2 = { | Array } +export type SessionV1InfoV2 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type SessionCreatedV2 = { id: string created: number @@ -9900,12 +10099,12 @@ export type SessionCreatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - info: SessionV2 + info: SessionV1InfoV2 } } @@ -9919,12 +10118,12 @@ export type SessionUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - info: SessionV2 + info: SessionV1InfoV2 } } @@ -9938,12 +10137,12 @@ export type SessionDeleted1 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - info: SessionV2 + info: SessionV1InfoV2 } } @@ -9957,7 +10156,7 @@ export type MessageUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9976,7 +10175,7 @@ export type MessageRemovedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9995,7 +10194,7 @@ export type MessagePartUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -10015,7 +10214,7 @@ export type MessagePartRemovedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -10025,6 +10224,21 @@ export type MessagePartRemovedV2 = { } } +export type SessionUsageUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.usage.updated" + location?: LocationRefV2 + data: { + sessionID: string + cost: MoneyUsd + tokens: TokenUsageInfo + } +} + export type SessionTextDeltaV2 = { id: string created: number @@ -10653,6 +10867,19 @@ export type McpStatusChangedV2 = { } } +export type McpResourcesChangedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRefV2 + data: { + server: string + } +} + export type PermissionAskedV2 = { id: string created: number @@ -13452,7 +13679,7 @@ export type SessionDiffResponses = { /** * Successfully retrieved diff */ - 200: Array + 200: Array } export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] @@ -15030,7 +15257,7 @@ export type V2AgentListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -15148,7 +15375,7 @@ export type V2SessionCreateResponses = { * Success */ 200: { - data: SessionV2InfoV2 + data: SessionInfoV2 } } @@ -15253,7 +15480,7 @@ export type V2SessionGetResponses = { * Success */ 200: { - data: SessionV2InfoV2 + data: SessionInfoV2 } } @@ -15292,7 +15519,7 @@ export type V2SessionForkResponses = { * Success */ 200: { - data: SessionV2InfoV2 + data: SessionInfoV2 } } @@ -15409,6 +15636,46 @@ export type V2SessionRenameResponses = { export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses] +export type V2SessionMoveData = { + body: { + destination: { + directory: string + } + moveChanges?: boolean | null + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/move" +} + +export type V2SessionMoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionMoveError = V2SessionMoveErrors[keyof V2SessionMoveErrors] + +export type V2SessionMoveResponses = { + /** + * + */ + 204: void +} + +export type V2SessionMoveResponse = V2SessionMoveResponses[keyof V2SessionMoveResponses] + export type V2SessionPromptData = { body: { id?: string | null @@ -15556,6 +15823,7 @@ export type V2SessionSyntheticData = { metadata?: { [key: string]: unknown } + resume?: boolean | null } path: { sessionID: string @@ -15752,7 +16020,7 @@ export type V2SessionRevertStageResponses = { * Success */ 200: { - data: RevertStateV2 + data: SessionRevertV2 } } @@ -15875,7 +16143,7 @@ export type V2SessionContextResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -16142,13 +16410,13 @@ export type V2SessionMessageResponses = { * Success */ 200: { - data: SessionMessage + data: SessionMessageInfo } } export type V2SessionMessageResponse = V2SessionMessageResponses[keyof V2SessionMessageResponses] -export type V2SessionMessagesData = { +export type V2MessageListData = { body?: never path: { sessionID: string @@ -16167,7 +16435,7 @@ export type V2SessionMessagesData = { url: "/api/session/{sessionID}/message" } -export type V2SessionMessagesErrors = { +export type V2MessageListErrors = { /** * InvalidCursorError | InvalidRequestError */ @@ -16186,16 +16454,16 @@ export type V2SessionMessagesErrors = { 500: UnknownErrorV2 } -export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] +export type V2MessageListError = V2MessageListErrors[keyof V2MessageListErrors] -export type V2SessionMessagesResponses = { +export type V2MessageListResponses = { /** * SessionMessagesResponse */ 200: SessionMessagesResponseV2 } -export type V2SessionMessagesResponse = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] +export type V2MessageListResponse = V2MessageListResponses[keyof V2MessageListResponses] export type V2ModelListData = { body?: never @@ -16232,7 +16500,7 @@ export type V2ModelListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -16273,7 +16541,7 @@ export type V2ModelDefaultResponses = { */ 200: { location: LocationInfoV2 - data: ModelV2Info | null + data: ModelInfo | null } } @@ -16723,6 +16991,43 @@ export type V2McpListResponses = { export type V2McpListResponse = V2McpListResponses[keyof V2McpListResponses] +export type V2McpResourceCatalogData = { + body?: never + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/mcp/resource" +} + +export type V2McpResourceCatalogErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2McpResourceCatalogError = V2McpResourceCatalogErrors[keyof V2McpResourceCatalogErrors] + +export type V2McpResourceCatalogResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: McpResourceCatalogV2 + } +} + +export type V2McpResourceCatalogResponse = V2McpResourceCatalogResponses[keyof V2McpResourceCatalogResponses] + export type V2CredentialRemoveData = { body?: never path: { @@ -17577,7 +17882,7 @@ export type V2CommandListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -17614,7 +17919,7 @@ export type V2SkillListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -18581,20 +18886,25 @@ export type V2VcsDiffResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } export type V2VcsDiffResponse = V2VcsDiffResponses[keyof V2VcsDiffResponses] -export type V2DebugLocationData = { +export type V2DebugLocationEvictData = { body?: never path?: never - query?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } url: "/api/debug/location" } -export type V2DebugLocationErrors = { +export type V2DebugLocationEvictErrors = { /** * InvalidRequestError */ @@ -18605,16 +18915,45 @@ export type V2DebugLocationErrors = { 401: UnauthorizedError } -export type V2DebugLocationError = V2DebugLocationErrors[keyof V2DebugLocationErrors] +export type V2DebugLocationEvictError = V2DebugLocationEvictErrors[keyof V2DebugLocationEvictErrors] -export type V2DebugLocationResponses = { +export type V2DebugLocationEvictResponses = { + /** + * + */ + 204: void +} + +export type V2DebugLocationEvictResponse = V2DebugLocationEvictResponses[keyof V2DebugLocationEvictResponses] + +export type V2DebugLocationListData = { + body?: never + path?: never + query?: never + url: "/api/debug/location" +} + +export type V2DebugLocationListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2DebugLocationListError = V2DebugLocationListErrors[keyof V2DebugLocationListErrors] + +export type V2DebugLocationListResponses = { /** * Success */ 200: Array } -export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses] +export type V2DebugLocationListResponse = V2DebugLocationListResponses[keyof V2DebugLocationListResponses] export type V2SearchProviderGetData = { body?: never diff --git a/packages/server/src/handlers/debug.ts b/packages/server/src/handlers/debug.ts index f5ffd536e4..ec797c0032 100644 --- a/packages/server/src/handlers/debug.ts +++ b/packages/server/src/handlers/debug.ts @@ -2,13 +2,24 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { Effect, Option, RcMap } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { requestRef } from "../location" export const DebugHandler = HttpApiBuilder.group(Api, "server.debug", (handlers) => - handlers.handle( - "debug.location", - Effect.fn(function* () { - const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) - return Array.from(yield* RcMap.keys(locations.rcMap)) - }), - ), + handlers + .handle( + "debug.location", + Effect.fn(function* () { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + return Array.from(yield* RcMap.keys(locations.rcMap)) + }), + ) + .handle( + "debug.location.evict", + Effect.fn(function* (ctx) { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + // Resolve through requestRef so the key matches the shape the location + // middleware cached the services under. + yield* locations.invalidate(requestRef(ctx.request)) + }), + ), ) diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts index 4d702f24c9..8f423534d4 100644 --- a/packages/server/src/handlers/mcp.ts +++ b/packages/server/src/handlers/mcp.ts @@ -6,20 +6,28 @@ import { response } from "../location" export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => Effect.gen(function* () { - return handlers.handle( - "mcp.list", - Effect.fn(function* () { - const service = yield* MCP.Service - return yield* response( - service - .servers() - .pipe( - Effect.map((servers) => - servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })), + return handlers + .handle( + "mcp.list", + Effect.fn(function* () { + const service = yield* MCP.Service + return yield* response( + service + .servers() + .pipe( + Effect.map((servers) => + servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })), + ), ), - ), - ) - }), - ) + ) + }), + ) + .handle( + "mcp.resource.catalog", + Effect.fn(function* () { + const service = yield* MCP.Service + return yield* response(service.resourceCatalog()) + }), + ) }), ) diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index 93734c628d..40e2ba1077 100644 --- a/packages/server/src/handlers/message.ts +++ b/packages/server/src/handlers/message.ts @@ -16,7 +16,7 @@ const Cursor = Schema.Struct({ const decodeCursor = Schema.decodeUnknownSync(Cursor) const cursor = { - encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") { + encode(message: SessionMessage.Info, order: "asc" | "desc", direction: "previous" | "next") { return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url") }, decode(input: string) { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index ce3bc15f36..e3160cc231 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,5 +1,6 @@ import { SessionV2 } from "@opencode-ai/core/session" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" @@ -8,8 +9,8 @@ import { ConflictError, CommandEvaluationError, CommandNotFoundError, - InvalidCursorError, InvalidRequestError, + InvalidCursorError, MessageNotFoundError, ServiceUnavailableError, SessionBusyError, @@ -24,6 +25,7 @@ const DefaultSessionsLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service + const moveSession = yield* MoveSession.Service return handlers .handle( @@ -201,6 +203,43 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.move", + Effect.fn(function* (ctx) { + yield* moveSession.moveSession({ + sessionID: ctx.params.sessionID, + destination: ctx.payload.destination, + moveChanges: ctx.payload.moveChanges, + }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("MoveSession.DestinationProjectMismatchError", () => + Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })), + ), + Effect.catchTag("MoveSession.ApplyChangesError", () => + Effect.fail( + new InvalidRequestError({ + message: + "Unable to apply your changes in the destination directory. The files may conflict with existing changes.", + }), + ), + ), + Effect.catchTag("MoveSession.CaptureChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + Effect.catchTag("MoveSession.ResetSourceChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.prompt", Effect.fn(function* (ctx) { @@ -329,6 +368,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl text: ctx.payload.text, description: ctx.payload.description, metadata: ctx.payload.metadata, + resume: ctx.payload.resume, }) .pipe( Effect.catchTag("Session.NotFoundError", (error) => diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index de6f4b328f..971d77734b 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,14 +1,9 @@ export * as ServerProcess from "./process" import { NodeHttpClient, NodeHttpServer } from "@effect/platform-node" -import { Credential } from "@opencode-ai/core/credential" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { Project } from "@opencode-ai/core/project" import { HealthGroup } from "@opencode-ai/protocol/groups/health" import { Context, Effect, Layer, Option } from "effect" -import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" import { createServer } from "node:http" import { ServerAuth } from "./auth" @@ -53,9 +48,11 @@ function listen(options: Options) { function bind(hostname: string, port: number, password: string) { const server = createServer() return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( + createRoutes(password).pipe( + Layer.flatMap((context) => + HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger), + ), Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), - Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), ), ).pipe( Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 1c5a57f62f..773b01ad2f 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -8,6 +8,7 @@ import { Observability } from "@opencode-ai/core/observability" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -19,7 +20,7 @@ import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Effect, Layer, Option } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { Api } from "./api" import { ServerAuth } from "./auth" import { handlers } from "./handlers" @@ -37,9 +38,11 @@ const applicationServices = LayerNode.group([ httpClient, ToolOutputStore.cleanupNode, Job.node, + MoveSession.node, Project.node, SessionV2.node, PluginRuntime.providerNode, + SdkPlugins.node, PermissionSaved.node, PtyTicket.node, Credential.node, @@ -55,26 +58,22 @@ export function createRoutes(password?: string) { ) } -export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) { - return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins) +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) } -function makeRoutes( - auth: Layer.Layer, - sdkPlugins?: SdkPlugins.Store, -) { +function makeRoutes(auth: Layer.Layer) { const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ [SessionExecution.node, SessionExecutionLocal.node], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], - ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), ] const serviceLayer = simulateEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements, startDriveServer } = yield* Effect.promise(() => - import("@opencode-ai/simulation/backend"), + const { simulationReplacements, startDriveServer } = yield* Effect.promise( + () => import("@opencode-ai/simulation/backend"), ) if (driveEnabled()) startDriveServer() return AppNodeBuilder.build(applicationServices, [ @@ -85,16 +84,24 @@ function makeRoutes( ) : AppNodeBuilder.build(applicationServices, replacements) - return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( - Layer.provide(handlers.pipe(Layer.provide(serviceLayer))), - Layer.provide(formLocationLayer), - Layer.provide(sessionLocationLayer), - Layer.provide(layer), - Layer.provide(authorizationLayer), - Layer.provide(schemaErrorLayer), - Layer.provide(auth), - Layer.provide(serviceLayer), - Layer.provide(Observability.layer), + return serviceLayer.pipe( + Layer.flatMap((context) => { + const services = Layer.succeedContext(context) + const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)) + return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( + Layer.provide(handlers.pipe(Layer.provide(services))), + Layer.provide(formLocationLayer), + Layer.provide(sessionLocationLayer), + Layer.provide(layer), + Layer.provide(authorizationLayer), + Layer.provide(schemaErrorLayer), + Layer.provide(auth), + Layer.provide(Observability.layer), + HttpRouter.provideRequest(requestServices), + Layer.provideMerge(services), + Layer.provideMerge(HttpRouter.layer), + ) + }), ) } @@ -108,5 +115,4 @@ function driveEnabled() { export const routes = createRoutes() -export const webHandler = () => - HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) +export const webHandler = () => HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 0e5dec34b2..f147cd7388 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -536,6 +536,7 @@ export function getToolInfo( title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } + case "patch": case "apply_patch": return { icon: "code-lines", @@ -728,7 +729,7 @@ export function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit } export function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -1449,7 +1450,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { - return state[name]?.render + return state[name === "apply_patch" ? "patch" : name]?.render } export const ToolRegistry = { @@ -2272,7 +2273,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "apply_patch", + name: "patch", render(props) { const i18n = useI18n() const fileComponent = useFileComponent() diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 48e8eee310..6fccfc305d 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -1,6 +1,6 @@ import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parsePatch } from "diff" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" type LegacyDiff = { file: string @@ -12,8 +12,7 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type SnapshotDiff = SnapshotFileDiff & { file: string } -type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff | LegacyDiff export type DiffSource = Pick export type ViewDiff = { diff --git a/packages/session-ui/src/components/session-review.tsx b/packages/session-ui/src/components/session-review.tsx index 8db21f025b..ec94af2f2d 100644 --- a/packages/session-ui/src/components/session-review.tsx +++ b/packages/session-ui/src/components/session-review.tsx @@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { checksum } from "@opencode-ai/core/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" +import { type FileContent, type FileDiffInfo, type VcsFileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" @@ -62,14 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { +type RawReviewDiff = (FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { +type ReviewDiff = (FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } - function diff(value: unknown): value is ReviewDiff { if (!value || typeof value !== "object" || Array.isArray(value)) return false if (!("file" in value) || typeof value.file !== "string") return false @@ -185,7 +183,7 @@ export const SessionReview = (props: SessionReviewProps) => { const itemsMap = createMemo(() => Object.fromEntries(list(props.diffs).map((diff) => [diff.file, { ...normalize(diff), preloaded: diff.preloaded }])), ) - const files = createMemo(() => props.diffs.map((diff) => diff.file!)) + const files = createMemo(() => props.diffs.map((diff) => diff.file)) const grouped = createMemo(() => { const next = new Map() for (const comment of props.comments ?? []) { diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 4ebf6459b1..cda95c0e8e 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -1,8 +1,9 @@ import { AssistantMessage, - type SnapshotFileDiff, + type FileDiffInfo, Message as MessageType, Part as PartType, + type UserMessage, } from "@opencode-ai/sdk/v2/client" import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" @@ -90,10 +91,17 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -type SummaryDiff = SnapshotFileDiff & { file: string } +type SummaryDiffInput = NonNullable["diffs"]>[number] +type SummaryDiff = FileDiffInfo -function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { - return typeof value.file === "string" +function summaryDiff(value: SummaryDiffInput): value is SummaryDiff { + return ( + typeof value.file === "string" && + typeof value.patch === "string" && + typeof value.additions === "number" && + typeof value.deletions === "number" && + value.status !== undefined + ) } const hidden = new Set(["todowrite"]) diff --git a/packages/session-ui/src/components/tool-error-card.stories.tsx b/packages/session-ui/src/components/tool-error-card.stories.tsx index dd60075812..90348b2a95 100644 --- a/packages/session-ui/src/components/tool-error-card.stories.tsx +++ b/packages/session-ui/src/components/tool-error-card.stories.tsx @@ -5,7 +5,7 @@ const docs = `### Overview Tool call failure summary styled like a tool trigger. ### API -- Required: \`tool\` (tool id, e.g. apply_patch, bash) +- Required: \`tool\` (tool id, e.g. patch, bash) - Required: \`error\` (error string) ### Behavior @@ -14,9 +14,9 @@ Tool call failure summary styled like a tool trigger. const samples = [ { - tool: "apply_patch", + tool: "patch", error: - "apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", + "patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", }, { tool: "bash", @@ -62,13 +62,13 @@ export default { }, }, args: { - tool: "apply_patch", + tool: "patch", error: samples[0].error, }, argTypes: { tool: { control: "select", - options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"], + options: ["patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"], }, error: { control: "text", diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 35720a2753..aaedded025 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -51,6 +51,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", bash: "ui.tool.shell", + patch: "ui.tool.patch", apply_patch: "ui.tool.patch", question: "ui.tool.questions", } diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 999ff510d5..d505249931 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,4 +1,4 @@ -import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, Message, Part, Provider, Session, SessionStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -21,7 +21,7 @@ type Data = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index 3e7bd83051..c05fe3a745 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -6,7 +6,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file" import { useI18n } from "@opencode-ai/ui/context/i18n" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" -import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileContent, FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -27,7 +27,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import "./session-review-v2.css" -type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff export type SessionReviewFilePreviewV2Props = { file: string diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 29613ff28a..a98b6f3289 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -16,6 +16,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opentui/core": "catalog:", diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts index e52c8296f6..eaacc162c1 100644 --- a/packages/simulation/src/backend/control.ts +++ b/packages/simulation/src/backend/control.ts @@ -1,7 +1,6 @@ import { Effect } from "effect" import { SimulationProtocol } from "../protocol" import { SimulationLLMExchange } from "./llm-exchange" -import { SimulationNetwork } from "./network" /** * Backend-hosted simulation control WebSocket. @@ -19,16 +18,15 @@ import { SimulationNetwork } from "./network" * - `llm.finish` { id, reason? } finish an exchange * - `llm.disconnect` { id } abruptly terminate an exchange without a finish * - `llm.pending` list open exchanges - * - `network.log` simulated network request log */ type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> function parseRequest(input: string | Buffer) { - return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) + return SimulationProtocol.Backend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise { +async function handle(socket: ControlSocket, request: SimulationProtocol.Backend.Request): Promise { switch (request.method) { case "llm.attach": { socket.data.unsubscribe?.() @@ -38,31 +36,27 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc return { attached: true } } case "llm.chunk": { - const params = await SimulationProtocol.Backend.decodeChunkParams(request.params) await Effect.runPromise( SimulationLLMExchange.push( - params.id, - params.items.map((item) => ({ type: "item", item }) as const), + request.params.id, + request.params.items.map((item) => ({ type: "item", item }) as const), ), ) return { ok: true } } case "llm.finish": { - const params = await SimulationProtocol.Backend.decodeFinishParams(request.params) - await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }])) + await Effect.runPromise( + SimulationLLMExchange.push(request.params.id, [{ type: "finish", reason: request.params.reason }]), + ) return { ok: true } } case "llm.disconnect": { - const params = await SimulationProtocol.Backend.decodeDisconnectParams(request.params) - await Effect.runPromise(SimulationLLMExchange.disconnect(params.id)) + await Effect.runPromise(SimulationLLMExchange.disconnect(request.params.id)) return { ok: true } } case "llm.pending": return { exchanges: SimulationLLMExchange.pending() } - case "network.log": - return { entries: SimulationNetwork.log() } } - throw new Error(`Unknown simulation control method: ${request.method}`) } export function start(endpoint: string) { @@ -79,7 +73,7 @@ export function start(endpoint: string) { socket.data.unsubscribe?.() }, async message(socket, message) { - let request: SimulationProtocol.JsonRpc.Request | undefined + let request: SimulationProtocol.Backend.Request | undefined try { request = parseRequest(message) const result = await handle(socket, request) diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts index 97b7b71b1f..638f80c1d0 100644 --- a/packages/simulation/src/backend/llm-exchange.ts +++ b/packages/simulation/src/backend/llm-exchange.ts @@ -18,7 +18,13 @@ import { Effect, Queue } from "effect" export type Item = | { readonly type: "textDelta"; readonly text: string } | { readonly type: "reasoningDelta"; readonly text: string } - | { readonly type: "toolCall"; readonly id: string; readonly name: string; readonly input: unknown } + | { + readonly type: "toolCall" + readonly index: number + readonly id: string + readonly name: string + readonly input: unknown + } | { readonly type: "raw"; readonly chunk: unknown } export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter" diff --git a/packages/simulation/src/backend/openai.ts b/packages/simulation/src/backend/openai.ts index c13e7772ef..fb58a44724 100644 --- a/packages/simulation/src/backend/openai.ts +++ b/packages/simulation/src/backend/openai.ts @@ -30,7 +30,11 @@ function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { { delta: { tool_calls: [ - { index: 0, id: item.id, function: { name: item.name, arguments: JSON.stringify(item.input) } }, + { + index: item.index, + id: item.id, + function: { name: item.name, arguments: JSON.stringify(item.input) }, + }, ], }, }, diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index d03e4a5f75..4b03cb6939 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,8 +1,11 @@ -import type { CliRenderer, Renderable } from "@opentui/core" +import { mkdtemp } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { CapturedFrame, CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" import type { SimulationProtocol } from "../protocol" import { SimulationRenderer } from "./renderer" -import { SimulationTrace } from "./trace" +import { SimulationPng } from "./png" export type Action = SimulationProtocol.Frontend.Action export type Element = SimulationProtocol.Frontend.Element @@ -47,7 +50,7 @@ function hit(renderer: CliRenderer, renderable: Renderable) { /** * Builds the harness the simulation server drives. * - * When the renderer is the fake simulation renderer, its TestRendererSetup + * When the renderer is the headless simulation renderer, its TestRendererSetup * provides the supported testing APIs. For the visible terminal renderer the * harness falls back to `requestRender` + `idle` and reading the private * `currentRenderBuffer`. @@ -91,57 +94,95 @@ export function elements(renderer: CliRenderer): Element[] { .filter((element) => element.focusable || element.clickable || element.editor) } -export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] { - const items = elements(renderer) - return [ - ...(renderer.currentFocusedEditor - ? ([{ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" }] satisfies Action[]) - : []), - ...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })), - ...items - .filter((item) => item.clickable) - .map((item) => ({ - type: "click" as const, - target: item.num, - x: Math.floor(item.x + item.width / 2), - y: Math.floor(item.y + item.height / 2), - })), - { type: "pressArrow", direction: "down" }, - { type: "pressArrow", direction: "up" }, - ] -} - export function state(harness: Harness) { return { - screen: harness.screen(), focused: { renderable: harness.renderer.currentFocusedRenderable?.num, editor: Boolean(harness.renderer.currentFocusedEditor), }, elements: elements(harness.renderer), - actions: actions(harness.renderer), } } +export async function screenshot(harness: Harness) { + await harness.renderOnce() + const image = SimulationPng.screenshot(harness.renderer) + const path = join(await mkdtemp(join(tmpdir(), "opencode-drive-")), "screenshot.png") + await Bun.write(path, image.data) + return path +} + +export function frame(harness: Harness): CapturedFrame { + const buffer = harness.renderer.currentRenderBuffer + return { + cols: buffer.width, + rows: buffer.height, + cursor: [0, 0], + lines: buffer.getSpanLines().map((line) => ({ + spans: line.spans.map((span) => ({ + text: span.text, + fg: span.fg, + bg: span.bg, + attributes: span.attributes, + width: span.width, + })), + })), + } +} + +export async function video(frames: CapturedFrame[]) { + const directory = await mkdtemp(join(tmpdir(), "opencode-drive-recording-")) + await Promise.all( + frames.map((frame, index) => + Bun.write( + join(directory, `frame-${index.toString().padStart(6, "0")}.png`), + SimulationPng.screenshotFrame(frame).data, + ), + ), + ) + const path = join(directory, "recording.mp4") + const process = Bun.spawn( + [ + "ffmpeg", + "-loglevel", + "error", + "-framerate", + "10", + "-i", + join(directory, "frame-%06d.png"), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + "-y", + path, + ], + { stderr: "pipe" }, + ) + if ((await process.exited) !== 0) throw new Error(`ffmpeg failed: ${await new Response(process.stderr).text()}`) + return path +} + export async function execute(harness: Harness, action: Action) { - SimulationTrace.add("ui.action", { action }) switch (action.type) { - case "typeText": + case "ui.type": await harness.mockInput.typeText(action.text) break - case "pressKey": + case "ui.press": harness.mockInput.pressKey(action.key, action.modifiers) break - case "pressEnter": + case "ui.enter": harness.mockInput.pressEnter() break - case "pressArrow": + case "ui.arrow": harness.mockInput.pressArrow(action.direction) break - case "focus": + case "ui.focus": all(harness.renderer.root).find((item) => item.num === action.target)?.focus() break - case "click": + case "ui.click": await harness.mockMouse.click(action.x, action.y) break } diff --git a/packages/simulation/src/frontend/png.ts b/packages/simulation/src/frontend/png.ts new file mode 100644 index 0000000000..137a09e1be --- /dev/null +++ b/packages/simulation/src/frontend/png.ts @@ -0,0 +1,84 @@ +import { fileURLToPath } from "node:url" +import { GlobalFonts, createCanvas } from "@napi-rs/canvas" +import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core" + +const CellWidth = 10 +const CellHeight = 20 +const FontSize = 16 +const FontFamily = "OpenCode Screenshot" + +GlobalFonts.registerFromPath( + fileURLToPath(new URL("../../../ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Regular.woff2", import.meta.url)), + FontFamily, +) + +export function screenshot(renderer: CliRenderer) { + return screenshotFrame({ + cols: renderer.currentRenderBuffer.width, + rows: renderer.currentRenderBuffer.height, + cursor: [0, 0], + lines: renderer.currentRenderBuffer.getSpanLines(), + }) +} + +export function screenshotFrame(frame: CapturedFrame) { + const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight) + const context = canvas.getContext("2d") + context.fillStyle = "#080808" + context.fillRect(0, 0, canvas.width, canvas.height) + context.textBaseline = "top" + + frame.lines.forEach((line, row) => { + let column = 0 + line.spans.forEach((span) => { + const attributes = span.attributes & 0xff + const inverse = Boolean(attributes & TextAttributes.INVERSE) + const hidden = Boolean(attributes & TextAttributes.HIDDEN) + const foreground = inverse ? span.bg : span.fg + const background = inverse ? span.fg : span.bg + const chars = [...span.text] + let remaining = span.width + + chars.forEach((char, index) => { + const cells = Math.max(1, remaining - (chars.length - index - 1)) + if (background.a) { + context.fillStyle = color(background) + context.fillRect(column * CellWidth, row * CellHeight, cells * CellWidth, CellHeight) + } + if (!hidden && char.codePointAt(0) !== 0x0a00) { + context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1) + context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"` + context.fillText(char, column * CellWidth, row * CellHeight + 1) + if (attributes & TextAttributes.UNDERLINE) { + context.fillRect(column * CellWidth, row * CellHeight + 17, cells * CellWidth, 1) + } + if (attributes & TextAttributes.STRIKETHROUGH) { + context.fillRect(column * CellWidth, row * CellHeight + 10, cells * CellWidth, 1) + } + } + column += cells + remaining -= cells + }) + while (remaining-- > 0) { + if (background.a) { + context.fillStyle = color(background) + context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight) + } + column++ + } + }) + }) + + return { + width: canvas.width, + height: canvas.height, + data: canvas.toBuffer("image/png"), + } +} + +function color(value: RGBA, opacity = 1) { + const [red, green, blue, alpha] = value.toInts() + return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})` +} + +export * as SimulationPng from "./png" diff --git a/packages/simulation/src/frontend/renderer.ts b/packages/simulation/src/frontend/renderer.ts index 168a8999b6..e973d57a98 100644 --- a/packages/simulation/src/frontend/renderer.ts +++ b/packages/simulation/src/frontend/renderer.ts @@ -4,7 +4,7 @@ import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testin const setups = new WeakMap() /** - * Creates the fake simulation renderer: a real CliRenderer backed by an + * Creates the headless simulation renderer: a real CliRenderer backed by an * in-memory screen buffer instead of a terminal. The TestRendererSetup is * kept module-side (keyed by renderer) so the harness can use the supported * testing APIs without app code carrying it around. diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index 854b5e9846..4edd687887 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,67 +1,100 @@ +import type { CapturedFrame } from "@opentui/core" import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" -import { SimulationTrace } from "./trace" export interface Server { readonly url: string readonly stop: () => void } -function actionParam(params: unknown) { - return SimulationProtocol.Frontend.decodeActionParams(params).action -} - function parseRequest(input: string | Buffer) { - return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) + return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) { +interface Recording { + readonly frames: CapturedFrame[] + readonly timer: ReturnType + pending: Promise +} + +async function handle( + harness: Harness, + request: SimulationProtocol.Frontend.Request, + recording: { current?: Recording }, + headless: boolean, +) { switch (request.method) { + case "ui.screenshot": + return SimulationActions.screenshot(harness) case "ui.state": { - const result = SimulationActions.state(harness) - SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length }) - return result + return SimulationActions.state(harness) } - case "ui.action": - return SimulationActions.execute(harness, actionParam(request.params)) - case "ui.render": { - await harness.renderOnce() - const result = SimulationActions.state(harness) - SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length }) - return result + case "ui.start-record": { + if (recording.current) throw new Error("UI recording is already active") + const frames = [SimulationActions.frame(harness)] + const current: Recording = { + frames, + timer: setInterval(() => { + current.pending = current.pending.then(async () => { + if (headless) await harness.renderOnce() + frames.push(SimulationActions.frame(harness)) + }) + }, 100), + pending: Promise.resolve(), + } + recording.current = current + return { recording: true } } - case "trace.list": - return { records: SimulationTrace.list() } - case "trace.clear": - SimulationTrace.clear() - return { cleared: true } - case "trace.export": - return SimulationTrace.exportTrace() + case "ui.end-record": { + if (!recording.current) throw new Error("UI recording is not active") + const current = recording.current + clearInterval(current.timer) + await current.pending + if (headless) await harness.renderOnce() + current.frames.push(SimulationActions.frame(harness)) + recording.current = undefined + return SimulationActions.video(current.frames) + } + case "ui.type": + return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text }) + case "ui.enter": + return SimulationActions.execute(harness, { type: "ui.enter" }) + case "ui.press": + return SimulationActions.execute(harness, { + type: "ui.press", + key: request.params.key, + modifiers: request.params.modifiers, + }) + case "ui.arrow": + return SimulationActions.execute(harness, { type: "ui.arrow", direction: request.params.direction }) + case "ui.focus": + return SimulationActions.execute(harness, { type: "ui.focus", target: request.params.target }) + case "ui.click": + return SimulationActions.execute(harness, { + type: "ui.click", + target: request.params.target, + x: request.params.x, + y: request.params.y, + }) } - throw new Error(`Unknown simulation method: ${request.method}`) } -export function start(harness: Harness, endpoint: string): Server { +export function start(harness: Harness, endpoint: string, headless: boolean): Server { const url = new URL(endpoint) - const server = Bun.serve<{ readonly drive: true }>({ + const recording: { current?: Recording } = {} + const server = Bun.serve<{ readonly drive: true; readonly headless: boolean }>({ hostname: url.hostname, port: Number(url.port), fetch(request, server) { - if (server.upgrade(request, { data: { drive: true } })) return undefined + if (server.upgrade(request, { data: { drive: true, headless } })) return undefined return new Response("opencode drive ui websocket", { status: 426 }) }, websocket: { - open() { - SimulationTrace.add("control.connect") - }, - close() { - SimulationTrace.add("control.disconnect") - }, async message(socket, message) { - let request: SimulationProtocol.JsonRpc.Request | undefined + let request: SimulationProtocol.Frontend.Request | undefined try { request = parseRequest(message) - const result = await handle(harness, request) + const result = await handle(harness, request, recording, headless) const next = SimulationProtocol.JsonRpc.success(request.id, result) if (next) socket.send(JSON.stringify(next)) } catch (error) { @@ -70,11 +103,10 @@ export function start(harness: Harness, endpoint: string): Server { }, }, }) - SimulationTrace.add("control.start", { url: endpoint }) return { url: endpoint, stop: () => { - SimulationTrace.add("control.stop", { url: endpoint }) + if (recording.current) clearInterval(recording.current.timer) server.stop(true) }, } diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index 96e68e8dca..c2a56ad8c1 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -7,19 +7,18 @@ import { SimulationServer } from "./server" /** * Drive-mode renderer entry point. * - * Creates the renderer (fake when OPENCODE_DRIVE_RENDERER=fake, the normal + * Creates the renderer (headless when OPENCODE_DRIVE_RENDERER=headless, the normal * visible renderer otherwise) and starts the UI control * server against it. The server stops when the renderer is destroyed, so the * caller only manages the renderer lifecycle. */ export async function create(options: CliRendererConfig): Promise { - const renderer = - process.env.OPENCODE_DRIVE_RENDERER === "fake" - ? await SimulationRenderer.create(options) - : await createCliRenderer(options) + const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless" + const renderer = headless ? await SimulationRenderer.create(options) : await createCliRenderer(options) const server = SimulationServer.start( SimulationActions.createHarness(renderer), DriveManifest.resolve().endpoints.ui, + headless, ) process.stderr.write(`opencode drive ui websocket: ${server.url}\n`) renderer.once("destroy", () => server.stop()) diff --git a/packages/simulation/src/frontend/trace.ts b/packages/simulation/src/frontend/trace.ts deleted file mode 100644 index b0914faace..0000000000 --- a/packages/simulation/src/frontend/trace.ts +++ /dev/null @@ -1,37 +0,0 @@ -export type TraceRecord = { - readonly id: number - readonly time: string - readonly type: string - readonly data?: unknown -} - -const records: TraceRecord[] = [] -let nextID = 0 - -export function add(type: string, data?: unknown) { - const record = { - id: ++nextID, - time: new Date().toISOString(), - type, - ...(data === undefined ? {} : { data }), - } satisfies TraceRecord - records.push(record) - return record -} - -export function list() { - return [...records] -} - -export function clear() { - records.length = 0 - nextID = 0 -} - -export function exportTrace() { - return { - records: list(), - } -} - -export * as SimulationTrace from "./trace" diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index b05f24d501..73c43f4119 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -4,9 +4,12 @@ const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null]) type Json = Schema.Schema.Type export namespace JsonRpc { - export const Request = Schema.Struct({ + export const RequestFields = { jsonrpc: Schema.Literal("2.0"), id: Schema.optional(JsonRpcID), + } + export const Request = Schema.Struct({ + ...RequestFields, method: Schema.String, params: Schema.optional(Schema.Json), }) @@ -56,12 +59,12 @@ export namespace Frontend { export interface KeyModifiers extends Schema.Schema.Type {} export const Action = Schema.Union([ - Schema.Struct({ type: Schema.Literal("typeText"), text: Schema.String }), - Schema.Struct({ type: Schema.Literal("pressKey"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), - Schema.Struct({ type: Schema.Literal("pressEnter") }), - Schema.Struct({ type: Schema.Literal("pressArrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), - Schema.Struct({ type: Schema.Literal("focus"), target: Schema.Number }), - Schema.Struct({ type: Schema.Literal("click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), + Schema.Struct({ type: Schema.Literal("ui.type"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("ui.press"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), + Schema.Struct({ type: Schema.Literal("ui.enter") }), + Schema.Struct({ type: Schema.Literal("ui.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), + Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }), + Schema.Struct({ type: Schema.Literal("ui.click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), ]) export type Action = Schema.Schema.Type @@ -80,37 +83,71 @@ export namespace Frontend { export interface Element extends Schema.Schema.Type {} export const State = Schema.Struct({ - screen: Schema.String, focused: Schema.Struct({ renderable: Schema.optional(Schema.Number), editor: Schema.Boolean, }), elements: Schema.Array(Element), - actions: Schema.Array(Action), }) export interface State extends Schema.Schema.Type {} - export const ActionParams = Schema.Struct({ action: Action }) - export interface ActionParams extends Schema.Schema.Type {} - export const decodeActionParams = Schema.decodeUnknownSync(ActionParams) + export const Screenshot = Schema.String + export type Screenshot = Schema.Schema.Type - export const TraceRecord = Schema.Struct({ - id: Schema.Number, - time: Schema.String, - type: Schema.String, - data: Schema.optional(Schema.Json), - }) - export interface TraceRecord extends Schema.Schema.Type {} + export const StartRecord = Schema.Struct({ recording: Schema.Literal(true) }) + export interface StartRecord extends Schema.Schema.Type {} + + export const EndRecord = Schema.String + export type EndRecord = Schema.Schema.Type + + export const TypeParams = Schema.Struct({ text: Schema.String }) + export interface TypeParams extends Schema.Schema.Type {} + + export const PressParams = Schema.Struct({ key: Schema.String, modifiers: Schema.optional(KeyModifiers) }) + export interface PressParams extends Schema.Schema.Type {} + + export const ArrowParams = Schema.Struct({ direction: Schema.Literals(["up", "down", "left", "right"]) }) + export interface ArrowParams extends Schema.Schema.Type {} + + export const FocusParams = Schema.Struct({ target: Schema.Number }) + export interface FocusParams extends Schema.Schema.Type {} + + export const ClickParams = Schema.Struct({ target: Schema.Number, x: Schema.Number, y: Schema.Number }) + export interface ClickParams extends Schema.Schema.Type {} + + export const Request = Schema.Union([ + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.focus"), params: FocusParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }), + Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literals([ + "ui.enter", + "ui.screenshot", + "ui.state", + "ui.start-record", + "ui.end-record", + ]), + }), + ]) + export type Request = Schema.Schema.Type + export const decodeRequest = Schema.decodeUnknownSync(Request) - export const TraceList = Schema.Struct({ records: Schema.Array(TraceRecord) }) - export interface TraceList extends Schema.Schema.Type {} } export namespace Backend { export const Item = Schema.Union([ Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }), Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }), - Schema.Struct({ type: Schema.Literal("toolCall"), id: Schema.String, name: Schema.String, input: Schema.Json }), + Schema.Struct({ + type: Schema.Literal("toolCall"), + index: Schema.Number, + id: Schema.String, + name: Schema.String, + input: Schema.Json, + }), Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Json }), ]) export type Item = Schema.Schema.Type @@ -130,6 +167,18 @@ export namespace Backend { export const DisconnectParams = Schema.Struct({ id: Schema.String }) export interface DisconnectParams extends Schema.Schema.Type {} + export const Request = Schema.Union([ + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }), + Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literals(["llm.attach", "llm.pending"]), + }), + ]) + export type Request = Schema.Schema.Type + export const decodeRequest = Schema.decodeUnknownSync(Request) + export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) export interface OpenedExchange extends Schema.Schema.Type {} @@ -141,9 +190,6 @@ export namespace Backend { }) export interface NetworkLogEntry extends Schema.Schema.Type {} - export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams) - export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams) - export const decodeDisconnectParams = Schema.decodeUnknownPromise(DisconnectParams) } export * as SimulationProtocol from "./index" diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index f2d10dc109..d231b110fa 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -123,8 +123,8 @@ function manageIntegration( const credentials = credentialConnections(integration) const selected = createMemo(() => data.location.search.provider() === integration.id) const selectSearch = () => { - void sdk.api.search - .selectProvider({ + void sdk.api.search.provider + .select({ providerID: integration.id, location: location(data), }) @@ -256,8 +256,8 @@ function KeyMethod(props: { placeholder="API key" onConfirm={(key) => { if (!key) return - void sdk.api.integration - .connectKey({ + void sdk.api.integration.connect + .key({ integrationID: props.integration.id, location: location(data), key, @@ -295,8 +295,8 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void sdk.api.integration - .connectOauth({ + void sdk.api.integration.connect + .oauth({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -364,8 +364,8 @@ function OAuthAuto(props: { })) const poll = () => { - void sdk.api.integration - .attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attempt + .status({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -391,7 +391,7 @@ function OAuthAuto(props: { onCleanup(() => { if (timer) clearTimeout(timer) if (settled) return - void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -421,7 +421,7 @@ function OAuthCode(props: { onCleanup(() => { if (settled) return - void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -430,8 +430,8 @@ function OAuthCode(props: { placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void sdk.api.integration - .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) + void sdk.api.integration.attempt + .complete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 405f2e0693..e0beff09ed 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -19,8 +19,9 @@ import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise" import { useRoute } from "../context/route" +import { DialogProjectCopyName } from "./dialog-project-copy-name" -export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } +export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } type ProjectDirectory = ProjectDirectoriesOutput[number] type DialogMoveSessionProps = { @@ -291,6 +292,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (await removedCurrent(deletingCurrent)) return } + async function create() { + const name = await DialogProjectCopyName.show(dialog) + if (name === null) return + props.onSelect({ type: "new", name }) + } + const fullHeight = createMemo(() => Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)), ) @@ -334,7 +341,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { { command: "dialog.move_session.new", title: "new", - onTrigger: () => props.onSelect({ type: "new" }), + onTrigger: () => void create(), }, { command: "dialog.move_session.delete", diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-project-copy-name.tsx new file mode 100644 index 0000000000..7ac357e1c8 --- /dev/null +++ b/packages/tui/src/component/dialog-project-copy-name.tsx @@ -0,0 +1,95 @@ +import { InputRenderable, TextAttributes } from "@opentui/core" +import { Slug } from "@opencode-ai/core/util/slug" +import { createSignal, onMount } from "solid-js" +import { useTuiConfig } from "../config" +import { useTheme } from "../context/theme" +import { useBindings, useCommandShortcut } from "../keymap" +import { useDialog, type DialogContext } from "../ui/dialog" + +export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { + const dialog = useDialog() + const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const generateShortcut = useCommandShortcut("dialog.project_copy.generate") + const [inputTarget, setInputTarget] = createSignal() + let input: InputRenderable + + function generate() { + input.value = Slug.create() + input.gotoLineEnd() + } + + function confirm() { + props.onConfirm(slugify(input.value) || Slug.create()) + } + + useBindings(() => ({ + target: inputTarget, + enabled: inputTarget() !== undefined, + priority: 1, + commands: [ + { + name: "dialog.project_copy.generate", + title: "Generate project copy name", + category: "Dialog", + run: generate, + }, + ], + bindings: tuiConfig.keybinds.get("dialog.project_copy.generate"), + })) + + onMount(() => { + dialog.setSize("medium") + setTimeout(() => { + if (!input || input.isDestroyed) return + input.focus() + }, 1) + }) + + return ( + + + + Name project copy + + dialog.clear()}> + esc + + + { + input = value + setInputTarget(value) + }} + onSubmit={confirm} + placeholder="Project copy name" + placeholderColor={theme.textMuted} + textColor={theme.text} + focusedTextColor={theme.text} + cursorColor={theme.text} + /> + + + enter submit + + + {generateShortcut()} generate one + + + + ) +} + +DialogProjectCopyName.show = (dialog: DialogContext) => + new Promise((resolve) => { + dialog.replace(() => , () => resolve(null)) + }) + +function slugify(input: string) { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") +} diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 72de26d278..025c0968b8 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -1,6 +1,6 @@ import { createMemo, createResource, createSignal, onMount } from "solid-js" import path from "path" -import type { SessionV2Info } from "@opencode-ai/sdk/v2" +import type { SessionInfo } from "@opencode-ai/sdk/v2" import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" @@ -44,7 +44,7 @@ export function DialogSessionList() { workspace: location.workspaceID, }) // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; session list UI reuses legacy mutable session types. - return { query, sessions: structuredClone(response.data) as SessionV2Info[] } + return { query, sessions: structuredClone(response.data) as SessionInfo[] } }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) @@ -77,7 +77,7 @@ export function DialogSessionList() { const pinnedSet = new Set(pinned) const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1])) - const option = (session: SessionV2Info, category: string) => { + const option = (session: SessionInfo, category: string) => { const directory = session.location.directory const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" const slot = slotByID.get(session.id) @@ -88,12 +88,11 @@ export function DialogSessionList() { category, footer, bg: deleting ? theme.error : undefined, - gutter: - data.session.family(session.id).some((id) => data.session.status(id) === "running") - ? () => - : slot === undefined - ? undefined - : () => {slot}, + gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running") + ? () => + : slot === undefined + ? undefined + : () => {slot}, } } @@ -135,16 +134,14 @@ export function DialogSessionList() { setToDelete(option.value) return } - void sdk.client.v2.session - .remove({ sessionID: option.value }, { throwOnError: true }) - .catch((error) => { - setToDelete(undefined) - toast.show({ - message: `Failed to delete session: ${errorMessage(error)}`, - variant: "error", - duration: 5000, - }) + void sdk.client.v2.session.remove({ sessionID: option.value }, { throwOnError: true }).catch((error) => { + setToDelete(undefined) + toast.show({ + message: `Failed to delete session: ${errorMessage(error)}`, + variant: "error", + duration: 5000, }) + }) }, }, { diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index 61c173a384..bb54dd8713 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -45,10 +45,10 @@ export function DialogSkill(props: DialogSkillProps) { return list.map((skill) => ({ title: skill.name.padEnd(maxWidth), description: skill.description?.replace(/\s+/g, " ").trim(), - value: skill.name, + value: skill.id, category: "Skills", onSelect: () => { - props.onSelect(skill.name) + props.onSelect(skill.id) dialog.clear() }, })) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 65bff40838..1b86dbe582 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -448,12 +448,12 @@ export function Autocomplete(props: { for (const skill of data.location.skill .list(location()) - ?.filter((skill) => skill.slash === true && !commandNames.has(skill.name)) ?? []) { + ?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) { results.push({ - display: "/" + skill.name, + display: "/" + skill.id, description: skill.description, onSelect: () => { - const newText = "/" + skill.name + " " + const newText = "/" + skill.id + " " const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7aac079b03..0fcbfa96d5 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" +import type { SessionInfo, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { createColors, createFrames } from "../../ui/spinner" @@ -57,6 +57,7 @@ import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" import { useLocation } from "../../context/location" +import { lastAssistantWithUsage } from "../../util/session" registerOpencodeSpinner() @@ -164,9 +165,9 @@ export function Prompt(props: PromptProps) { const status = createMemo(() => data.session.status(props.sessionID ?? "")) const activeSubagents = createMemo(() => { if (!props.sessionID) return 0 - return data.session.family(props.sessionID).filter( - (id) => id !== props.sessionID && data.session.status(id) === "running", - ).length + return data.session + .family(props.sessionID) + .filter((id) => id !== props.sessionID && data.session.status(id) === "running").length }) const runningShells = createMemo( () => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length, @@ -218,7 +219,10 @@ export function Prompt(props: PromptProps) { const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() const workspace = usePromptWorkspace(props.sessionID) - const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) + const move = usePromptMove({ + projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(), + sessionID: () => props.sessionID, + }) const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const connected = useConnected() @@ -273,18 +277,20 @@ export function Prompt(props: PromptProps) { const usage = createMemo(() => { if (!props.sessionID) return - const session = sync.session.get(props.sessionID) - const msg = sync.data.message[props.sessionID] ?? [] - const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + const session = data.session.get(props.sessionID) + if (!session) return + const last = lastAssistantWithUsage(data.session.message.list(props.sessionID), session.revert?.messageID) if (!last) return const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write if (tokens <= 0) return - const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + const model = data.location + .model.list(session.location) + ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = session?.cost ?? 0 + const cost = session.cost return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, @@ -955,13 +961,13 @@ export function Prompt(props: PromptProps) { if (workspace.creating() || move.creating()) return false if (auto()?.visible) return false if (!store.prompt.text) return false - const agent = local.agent.current() - if (!agent) return false const trimmed = store.prompt.text.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() return true } + const agent = local.agent.current() + if (!agent) return false const selectedModel = local.model.current() if (!selectedModel) { void promptModelWarning() @@ -991,7 +997,7 @@ export function Prompt(props: PromptProps) { const selectedWorkspace = workspace.selection() const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined - const directory = await move.getDirectory(store.prompt.text) + const directory = await move.getDirectory() if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) const location = data.location.default() @@ -1022,7 +1028,7 @@ export function Prompt(props: PromptProps) { sessionID = created.id // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; prompt state still uses legacy mutable session types. - session = structuredClone(created) as SessionV2Info + session = structuredClone(created) as SessionInfo } const inputText = expandTrackedPastedText( @@ -1093,7 +1099,7 @@ export function Prompt(props: PromptProps) { } else if ( inputText.startsWith("/") && (data.location.skill.list(currentLocation()) ?? []).some( - (skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1), + (skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1), ) ) { move.startSubmit() @@ -1121,7 +1127,7 @@ export function Prompt(props: PromptProps) { }) } if (session?.revert) { - const error = await sdk.api.session.revertCommit({ sessionID }).then( + const error = await sdk.api.session.revert.commit({ sessionID }).then( () => undefined, (error) => error, ) @@ -1621,11 +1627,11 @@ export function Prompt(props: PromptProps) { {agentShortcut()} agents - - {paletteShortcut()} commands - + + {paletteShortcut()} commands + diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 9fdd5c9d6a..1139385034 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -4,12 +4,12 @@ import { useTuiPaths } from "../../context/runtime" import { errorMessage } from "../../util/error" import { useDialog } from "../../ui/dialog" import { useSDK } from "../../context/sdk" -import { useSync } from "../../context/sync" import { useToast } from "../../ui/toast" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" import { useHomeSessionDestination } from "../../routes/home/session-destination" import { useProject } from "../../context/project" +import { useData } from "../../context/data" function moveReminderText(directory: string) { return `The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.` @@ -18,38 +18,33 @@ function moveReminderText(directory: string) { export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) { const dialog = useDialog() const sdk = useSDK() - const sync = useSync() const toast = useToast() const homeDestination = useHomeSessionDestination() const project = useProject() + const data = useData() const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) const [creatingDots, setCreatingDots] = createSignal(3) const [progress, setProgress] = createSignal() - async function create(context?: string) { - const projectID = input.projectID() + async function create(name: string) { + const projectID = await resolveProjectID() if (!projectID) return setCreating(true) setProgress("Creating copy") try { - const generated = await sdk.client.experimental.projectCopy.generateName( - { projectID, context }, - { throwOnError: true }, - ) const result = await sdk.api.projectCopy.create({ projectID, location: { directory: project.instance.directory() || paths.cwd }, strategy: "git_worktree", directory: path.join(paths.worktree, projectID.slice(0, 6)), - name: generated.data.name, + name, }) const directory = result.directory if (!directory) throw new Error("No project copy directory returned") - // Call a location-based route to make sure it's bootstrapped - // before moving on - await sdk.client.path.get({ directory }, { throwOnError: true }) + // Call a location-based route to make sure it's bootstrapped before moving on. + await sdk.api.location.get({ location: { directory } }) setProgress("Creating session") return directory @@ -62,11 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } } - function open() { - const projectID = input.projectID() - if (!projectID) return + async function open() { + const projectID = await resolveProjectID() + if (!projectID) { + toast.show({ message: "Unable to determine current project", variant: "error" }) + return + } const sessionID = input.sessionID() - const session = sessionID ? sync.session.get(sessionID) : undefined + const session = sessionID ? await resolveSession(sessionID) : undefined dialog.replace(() => ( string | undefined; sess (session ? { type: "directory", - directory: session.directory, - subdirectory: !!session.path, + directory: session.location.directory, + subdirectory: !!session.subpath, } : { type: "directory", @@ -98,26 +96,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess )) } - function sessionContext(sessionID: string) { - const session = sync.session.get(sessionID) - const messages = (sync.data.message[sessionID] ?? []) - .slice(-6) - .map((message) => - [ - message.role + ":", - ...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])), - ].join(" "), - ) - return [session?.title, ...messages].filter(Boolean).join("\n") || undefined - } - async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) { - const session = sync.session.get(sessionID) - const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined) + const session = await resolveSession(sessionID) + const status = await sdk.client.vcs.status({ directory: session?.location.directory }).catch(() => undefined) const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no" if (!choice) return dialog.clear() - const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory + const directory = selection.type === "new" ? await create(selection.name) : selection.directory if (!directory) { setProgress(undefined) dialog.clear() @@ -125,27 +110,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } setProgress("Moving session") try { - await sdk.client.experimental.controlPlane.moveSession( - { - sessionID, - destination: { directory }, - moveChanges: choice === "yes", - }, - { throwOnError: true }, - ) - await sdk.client.session - .promptAsync({ - sessionID, - directory, - noReply: true, - parts: [ - { - type: "text", - text: moveReminderText(directory), - synthetic: true, - }, - ], - }) + await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" }) + await sdk.api.session + .synthetic({ sessionID, text: moveReminderText(directory), resume: false }) .catch(() => undefined) dialog.clear() } catch (error) { @@ -157,16 +124,34 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } } + async function resolveProjectID() { + const projectID = input.projectID() + if (projectID) return projectID + const sessionID = input.sessionID() + if (sessionID) return (await resolveSession(sessionID))?.projectID + return sdk.api.project + .current({ location: { directory: project.instance.directory() || paths.cwd } }) + .then((project) => project.id) + .catch(() => undefined) + } + + async function resolveSession(sessionID: string) { + const session = data.session.get(sessionID) + if (session) return session + await data.session.refresh(sessionID).catch(() => undefined) + return data.session.get(sessionID) + } + const pending = createMemo(() => Boolean(homeDestination?.destination())) const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new") - async function getDirectory(context?: string) { + async function getDirectory() { const value = homeDestination?.destination() if (!value) return if (value.type === "directory") { return value.directory } - return await create(context) + return await create(value.name) } function startSubmit() { diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 7acb093a7b..0f97f8277d 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -207,6 +207,7 @@ export const Definitions = { "dialog.select.end": keybind("end", "Move to last dialog item"), "dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), + "dialog.project_copy.generate": keybind("tab", "Generate project copy name"), "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), "dialog.move_session.new": keybind("ctrl+m", "New project copy"), "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 96f3c5e04f..53b1d68450 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,56 +1,71 @@ +// Client data layer: apply server events and cache API reads into a Solid store. +// Prefer straightforward projection. Do not add generation counters, stale-response +// merges, live/history overlays, or other race machinery here—last write wins. +// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns. + import type { - AgentV2Info, - CommandV2Info, + AgentInfo, + CommandInfo, FormFormInfo, FormIntegrationInfo, FormUrlInfo, IntegrationInfo, LocationRef, McpServer, - ModelV2Info, + ModelInfo, PermissionSavedInfo, PermissionV2Request, ProviderV2Info, ReferenceInfo, - SessionMessage, + SessionMessageInfo, SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, SessionMessageAssistantTool, - SessionV2Info, + SessionInfo, Shell, - SkillV2Info, + SkillInfo, V2Event, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" -import { createSignal, onCleanup } from "solid-js" +import { batch, createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") +const MESSAGE_PAGE_SIZE = 25 export type FormInfo = FormFormInfo | FormUrlInfo | FormIntegrationInfo +// Per-session message timeline plus older-history paging. `items` is ascending; +// `cursor` is the opaque server cursor for the next older page after a desc first load. +type SessionMessages = { + items: SessionMessageInfo[] + cursor?: string + complete: boolean + loading: boolean +} + type LocationData = { - agent?: AgentV2Info[] - command?: CommandV2Info[] + agent?: AgentInfo[] + command?: CommandInfo[] integration?: IntegrationInfo[] mcp?: McpServer[] - model?: ModelV2Info[] + model?: ModelInfo[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] searchProvider?: string | null // Currently running shell commands for this location, keyed by shell id. Entries are removed // once the command exits or is deleted, so this only ever holds in-flight shells. shell?: Record - skill?: SkillV2Info[] + skill?: SkillInfo[] } type Data = { session: { - info: Record + info: Record // Family index keyed by a family's root (or furthest-known-ancestor when the // true root is not yet loaded). The value is a flat deduplicated list of every // session ID in that family, including the key itself once its info arrives. @@ -58,7 +73,7 @@ type Data = { status: Record compaction: Partial> compactionReason: Partial> - message: Record + message: Record input: Record permission: Record // Pending forms keyed by session ID. @@ -70,6 +85,10 @@ type Data = { location: Record } +function emptyMessages(): SessionMessages { + return { items: [], complete: false, loading: false } +} + function locationKey(location: LocationRef) { return JSON.stringify([location.directory, location.workspaceID]) } @@ -112,47 +131,42 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() - let connectionGeneration = 0 - let statusChanges: Set | undefined let bootstrapping: Promise | undefined function setSessionStatus(sessionID: string, status: DataSessionStatus) { - statusChanges?.add(sessionID) setStore("session", "status", sessionID, status) } const message = { - update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => void) { + update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( "session", "message", produce((draft) => { - fn((draft[sessionID] ??= []), index(sessionID)) + fn((draft[sessionID] ??= emptyMessages()).items, index(sessionID)) }), ) }, - append(messages: SessionMessage[], index: Map, item: SessionMessage) { + append(messages: SessionMessageInfo[], index: Map, item: SessionMessageInfo) { if (index.has(item.id)) return index.set(item.id, messages.length) messages.push(item) }, - activeAssistant(messages: SessionMessage[]) { + activeAssistant(messages: SessionMessageInfo[]) { const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed) return item?.type === "assistant" ? item : undefined }, - assistant(messages: SessionMessage[], index: Map, messageID: string) { + assistant(messages: SessionMessageInfo[], index: Map, messageID: string) { const position = index.get(messageID) const item = position === undefined ? undefined : messages[position] return item?.type === "assistant" ? item : undefined }, - shell(messages: SessionMessage[], shellID: string) { - const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID) + shell(messages: SessionMessageInfo[], shellID: string) { + const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID) return item?.type === "shell" ? item : undefined }, - compaction(messages: SessionMessage[]) { - const item = messages.findLast( - (item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"), - ) + compaction(messages: SessionMessageInfo[]) { + const item = messages.findLast((item) => item.type === "compaction" && item.status === "running") return item?.type === "compaction" ? item : undefined }, latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { @@ -252,6 +266,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.deleted": removeSession(event.data.sessionID) break + case "session.usage.updated": + if (store.session.info[event.data.sessionID]) + setStore("session", "info", event.data.sessionID, { + cost: event.data.cost, + tokens: event.data.tokens, + }) + break case "catalog.updated": void Promise.all([ result.location.model.refresh(event.location), @@ -306,6 +327,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "title", event.data.title) break + case "session.moved": + if (store.session.info[event.data.sessionID]) { + setStore("session", "info", event.data.sessionID, "location", mutable(event.data.location)) + setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath) + } + break case "session.prompt.promoted": { message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) @@ -360,7 +387,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.append(draft, index, { id: messageIDFromEvent(event.id), type: "synthetic", - sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, time: { created: event.created }, @@ -372,7 +398,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.append(draft, index, { id: messageIDFromEvent(event.id), type: "shell", - shell: event.data.shell, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, + exit: event.data.shell.exit, time: { created: event.created }, }) }) @@ -381,7 +410,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.update(event.data.sessionID, (draft) => { const match = message.shell(draft, event.data.shell.id) if (!match) return - match.shell = event.data.shell + match.command = event.data.shell.command + match.status = event.data.shell.status + match.exit = event.data.shell.exit match.output = event.data.output match.time.completed = event.created }) @@ -416,7 +447,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "session.step.ended": + case "session.step.ended": { message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return @@ -428,6 +459,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot } }) break + } case "session.step.failed": message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) @@ -436,6 +468,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.finish = "error" currentAssistant.error = event.data.error currentAssistant.retry = undefined + if (event.data.cost !== undefined && event.data.tokens !== undefined) { + currentAssistant.cost = event.data.cost + currentAssistant.tokens = event.data.tokens + } }) break case "session.text.started": @@ -465,7 +501,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: event.data.callID, name: event.data.name, time: { created: event.created }, - state: { status: "pending", input: "" }, + state: { status: "streaming", input: "" }, }) }) break @@ -475,7 +511,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "pending") match.state.input += event.data.delta + if (match?.state.status === "streaming") match.state.input += event.data.delta }) break case "session.tool.input.ended": @@ -484,7 +520,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "pending") match.state.input = event.data.text + if (match?.state.status === "streaming") match.state.input = event.data.text }) break case "session.tool.called": @@ -536,7 +572,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return + if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return match.state = { status: "error", error: event.data.error, @@ -591,27 +627,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setSessionStatus(event.data.sessionID, "running") break case "session.compaction.admitted": - message.update(event.data.sessionID, (draft, index) => { - if (message.compaction(draft)) return - message.append(draft, index, { - id: event.data.inputID, - type: "compaction", - status: "queued", - reason: "manual", - summary: "", - recent: "", - time: { created: event.created }, - }) - }) break case "session.compaction.started": setStore("session", "compaction", event.data.sessionID, "") setStore("session", "compactionReason", event.data.sessionID, event.data.reason) - if (event.data.reason === "manual") - message.update(event.data.sessionID, (draft) => { - const current = message.compaction(draft) - if (current) current.status = "running" + message.update(event.data.sessionID, (draft, index) => { + const current = message.compaction(draft) + if (current) { + current.status = "running" + current.reason = event.data.reason + return + } + message.append(draft, index, { + id: event.data.inputID ?? messageIDFromEvent(event.id), + type: "compaction", + status: "running", + reason: event.data.reason, + summary: "", + recent: event.data.recent ?? "", + time: { created: event.created }, }) + }) break case "session.execution.succeeded": case "session.execution.failed": @@ -635,8 +671,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", event.data.sessionID, "revert", undefined) break case "session.revert.committed": - if (store.session.info[event.data.sessionID]) + if (store.session.info[event.data.sessionID]) { setStore("session", "info", event.data.sessionID, "revert", undefined) + } setStore( "session", "input", @@ -651,22 +688,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.compaction.delta": setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text) - if (store.session.compactionReason[event.data.sessionID] === "manual") - message.update(event.data.sessionID, (draft) => { - const current = message.compaction(draft) - if (current) current.summary += event.data.text - }) + message.update(event.data.sessionID, (draft) => { + const current = message.compaction(draft) + if (current) current.summary += event.data.text + }) break case "session.compaction.ended": setStore("session", "compaction", event.data.sessionID, undefined) setStore("session", "compactionReason", event.data.sessionID, undefined) message.update(event.data.sessionID, (draft, index) => { - const current = event.data.reason === "manual" ? message.compaction(draft) : undefined + const current = message.compaction(draft) if (current) { - current.status = "completed" - current.reason = event.data.reason - current.summary = event.data.text - current.recent = event.data.recent + const position = index.get(current.id) + if (position !== undefined) + draft[position] = { + ...current, + status: "completed", + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + } return } message.append(draft, index, { @@ -683,9 +724,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.compaction.failed": setStore("session", "compaction", event.data.sessionID, undefined) setStore("session", "compactionReason", event.data.sessionID, undefined) - message.update(event.data.sessionID, (draft) => { + message.update(event.data.sessionID, (draft, index) => { const current = message.compaction(draft) - if (current) current.status = "failed" + if (!current) return + const position = index.get(current.id) + if (position !== undefined) + draft[position] = { + ...current, + status: "failed", + reason: event.data.reason, + error: event.data.error, + } }) break case "permission.v2.asked": @@ -815,35 +864,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, message: { ids(sessionID: string) { - return (store.session.message[sessionID] ?? []).map((message) => message.id) + return (store.session.message[sessionID]?.items ?? []).map((message) => message.id) }, list(sessionID: string) { - return store.session.message[sessionID] ?? [] + return store.session.message[sessionID]?.items ?? [] }, get(sessionID: string, messageID: string) { - const messages = store.session.message[sessionID] + const messages = store.session.message[sessionID]?.items const position = messageIndex.get(sessionID)?.get(messageID) return position === undefined ? undefined : messages?.[position] }, + cursor(sessionID: string) { + return store.session.message[sessionID]?.cursor + }, + complete(sessionID: string) { + return store.session.message[sessionID]?.complete ?? false + }, + loading(sessionID: string) { + return store.session.message[sessionID]?.loading ?? false + }, async refresh(sessionID: string) { - const live = [...(store.session.message[sessionID] ?? [])] - setStore("session", "message", sessionID, []) + setStore("session", "message", sessionID, { ...emptyMessages(), loading: true }) messageIndex.set(sessionID, new Map()) - const loaded = mutable( - (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data, - ).toReversed() - const loadedIDs = new Set(loaded.map((message) => message.id)) - const liveByID = new Map(live.map((message) => [message.id, message])) - const messages = [ - ...loaded.map((message) => { - if (message.type === "user") return message - return liveByID.get(message.id) ?? message - }), - ...live.filter((message) => !loadedIDs.has(message.id)), - ].toSorted((a, b) => a.time.created - b.time.created) - messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) - setStore("session", "message", sessionID, messages) - const running = messages.find((message) => message.type === "compaction" && message.status === "running") + const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, order: "desc" }) + const items = mutable(response.data).toReversed() + messageIndex.set(sessionID, new Map(items.map((message, index) => [message.id, index]))) + setStore("session", "message", sessionID, { + items, + cursor: response.cursor.next ?? undefined, + complete: response.data.length < MESSAGE_PAGE_SIZE, + loading: false, + }) + const running = items.find((message) => message.type === "compaction" && message.status === "running") setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined) setStore( "session", @@ -852,6 +904,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ running?.type === "compaction" ? running.reason : undefined, ) }, + async more(sessionID: string) { + const current = store.session.message[sessionID] + if (!current || current.loading || current.complete || !current.cursor) return + const cursor = current.cursor + setStore("session", "message", sessionID, "loading", true) + const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, cursor }) + const older = mutable(response.data).toReversed() + const prepend = older.filter((item) => !messageIndex.get(sessionID)?.has(item.id)) + const items = [...prepend, ...current.items] + messageIndex.set(sessionID, new Map(items.map((item, position) => [item.id, position]))) + batch(() => { + setStore("session", "message", sessionID, "items", items) + setStore("session", "message", sessionID, { + cursor: response.cursor.next ?? undefined, + complete: response.data.length < MESSAGE_PAGE_SIZE, + loading: false, + }) + }) + }, }, permission: { list(sessionID: string) { @@ -876,7 +947,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.project.permission[projectID] }, async refresh(projectID: string) { - setStore("project", "permission", projectID, mutable(await sdk.api.permission.listSaved({ projectID }))) + setStore("project", "permission", projectID, mutable(await sdk.api.permission.saved.list({ projectID }))) }, }, }, @@ -983,7 +1054,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.searchProvider ?? undefined }, async refresh(ref?: LocationRef) { - const result = await sdk.api.search.provider({ location: locationQuery(ref ?? defaultLocation()) }) + const result = await sdk.api.search.provider.get({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], searchProvider: result.data ?? null }) }, @@ -1021,6 +1092,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) for (const session of response.data) registerSession(session.id) }), + sdk.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { + const permissions = mutable(response.data).reduce>( + (result, request) => ({ + ...result, + [request.sessionID]: [...(result[request.sessionID] ?? []), request], + }), + {}, + ) + setStore("session", "permission", reconcile(permissions)) + }), + sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { + const forms = mutable(response.data).reduce>( + (result, form) => ({ + ...result, + [form.sessionID]: [...(result[form.sessionID] ?? []), form], + }), + {}, + ) + setStore("session", "form", reconcile(forms)) + }), result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), @@ -1044,23 +1135,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function refreshActive() { - const generation = ++connectionGeneration - const changed = new Set() - statusChanges = changed void sdk.api.session .active() .then((active) => { - if (generation !== connectionGeneration) return - const status: Record = Object.fromEntries( - Object.keys(active).map((sessionID) => [sessionID, "running" as const]), + setStore( + "session", + "status", + reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))), ) - for (const sessionID of changed) status[sessionID] = store.session.status[sessionID] - setStore("session", "status", reconcile(status)) }) .catch(() => undefined) - .finally(() => { - if (statusChanges === changed) statusChanges = undefined - }) } onCleanup( diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 7e691ad29c..40eac011ae 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -15,7 +15,7 @@ import type { ProviderListResponse, QuestionRequest, Session, - SnapshotFileDiff, + FileDiffInfo, Todo, VcsInfo, } from "@opencode-ai/sdk/v2" @@ -42,9 +42,6 @@ export const { provider_default: Record provider_next: ProviderListResponse console_state: ConsoleState - capabilities: { - experimentalBackgroundSubagents: boolean - } provider_auth: Record agent: Agent[] command: Command[] @@ -52,7 +49,7 @@ export const { question: Record config: Config session: Session[] - session_diff: Record + session_diff: Record todo: Record message: Record part: Record @@ -71,9 +68,6 @@ export const { connected: [], }, console_state: emptyConsoleState, - capabilities: { - experimentalBackgroundSubagents: false, - }, provider_auth: {}, agent: [], command: [], diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index f1c99d9679..ae41cec303 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -1,7 +1,8 @@ -import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo } from "solid-js" +import { useData } from "../../context/data" +import { lastAssistantWithUsage } from "../../util/session" const id = "internal:sidebar-context" @@ -11,13 +12,14 @@ const money = new Intl.NumberFormat("en-US", { }) function View(props: { api: TuiPluginApi; session_id: string }) { + const data = useData() const theme = () => props.api.theme.current - const msg = createMemo(() => props.api.state.session.messages(props.session_id)) - const session = createMemo(() => props.api.state.session.get(props.session_id)) + const msg = createMemo(() => data.session.message.list(props.session_id)) + const session = createMemo(() => data.session.get(props.session_id)) const cost = createMemo(() => session()?.cost ?? 0) const state = createMemo(() => { - const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID) if (!last) { return { tokens: 0, @@ -27,7 +29,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) { const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + const model = data.location + .model.list(session()?.location) + ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) return { tokens, percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index c59046a017..6fb51ffafc 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -6,7 +6,7 @@ import { useTuiPaths } from "../../context/runtime" const id = "internal:sidebar-footer" -function View(props: { api: TuiPluginApi; sessionID: string }) { +function View(props: { api: TuiPluginApi; directory: string }) { const paths = useTuiPaths() const theme = () => props.api.theme.current const has = createMemo(() => @@ -17,10 +17,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const show = createMemo(() => !has() && !done()) const path = createMemo(() => { - const session = props.api.state.session.get(props.sessionID) - const dir = session?.directory || props.api.state.path.directory || paths.cwd - const out = abbreviateHome(dir, paths.home) - const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined + const out = abbreviateHome(props.directory, paths.home) + const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined const text = branch ? out + ":" + branch : out const list = text.split("/") return { @@ -84,7 +82,7 @@ const tui: TuiPlugin = async (api) => { order: 100, slots: { sidebar_footer(_ctx, props) { - return + return }, }, }) diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index ed88a1107f..1bee45dedc 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { TextAttributes, type BorderSides, @@ -56,7 +56,7 @@ type DiffFile = { readonly status: "added" | "deleted" | "modified" } -const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] => +const normalizeDiffs = (diffs: readonly (VcsFileDiff | FileDiffInfo | SnapshotFileDiff)[]): DiffFile[] => diffs.flatMap((item) => item.file ? [ diff --git a/packages/tui/src/routes/home/session-destination.tsx b/packages/tui/src/routes/home/session-destination.tsx index 352010840b..35611b00b7 100644 --- a/packages/tui/src/routes/home/session-destination.tsx +++ b/packages/tui/src/routes/home/session-destination.tsx @@ -10,7 +10,7 @@ import { import { useSync } from "../../context/sync" import { useTuiPaths } from "../../context/runtime" -export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } +export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } type Context = { destination: Accessor diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index f31b14868a..db64c7b5da 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -24,7 +24,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) { description: "undo messages and file changes", onSelect: async (dialog) => { await sdk.api.session - .revertStage({ sessionID: props.sessionID, messageID: props.messageID }) + .revert.stage({ sessionID: props.sessionID, messageID: props.messageID }) .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) dialog.clear() }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index fd970a96fe..62507b44d3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -26,14 +26,14 @@ import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../con import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { - ModelV2Info, - SessionMessage, + ModelInfo, + SessionMessageInfo, SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, SessionMessageAssistantTool, SessionMessageUser, - SessionV2Info, + SessionInfo, } from "@opencode-ai/sdk/v2" import { useLocal } from "../../context/local" import { Locale } from "../../util/locale" @@ -130,7 +130,7 @@ const context = createContext<{ showGenericToolOutput: () => boolean groupExploration: () => boolean diffWrapMode: () => "word" | "none" - models: () => ModelV2Info[] + models: () => ModelInfo[] tui: ReturnType }>() @@ -172,16 +172,6 @@ export function Session() { }) onCleanup(() => setEpilogue()) const messages = sessionMessages - const transientCompaction = createMemo(() => { - if ( - messages().some( - (message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"), - ) - ) - return - const text = data.session.compaction(route.sessionID) - return text === undefined ? undefined : { text } - }) const descendantSessionIDs = createMemo(() => { if (session()?.parentID) return [] return data.session.family(route.sessionID).filter((id) => id !== route.sessionID) @@ -208,6 +198,16 @@ export function Session() { ?.id }) + // Admitted inputs and in-flight manual compaction sit after history, not in rows. + const pendingMessages = createMemo(() => { + const boundary = session()?.revert?.messageID + return messages().filter((message) => { + if (boundary && message.id >= boundary) return false + if (data.session.input.has(route.sessionID, message.id)) return true + return message.type === "compaction" && message.status === "running" + }) + }) + const lastAssistant = createMemo(() => { return messages().findLast((x) => x.type === "assistant") }) @@ -251,37 +251,44 @@ export function Session() { }), ) - createEffect(() => { - const sessionID = route.sessionID - void (async () => { - await Promise.all([ - data.session.refresh(sessionID), - data.session.permission.refresh(sessionID), - data.session.form.refresh(sessionID), - ]) - const info = data.session.get(sessionID) - if (!info) { - toast.show({ - message: `Session not found: ${sessionID}`, - variant: "error", - duration: 5000, + createEffect( + on( + () => route.sessionID, + (sessionID) => { + void (async () => { + if (data.session.message.list(sessionID).length === 0) { + await Promise.all([ + data.session.refresh(sessionID), + data.session.message.refresh(sessionID), + data.session.permission.refresh(sessionID), + data.session.form.refresh(sessionID), + ]) + } + const info = data.session.get(sessionID) + if (!info) { + toast.show({ + message: `Session not found: ${sessionID}`, + variant: "error", + duration: 5000, + }) + navigate({ type: "home" }) + return + } + project.workspace.set(info.location.workspaceID) + editor.reconnect(info.location.directory) + if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + })().catch((error) => { + if (route.sessionID !== sessionID) return + toast.show({ + message: errorMessage(error), + variant: "error", + duration: 5000, + }) + navigate({ type: "home" }) }) - navigate({ type: "home" }) - return - } - project.workspace.set(info.location.workspaceID) - editor.reconnect(info.location.directory) - if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) - })().catch((error) => { - if (route.sessionID !== sessionID) return - toast.show({ - message: errorMessage(error), - variant: "error", - duration: 5000, - }) - navigate({ type: "home" }) - }) - }) + }, + ), + ) let seeded = false let scroll: ScrollBoxRenderable @@ -337,12 +344,14 @@ export function Session() { if (!targetID) { scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height) + if (direction === "prev") loadOlder() dialog.clear() return } const child = scroll.getChildren().find((c) => c.id === targetID) if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (direction === "prev") loadOlder() dialog.clear() } @@ -353,6 +362,24 @@ export function Session() { }, 50) } + let loadingOlder = false + function loadOlder() { + if (loadingOlder || scroll.scrollTop > 2) return + loadingOlder = true + const before = scroll.scrollHeight + void data.session.message.more(route.sessionID).then( + () => { + setTimeout(() => { + if (!scroll.isDestroyed) scroll.scrollBy(scroll.scrollHeight - before) + loadingOlder = false + }, 50) + }, + () => { + loadingOlder = false + }, + ) + } + const sessionCommandList = createMemo(() => [ { title: "Share session", @@ -437,7 +464,7 @@ export function Session() { dialog.clear() return } - const error = await sdk.api.session.revertStage({ sessionID: route.sessionID, messageID: target }).then( + const error = await sdk.api.session.revert.stage({ sessionID: route.sessionID, messageID: target }).then( () => undefined, (error) => error, ) @@ -454,7 +481,7 @@ export function Session() { slash: { name: "redo" }, run: () => { void (async () => { - const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) @@ -558,6 +585,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 2) + loadOlder() dialog.clear() }, }, @@ -578,6 +606,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-1) + loadOlder() dialog.clear() }, }, @@ -598,6 +627,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 4) + loadOlder() dialog.clear() }, }, @@ -618,6 +648,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(0) + loadOlder() dialog.clear() }, }, @@ -927,6 +958,9 @@ export function Session() { stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} + onMouseScroll={(event) => { + if (event.scroll?.direction === "up") void loadOlder() + }} > {(row) => ( @@ -936,9 +970,13 @@ export function Session() { /> )} - - {(compaction) => } - + + {(message) => ( + + + + )} + SessionMessage | undefined }) { +function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) { return ( @@ -1062,7 +1100,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) = ) } -function BackgroundToolHint(props: { messages: SessionMessage[] }) { +function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { const { theme } = useTheme() const shortcut = useCommandShortcut("session.background") const visible = createMemo(() => { @@ -1090,14 +1128,14 @@ function BackgroundToolHint(props: { messages: SessionMessage[] }) { ) } -function SessionMessageView(props: { message: SessionMessage }) { +function SessionMessageView(props: { message: SessionMessageInfo }) { return ( - } /> + } /> @@ -1106,17 +1144,17 @@ function SessionMessageView(props: { message: SessionMessage }) { when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"} > }> - } /> + } /> - } /> + } /> ) } -function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessage | undefined }) { +function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) { const message = createMemo(() => props.message(props.partRef.messageID)) const part = createMemo(() => { const item = message() @@ -1150,7 +1188,7 @@ function SessionGroupView(props: { refs: PartRef[] pending: PartRef[] completed: boolean - message: (messageID: string) => SessionMessage | undefined + message: (messageID: string) => SessionMessageInfo | undefined }) { const { theme } = useTheme() const ctx = use() @@ -1260,7 +1298,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { ) } -function SessionSwitchMessageV2(props: { message: SessionMessage }) { +function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) { const ctx = use() const { theme } = useTheme() const text = () => { @@ -1276,7 +1314,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) { ) } -function SessionNoticeMessageV2(props: { message: SessionMessage }) { +function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { const { theme } = useTheme() const text = () => { if (props.message.type === "system") return "Instructions updated" @@ -1290,7 +1328,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) { ) } -function SessionSkillMessage(props: { message: Extract }) { +function SessionSkillMessage(props: { message: Extract }) { const { theme } = useTheme() return ( @@ -1300,7 +1338,7 @@ function SessionSkillMessage(props: { message: Extract + message?: Extract status?: "running" text?: string }) { @@ -1308,9 +1346,10 @@ function CompactionMessage(props: { const kv = useKV() const { theme, syntax } = useTheme() const status = () => props.message?.status ?? props.status - const text = () => props.message?.summary ?? props.text ?? "" + const text = () => + props.message?.status === "failed" ? props.message.error.message : (props.message?.summary ?? props.text ?? "") const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted) - const border = () => (status() === "queued" ? theme.border : color()) + const border = color return ( @@ -1328,11 +1367,8 @@ function CompactionMessage(props: { - - - - {status() === "queued" ? "Compaction queued" : "Compaction"} + Compaction @@ -1363,7 +1399,7 @@ function statusLabel(status: "added" | "modified" | "deleted") { function RevertMessage(props: { count: number files: ReadonlyArray<{ - readonly path: string + readonly file: string readonly status: "added" | "modified" | "deleted" readonly additions: number readonly deletions: number @@ -1383,7 +1419,7 @@ function RevertMessage(props: { onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return void (async () => { - const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) @@ -1412,7 +1448,7 @@ function RevertMessage(props: { {statusLabel(file.status)} - {Locale.truncateLeft(file.path, 60)} + {Locale.truncateLeft(file.file, 60)} 0}> +{file.additions} @@ -1433,7 +1469,7 @@ function RevertMessage(props: { ) } -function ShellMessage(props: { message: Extract }) { +function ShellMessage(props: { message: Extract }) { const { theme } = useTheme() const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? "")) @@ -1448,7 +1484,7 @@ function ShellMessage(props: { message: Extract - $ {props.message.shell.command} + $ {props.message.command} {output()} @@ -1560,7 +1596,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole .map((part) => part.type === "tool" && ["read", "glob", "grep"].includes(toolDisplay(part.name)) && - part.state.status !== "pending" + part.state.status !== "streaming" ? part : undefined, ) @@ -1832,7 +1868,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { const data = useData() const display = createMemo(() => toolDisplay(props.part.name)) const activeBackgroundWork = createMemo(() => { - if (props.part.state.status === "pending") return false + if (props.part.state.status === "streaming") return false if (display() === "shell") { const shellID = stringValue(props.part.state.structured.shellID) return Boolean(shellID && data.shell.get(shellID)) @@ -1856,13 +1892,13 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { const toolprops = { get metadata() { - return props.part.state.status === "pending" ? {} : props.part.state.structured + return props.part.state.status === "streaming" ? {} : props.part.state.structured }, get input() { return typeof props.part.state.input === "string" ? {} : props.part.state.input }, get output() { - if (props.part.state.status === "pending") return undefined + if (props.part.state.status === "streaming") return undefined return props.part.state.content .flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri])) .join("\n") @@ -1908,7 +1944,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { - + @@ -2187,7 +2223,7 @@ function Shell(props: ToolProps) { const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning()) const command = createMemo(() => stringValue(props.input.command)) const output = createMemo(() => { - if (props.part.state.status === "pending") return "" + if (props.part.state.status === "streaming") return "" if (shellID()) return "" const content = props.part.state.content[0] return stripAnsi(content?.type === "text" ? content.text.trim() : "") @@ -2209,7 +2245,7 @@ function Shell(props: ToolProps) { Writing command... ) : ( Writing command... @@ -2425,7 +2461,7 @@ function executeCalls(value: unknown): ExecuteCall[] { function Execute(props: ToolProps) { const ctx = use() const { theme } = useTheme() - const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running") + const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running") const calls = createMemo(() => executeCalls(props.metadata.toolCalls)) const output = createMemo(() => stripAnsi(props.output?.trim() ?? "")) const hasRuntimeError = createMemo(() => props.metadata.error === true) @@ -2521,7 +2557,7 @@ function Edit(props: ToolProps) { : "# Preparing edit..." } part={props.part} - spinner={props.part.state.status === "pending"} + spinner={props.part.state.status === "streaming"} /> @@ -2613,7 +2649,7 @@ function ApplyPatch(props: ToolProps) { : "# Preparing patch..." } part={props.part} - spinner={props.part.state.status === "pending"} + spinner={props.part.state.status === "streaming"} /> @@ -2744,7 +2780,7 @@ const toolDisplays = new Set([ "edit", "subagent", "execute", - "apply_patch", + "patch", "todowrite", "question", "skill", @@ -2753,7 +2789,7 @@ const toolDisplays = new Set([ export function toolDisplay(tool: string) { // Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render // them with the renamed views. - const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool + const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool return toolDisplays.has(normalized) ? normalized : "generic" } @@ -2763,15 +2799,15 @@ function recordValue(value: unknown): Record | undefined { } function formatSessionTranscript( - session: SessionV2Info, - messages: SessionMessage[], + session: SessionInfo, + messages: SessionMessageInfo[], thinking: boolean, toolDetails: boolean, ) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] if (message.type === "shell") - return [`## Shell\n\n\`\`\`\n$ ${message.shell.command}\n${message.output?.output ?? ""}\n\`\`\``] + return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``] if (message.type !== "assistant") return [] const content = message.content.flatMap((item) => { if (item.type === "text") return [item.text] @@ -2781,7 +2817,7 @@ function formatSessionTranscript( const output = item.state.status === "error" ? item.state.error.message - : item.state.status === "pending" + : item.state.status === "streaming" ? "" : item.state.content .flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri])) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index a137d6272a..a0ca4775c3 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -148,7 +148,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const message = data.session.message.get(props.request.sessionID, tool.messageID) if (message?.type !== "assistant") return {} const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID) - if (part?.type === "tool" && part.state.status !== "pending") return part.state.input + if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input return {} }) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 5d2a361452..ee51769b42 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,6 +1,6 @@ -import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" import { createEffect, on, onCleanup, type Accessor } from "solid-js" -import { createStore, produce, reconcile } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { useData } from "../../context/data" export type PartRef = { @@ -25,11 +25,21 @@ export function createSessionRows(sessionID: Accessor) { const [rows, setRows] = createStore([]) const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID + function pendingIDs() { + const inputs = data.session.input.list(sessionID()) + const pending = new Set(inputs) + for (const message of data.session.message.list(sessionID())) { + if (message.type === "compaction" && message.status === "running") pending.add(message.id) + } + return pending + } + function reduce() { const messages = data.session.message.list(sessionID()) - const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() - const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) + const visible = boundary ? messages.filter((message) => message.id < boundary) : messages + const pending = pendingIDs() + const rows = reduceSessionRows(visible.filter((message) => !pending.has(message.id))) partitionPending(rows, pendingPermissions()) return rows } @@ -52,48 +62,30 @@ export function createSessionRows(sessionID: Accessor) { }) createEffect( - on(sessionID, (id) => { - setRows(reconcile(reduce())) - void data.session.message.refresh(id).then( - () => { - if (sessionID() !== id) return - setRows(reconcile(reduce())) - }, - () => undefined, - ) + on(sessionID, () => { + setRows(reduce()) }), ) - // Re-reduce when the revert boundary changes (stage/clear/commit). createEffect( on(revertBoundary, () => { - setRows(reconcile(reduce())) + setRows(reduce()) }), ) + // Pending inputs and compaction leaving the pending set change history membership. createEffect( on( - () => - data.session.message.list(sessionID()).flatMap((message) => - message.type === "user" - ? [ - { - id: message.id, - created: message.time.created, - input: data.session.input.has(sessionID(), message.id), - }, - ] - : message.type === "compaction" - ? [ - { - id: message.id, - created: message.time.created, - input: message.status === "queued" || message.status === "running", - }, - ] - : [], - ), - () => setRows(reconcile(reduce())), + () => { + const messages = data.session.message.list(sessionID()) + const pending = data.session.input.list(sessionID()).join("\0") + const compaction = messages + .filter((message) => message.type === "compaction") + .map((message) => `${message.id}:${message.status}`) + .join("\0") + return `${pending}\u0001${compaction}` + }, + () => setRows(reduce()), ), ) @@ -101,12 +93,9 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return - const pending = isPending(messageID) - const message = data.session.message.get(sessionID(), messageID) - const index = - message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft) - if (!pending) completePrevious(draft, index) - draft.splice(index, 0, { type: "message", messageID }) + if (pendingIDs().has(messageID)) return + completePrevious(draft) + draft.push({ type: "message", messageID }) }), ) @@ -114,15 +103,14 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (hasPart(draft, ref)) return - const index = queuedStart(draft) if (name && exploration(name)) { - const previous = draft[index - 1] + const previous = draft.at(-1) if (previous?.type === "group" && previous.kind === "exploration") { previous.refs.push(ref) return } - completePrevious(draft, index) - draft.splice(index, 0, { + completePrevious(draft) + draft.push({ type: "group", kind: "exploration", refs: [ref], @@ -131,8 +119,8 @@ export function createSessionRows(sessionID: Accessor) { }) return } - completePrevious(draft, index) - draft.splice(index, 0, { type: "part", ref }) + completePrevious(draft) + draft.push({ type: "part", ref }) }), ) @@ -140,9 +128,8 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return - const index = queuedStart(draft) - completePrevious(draft, index) - draft.splice(index, 0, { type: "assistant-footer", messageID }) + completePrevious(draft) + draft.push({ type: "assistant-footer", messageID }) }), ) @@ -154,26 +141,13 @@ export function createSessionRows(sessionID: Accessor) { }), ) - const isPending = (messageID: string) => { - const message = data.session.message.get(sessionID(), messageID) - if (message?.type === "user") return data.session.input.has(sessionID(), messageID) - return message?.type === "compaction" && (message.status === "queued" || message.status === "running") - } - - const queuedStart = (rows: SessionRow[]) => { - const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID)) - return index === -1 ? rows.length : index - } - const message = (event: { id: string; data: { sessionID: string } }) => { if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_")) } - const input = (event: { data: { sessionID: string; inputID: string } }) => { - if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) - } const subscriptions = [ - data.on("session.prompt.admitted", input), - data.on("session.compaction.admitted", input), + data.on("session.prompt.promoted", (event) => { + if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) + }), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { if (event.data.sessionID === sessionID() && event.data.description?.trim()) @@ -182,9 +156,7 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.shell.started", message), data.on("session.agent.selected", message), data.on("session.model.selected", message), - data.on("session.compaction.ended", (event) => { - if (event.data.reason !== "manual") message(event) - }), + data.on("session.text.delta", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) @@ -224,20 +196,11 @@ export function createSessionRows(sessionID: Accessor) { return rows } -export function reduceSessionRows(messages: SessionMessage[], inputs = new Set()) { - const isInput = (message: SessionMessage) => inputs.has(message.id) - const pendingCompactions = messages.filter( - (message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"), - ) - const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) - return [ - ...messages.filter((message) => !pending.has(message.id)), - ...pendingCompactions, - ...messages.filter(isInput), - ].reduce((rows, message) => { +export function reduceSessionRows(messages: SessionMessageInfo[]) { + return messages.reduce((rows, message) => { if (message.type !== "assistant") { if (message.type === "synthetic" && !message.description?.trim()) return rows - if (!pending.has(message.id)) completePrevious(rows) + completePrevious(rows) rows.push({ type: "message", messageID: message.id }) return rows } diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 5639f6451f..bd415768a2 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -85,7 +85,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - + Open diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 71b3a161e5..f02ef3f20e 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -6,6 +6,7 @@ import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" import { useTerminalDimensions } from "@opentui/solid" import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" +import { lastAssistantWithUsage } from "../../util/session" export function SubagentFooter() { const route = useRouteData("session") @@ -22,17 +23,15 @@ export function SubagentFooter() { const usage = createMemo(() => { const current = session() if (!current) return + const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID) + if (!last) return const tokens = - current.tokens.input + - current.tokens.output + - current.tokens.reasoning + - current.tokens.cache.read + - current.tokens.cache.write + last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write if (tokens <= 0) return const model = data.location .model.list(current.location) - ?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id) + ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = current.cost @@ -83,10 +82,10 @@ export function SubagentFooter() { setHover("parent")} - onMouseOut={() => setHover(null)} + onMouseOver={() => setHover("parent")} + onMouseOut={() => setHover(null)} onMouseUp={() => keymap.dispatchCommand("session.parent")} - backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > Parent {parentShortcut()} diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 94ccad22d0..41f583b1c6 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,3 +1,14 @@ +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" + export function isDefaultTitle(title: string) { return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) } + +export function lastAssistantWithUsage(messages: ReadonlyArray, boundary?: string) { + const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1 + if (boundary && boundaryIndex === -1) return undefined + return messages.findLast( + (message, index): message is SessionMessageAssistant & { tokens: NonNullable } => + message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex), + ) +} diff --git a/packages/tui/src/util/tool-display.ts b/packages/tui/src/util/tool-display.ts index 18e256bd34..c9d92434e2 100644 --- a/packages/tui/src/util/tool-display.ts +++ b/packages/tui/src/util/tool-display.ts @@ -6,7 +6,7 @@ export function webSearchProviderLabel(provider: unknown) { export function toolDisplayMetadata(state: unknown): Record { if (!state || typeof state !== "object" || Array.isArray(state)) return {} - if (!("status" in state) || state.status === "pending") return {} + if (!("status" in state) || state.status === "streaming") return {} if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {} if (Array.isArray(state.structured)) return {} return state.structured as Record diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index c08b557eee..ac3abc9581 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -93,7 +93,7 @@ function permission(id: string, sessionID = "session"): PermissionRequest { } } -function durable(sessionID: string) { +function durable(sessionID: string): { aggregateID: string; seq: number; version: 1 } { return { aggregateID: sessionID, seq: 0, version: 1 } } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b7b349a196..b17e6eea57 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -24,6 +24,12 @@ function emitEvent(events: ReturnType, event: V2Event) events.emit({ ...event, location: { directory } }) } +function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 } +function durable( + sessionID: string, + seq: number, + version: Version, +): { aggregateID: string; seq: number; version: Version } function durable(sessionID: string, seq = 0, version = 1) { return { aggregateID: sessionID, seq, version } } @@ -108,6 +114,356 @@ test("refreshes resources into reactive getters", async () => { } }) +test("pages older messages through nested message state", async () => { + const events = createEventStream() + const sessionID = "ses_message_page" + const pages: Array<{ limit?: string | null; order?: string | null; cursor?: string | null }> = [] + // Full first page (desc) so complete stays false until a short older page arrives. + const first = Array.from({ length: 50 }, (_, index) => { + const n = 51 - index + return { id: `msg_${n}`, type: "user" as const, text: String(n), time: { created: n } } + }) + const calls = createFetch((url) => { + if (url.pathname !== `/api/session/${sessionID}/message`) return + pages.push({ + limit: url.searchParams.get("limit"), + order: url.searchParams.get("order"), + cursor: url.searchParams.get("cursor"), + }) + if (!url.searchParams.get("cursor")) + return json({ + data: first, + cursor: { next: "cursor-older" }, + }) + return json({ + data: [{ id: "msg_1", type: "user", text: "one", time: { created: 1 } }], + cursor: {}, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await data.session.message.refresh(sessionID) + expect(pages).toEqual([{ limit: "50", order: "desc", cursor: null }]) + expect(data.session.message.ids(sessionID)).toEqual(first.toReversed().map((message) => message.id)) + expect(data.session.message.cursor(sessionID)).toBe("cursor-older") + expect(data.session.message.complete(sessionID)).toBe(false) + expect(data.session.message.loading(sessionID)).toBe(false) + + await data.session.message.more(sessionID) + expect(pages).toEqual([ + { limit: "50", order: "desc", cursor: null }, + { limit: "50", order: null, cursor: "cursor-older" }, + ]) + expect(data.session.message.ids(sessionID)[0]).toBe("msg_1") + expect(data.session.message.ids(sessionID)).toHaveLength(51) + expect(data.session.message.cursor(sessionID)).toBeUndefined() + expect(data.session.message.complete(sessionID)).toBe(true) + + await data.session.message.more(sessionID) + expect(pages).toHaveLength(2) + } finally { + app.renderer.destroy() + } +}) + +test("applies absolute usage events to session info", async () => { + const events = createEventStream() + const sessionID = "ses_usage_refresh" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}`) + return json({ + data: { + id: sessionID, + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Usage", + location: { directory }, + }, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await data.session.refresh(sessionID) + emitEvent(events, { + id: "evt_usage_2", + created: 2, + type: "session.usage.updated", + data: { + sessionID, + cost: 0.5, + tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + await wait(() => data.session.get(sessionID)?.cost === 0.5) + expect(data.session.get(sessionID)?.tokens).toEqual({ + input: 5, + output: 2, + reasoning: 1, + cache: { read: 1, write: 1 }, + }) + + emitEvent(events, { + id: "evt_usage_3", + created: 3, + type: "session.usage.updated", + data: { + sessionID, + cost: 1, + tokens: { input: 10, output: 4, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + await wait(() => data.session.get(sessionID)?.cost === 1) + expect(data.session.get(sessionID)?.title).toBe("Usage") + + emitEvent(events, { + id: "evt_usage_deleted", + created: 9, + type: "session.deleted", + durable: durable(sessionID, 9, 2), + data: { sessionID }, + }) + await wait(() => data.session.get(sessionID) === undefined) + } finally { + app.renderer.destroy() + } +}) + +test("truncates committed revert messages without changing lifetime usage", async () => { + const events = createEventStream() + const sessionID = "ses_revert_usage" + let cost = 0 + let tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + if (url.pathname !== `/api/session/${sessionID}`) return + return json({ + data: { + id: sessionID, + projectID: "proj_test", + cost, + tokens, + time: { created: 0, updated: 0 }, + title: "Revert usage", + location: { directory }, + }, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await data.session.refresh(sessionID) + emitEvent(events, { + id: "evt_revert_boundary_started", + created: 1, + type: "session.step.started", + durable: durable(sessionID, 1), + data: { + sessionID, + assistantMessageID: "msg_revert_boundary", + agent: "build", + model: { providerID: "provider", id: "model" }, + }, + }) + cost = 0.5 + tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } } + emitEvent(events, { + id: "evt_revert_boundary_ended", + created: 2, + type: "session.step.ended", + durable: durable(sessionID, 2), + data: { + sessionID, + assistantMessageID: "msg_revert_boundary", + finish: "stop", + cost: 0.5, + tokens, + }, + }) + emitEvent(events, { + id: "evt_revert_boundary_usage", + created: 2, + type: "session.usage.updated", + data: { sessionID, cost, tokens }, + }) + await wait(() => data.session.get(sessionID)?.cost === 0.5) + + emitEvent(events, { + id: "evt_revert_later_started", + created: 3, + type: "session.step.started", + durable: durable(sessionID, 3), + data: { + sessionID, + assistantMessageID: "msg_revert_later", + agent: "build", + model: { providerID: "provider", id: "model" }, + }, + }) + cost = 0.75 + tokens = { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } } + emitEvent(events, { + id: "evt_revert_later_ended", + created: 4, + type: "session.step.ended", + durable: durable(sessionID, 4), + data: { + sessionID, + assistantMessageID: "msg_revert_later", + finish: "stop", + cost: 0.25, + tokens: { input: 3, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }) + emitEvent(events, { + id: "evt_revert_later_usage", + created: 4, + type: "session.usage.updated", + data: { sessionID, cost, tokens }, + }) + await wait(() => data.session.get(sessionID)?.cost === 0.75) + emitEvent(events, { + id: "evt_revert_staged", + created: 5, + type: "session.revert.staged", + durable: durable(sessionID, 5), + data: { sessionID, revert: { messageID: "msg_revert_later" } }, + }) + await wait(() => data.session.get(sessionID)?.revert?.messageID === "msg_revert_later") + + emitEvent(events, { + id: "evt_revert_committed", + created: 6, + type: "session.revert.committed", + durable: durable(sessionID, 6), + data: { sessionID, to: "msg_revert_later" }, + }) + await wait(() => data.session.message.ids(sessionID).length === 1) + expect(data.session.get(sessionID)?.cost).toBe(0.75) + expect(data.session.message.ids(sessionID)).toEqual(["msg_revert_boundary"]) + expect(data.session.get(sessionID)?.revert).toBeUndefined() + expect(data.session.get(sessionID)?.tokens).toEqual(tokens) + } finally { + app.renderer.destroy() + } +}) + +test("updates session location when moved", async () => { + const events = createEventStream() + const destination = "/tmp/opencode-moved" + const calls = createFetch((url) => { + if (url.pathname === "/api/session/ses_test") + return json({ + data: { + id: "ses_test", + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Test session", + location: { directory }, + }, + }) + }, events) + let data!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + data = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + await data.session.refresh("ses_test") + emitEvent(events, { + id: "evt_moved_1", + created: 1, + type: "session.moved", + durable: durable("ses_test"), + data: { + sessionID: "ses_test", + location: { directory: destination }, + subpath: "packages/cli", + }, + }) + await wait(() => data.session.get("ses_test")?.location.directory === destination) + expect(data.session.get("ses_test")?.subpath).toBe("packages/cli") + } finally { + app.renderer.destroy() + } +}) + test("restores running manual compaction before applying live deltas", async () => { const events = createEventStream() const calls = createFetch((url) => { @@ -148,7 +504,11 @@ test("restores running manual compaction before applying live deltas", async () try { await data.session.message.refresh("session-compaction") - expect(data.session.compaction("session-compaction")).toBe("Existing ") + expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({ + type: "compaction", + status: "running", + summary: "Existing ", + }) emitEvent(events, { id: "evt_compaction_delta", @@ -159,7 +519,7 @@ test("restores running manual compaction before applying live deltas", async () await wait(() => { const message = data.session.message.get("session-compaction", "message-compaction") - return message?.type === "compaction" && message.summary === "Existing summary" + return message?.type === "compaction" && message.status === "running" && message.summary === "Existing summary" }) } finally { app.renderer.destroy() @@ -234,15 +594,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { expect(data.connection.error()).toBe("Event stream disconnected") await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000) - emitEvent(events, { - id: "evt_execution_started_after_reconnect", - created: 1, - type: "session.execution.started", - durable: durable("session-new"), - data: { sessionID: "session-new" }, - }) - await wait(() => data.session.status("session-new") === "running") - resolveActive(json({ data: {} })) + resolveActive(json({ data: { "session-new": { type: "running" } } })) await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000) await wait(() => data.session.status("session-stale") === "idle") @@ -256,16 +608,18 @@ test("reconnects the event stream and bootstraps fresh data", async () => { } }) -test("completes exploration when a queued prompt is promoted", async () => { +test("keeps pending prompts out of history rows until promoted", async () => { const events = createEventStream() const sessionID = "session-promotion" const calls = createFetch((url) => { if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) }, events) let rows!: ReturnType + let data!: ReturnType function Probe() { rows = createSessionRows(() => sessionID) + data = useData() return } @@ -320,7 +674,8 @@ test("completes exploration when a queued prompt is promoted", async () => { delivery: "steer", }, }) - await wait(() => rows.at(-1)?.type === "message") + await wait(() => data.session.input.has(sessionID, "message-user")) + expect(rows.some((row) => row.type === "message" && row.messageID === "message-user")).toBe(false) expect(rows.find((row) => row.type === "group")?.completed).toBe(false) emitEvent(events, { @@ -330,7 +685,8 @@ test("completes exploration when a queued prompt is promoted", async () => { durable: durable(sessionID, 3), data: { sessionID, inputID: "message-user" }, }) - await wait(() => rows.find((row) => row.type === "group")?.completed === true) + await wait(() => rows.some((row) => row.type === "message" && row.messageID === "message-user")) + expect(data.session.input.has(sessionID, "message-user")).toBe(false) expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" }) } finally { app.renderer.destroy() @@ -455,8 +811,35 @@ test("connectedOnce is false until first connect and persists across disconnect" test("tracks session status from active sessions and execution events", async () => { const events = createEventStream() + let settled = false const calls = createFetch((url) => { if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } }) + if (url.pathname === "/api/session/session-live") + return json({ + data: { + id: "session-live", + projectID: "proj_test", + cost: settled ? 0.75 : 0, + tokens: settled + ? { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } } + : { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Live session", + location: { directory }, + }, + }) + if (url.pathname === "/api/session/session-failed") + return json({ + data: { + id: "session-failed", + projectID: "proj_test", + cost: 0.25, + tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Failed session", + location: { directory }, + }, + }) }, events) let data!: ReturnType let rows!: SessionRow[] @@ -484,7 +867,9 @@ test("tracks session status from active sessions and execution events", async () try { await wait(() => data.session.status("session-active") === "running") expect(data.session.status("session-idle")).toBe("idle") + await data.session.refresh("session-live") + settled = true emitEvent(events, { id: "evt_execution_started", created: 0, @@ -510,30 +895,46 @@ test("tracks session status from active sessions and execution events", async () id: "evt_step_ended", created: 0, type: "session.step.ended", - durable: durable("session-live", 1, 2), + durable: durable("session-live", 1), data: { sessionID: "session-live", assistantMessageID: "message-live", finish: "stop", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: 0.75, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, + }, + }) + emitEvent(events, { + id: "evt_step_usage", + created: 0, + type: "session.usage.updated", + data: { + sessionID: "session-live", + cost: 0.75, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, }, }) await wait(() => { const assistant = data.session.message.get("session-live", "message-live") return assistant?.type === "assistant" && assistant.finish === "stop" }) + await wait(() => data.session.get("session-live")?.cost === 0.75) expect(data.session.status("session-live")).toBe("running") + expect(data.session.get("session-live")).toMatchObject({ + cost: 0.75, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, + }) emitEvent(events, { id: "evt_execution_succeeded", created: 0, type: "session.execution.succeeded", - durable: durable("session-live", 1, 3), + durable: durable("session-live", 1), data: { sessionID: "session-live" }, }) await wait(() => data.session.status("session-live") === "idle") + await data.session.refresh("session-failed") emitEvent(events, { id: "evt_failed_execution_started", created: 0, @@ -559,11 +960,23 @@ test("tracks session status from active sessions and execution events", async () id: "evt_step_failed", created: 0, type: "session.step.failed", - durable: durable("session-failed", 1, 2), + durable: durable("session-failed", 1), data: { sessionID: "session-failed", assistantMessageID: "message-failed", error: { type: "provider.content-filter", message: "Provider blocked the response" }, + cost: 0.25, + tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, + }, + }) + emitEvent(events, { + id: "evt_failed_step_usage", + created: 0, + type: "session.usage.updated", + data: { + sessionID: "session-failed", + cost: 0.25, + tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, }, }) await wait(() => { @@ -574,13 +987,20 @@ test("tracks session status from active sessions and execution events", async () assistant.error?.type === "provider.content-filter" ) }) + await wait(() => data.session.get("session-failed")?.cost === 0.25) + expect(data.session.get("session-failed")?.tokens).toEqual({ + input: 5, + output: 1, + reasoning: 1, + cache: { read: 1, write: 0 }, + }) expect(data.session.status("session-failed")).toBe("running") emitEvent(events, { id: "evt_failed_execution_failed", created: 0, type: "session.execution.failed", - durable: durable("session-failed", 1, 3), + durable: durable("session-failed", 1), data: { sessionID: "session-failed", error: { type: "provider.content-filter", message: "Provider blocked the response" }, @@ -599,7 +1019,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_step_started", created: 0, type: "session.step.started", - durable: durable("session-retry", 1, 2), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", assistantMessageID: "message-retry", @@ -611,7 +1031,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_scheduled", created: 0, type: "session.retry.scheduled", - durable: durable("session-retry", 1, 3), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", assistantMessageID: "message-retry", @@ -629,7 +1049,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_next_step", created: 2_000, type: "session.step.started", - durable: durable("session-retry", 1, 4), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", assistantMessageID: "message-retry", @@ -647,7 +1067,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_scheduled_again", created: 2_000, type: "session.retry.scheduled", - durable: durable("session-retry", 1, 5), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", assistantMessageID: "message-retry", @@ -664,29 +1084,18 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_interrupted", created: 2_000, type: "session.execution.interrupted", - durable: durable("session-retry", 1, 6), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", reason: "shutdown" }, }) await wait(() => data.session.status("session-retry") === "idle") expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry") - emitEvent(events, { - id: "evt_compaction_admitted", - created: 0, - type: "session.compaction.admitted", - durable: durable("session-manual", 1), - data: { sessionID: "session-manual", inputID: "message-compaction" }, - }) - await wait(() => { - const message = data.session.message.get("session-manual", "message-compaction") - return message?.type === "compaction" && message.status === "queued" - }) emitEvent(events, { id: "evt_manual_compaction_started", created: 1, type: "session.compaction.started", durable: durable("session-manual", 2), - data: { sessionID: "session-manual", reason: "manual" }, + data: { sessionID: "session-manual", reason: "manual", recent: "", inputID: "message-compaction" }, }) emitEvent(events, { id: "evt_manual_compaction_delta", @@ -696,13 +1105,13 @@ test("tracks session status from active sessions and execution events", async () }) await wait(() => { const message = data.session.message.get("session-manual", "message-compaction") - return message?.type === "compaction" && message.summary === "Streamed summary" + return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary" }) emitEvent(events, { id: "evt_manual_compaction_ended", created: 3, type: "session.compaction.ended", - durable: durable("session-manual", 3), + durable: durable("session-manual", 4), data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" }, }) await wait(() => { @@ -718,7 +1127,7 @@ test("tracks session status from active sessions and execution events", async () created: 0, type: "session.compaction.started", durable: durable("session-live", 2), - data: { sessionID: "session-live", reason: "auto" }, + data: { sessionID: "session-live", reason: "auto", recent: "" }, }) emitEvent(events, { id: "evt_compaction_delta_1", @@ -732,18 +1141,25 @@ test("tracks session status from active sessions and execution events", async () type: "session.compaction.delta", data: { sessionID: "session-live", text: "summary" }, }) - await wait(() => data.session.compaction("session-live") === "Live summary") + await wait(() => { + const message = data.session.message.get("session-live", "msg_compaction_started") + return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary" + }) emitEvent(events, { id: "evt_compaction_ended", created: 0, type: "session.compaction.ended", - durable: durable("session-live", 3), + durable: durable("session-live", 5), data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" }, }) - await wait(() => data.session.compaction("session-live") === undefined) - expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({ + await wait(() => { + const message = data.session.message.get("session-live", "msg_compaction_started") + return message?.type === "compaction" && message.status === "completed" + }) + expect(data.session.message.get("session-live", "msg_compaction_started")).toMatchObject({ type: "compaction", + status: "completed", summary: "Live summary", }) } finally { @@ -1092,6 +1508,52 @@ test("adds and dismisses permission requests from live events", async () => { } }) +test("reconciles all pending permission requests when the event stream reconnects", async () => { + const events = createEventStream() + let requests = [ + { id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] }, + { id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] }, + ] + let calls = 0 + const fetch = createFetch((url) => { + if (url.pathname !== "/api/permission/request") return + calls++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old") + expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep") + + requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }] + events.disconnect() + + await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new") + expect(data.session.permission.list("ses_old")).toBeUndefined() + expect(data.session.permission.list("ses_keep")).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) + test("adds, dismisses, and refreshes form requests", async () => { const events = createEventStream() const calls = createFetch((url) => { @@ -1162,6 +1624,52 @@ test("adds, dismisses, and refreshes form requests", async () => { } }) +test("reconciles all pending form requests when the event stream reconnects", async () => { + const events = createEventStream() + let requests = [ + { id: "frm_old", sessionID: "ses_old", mode: "form" as const, fields: [] }, + { id: "frm_keep", sessionID: "ses_keep", mode: "url" as const, url: "https://example.com" }, + ] + let calls = 0 + const fetch = createFetch((url) => { + if (url.pathname !== "/api/form/request") return + calls++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old") + expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep") + + requests = [{ id: "frm_new", sessionID: "ses_new", mode: "form" as const, fields: [] }] + events.disconnect() + + await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new") + expect(data.session.form.list("ses_old")).toBeUndefined() + expect(data.session.form.list("ses_keep")).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) + test("settles pending tools when a live failure arrives", async () => { const events = createEventStream() const calls = createFetch((url) => { diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 402db8b5fe..c95e3834d2 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -122,6 +122,8 @@ describe("TUI inline tool wrapping", () => { // Legacy tool names normalize to their renamed views. expect(toolDisplay("bash")).toBe("shell") expect(toolDisplay("task")).toBe("subagent") + expect(toolDisplay("apply_patch")).toBe("patch") + expect(toolDisplay("patch")).toBe("patch") expect(toolDisplay("plugin_tool")).toBe("generic") }) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 362ab40369..1def9caa55 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test" -import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" import { reduceSessionRows } from "../../../src/routes/session/rows" test("groups exploration parts across assistant messages until a delimiter", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, assistant("assistant-1", [ { type: "text", text: "Looking" }, @@ -35,7 +35,7 @@ test("groups exploration parts across assistant messages until a delimiter", () }) test("keeps non-exploration tools as individual part rows", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }, { type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } }, @@ -63,7 +63,7 @@ test("keeps non-exploration tools as individual part rows", () => { }) test("assigns stable kind ordinals within an assistant message", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ { type: "text", text: "First" }, { type: "reasoning", text: "Think" }, @@ -81,7 +81,7 @@ test("assigns stable kind ordinals within an assistant message", () => { }) test("groups across empty assistant reasoning parts", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ { type: "reasoning", text: "Looking" }, { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, @@ -112,7 +112,7 @@ test("completes exploration groups when another row follows", () => { { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, ]) finished.finish = "stop" - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "user", id: "user-1", text: "Continue", time: { created: 2 } }, finished, @@ -139,12 +139,11 @@ test("completes exploration groups when another row follows", () => { }) test("hides synthetic messages without descriptions", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "synthetic", id: "synthetic-1", - sessionID: "session-1", text: "internal context", time: { created: 2 }, }, @@ -166,12 +165,11 @@ test("hides synthetic messages without descriptions", () => { }) test("renders synthetic messages with descriptions", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "synthetic", id: "synthetic-1", - sessionID: "session-1", text: "internal context", description: "Explicit notice", time: { created: 2 }, @@ -209,31 +207,25 @@ test("renders a footer for a pre-output retry assistant after replay", () => { expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }]) }) -test("places a pending compaction barrier before every queued user message", () => { - const queued = (id: string, text: string, created: number): SessionMessage => ({ - type: "user", - id, - text, - time: { created }, - }) - const messages: SessionMessage[] = [ - queued("user-before", "Before", 1), +test("history reduce keeps chronological order without pending reordering", () => { + const messages: SessionMessageInfo[] = [ + { type: "user", id: "user-1", text: "Before", time: { created: 1 } }, { type: "compaction", id: "compaction", - status: "queued", + status: "completed", reason: "manual", - summary: "", + summary: "done", recent: "", time: { created: 2 }, }, - queued("user-after", "After", 3), + { type: "user", id: "user-2", text: "After", time: { created: 3 } }, ] - expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([ + expect(reduceSessionRows(messages)).toEqual([ + { type: "message", messageID: "user-1" }, { type: "message", messageID: "compaction" }, - { type: "message", messageID: "user-before" }, - { type: "message", messageID: "user-after" }, + { type: "message", messageID: "user-2" }, ]) }) @@ -249,5 +241,5 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses } function pending() { - return { status: "pending" as const, input: "" } + return { status: "streaming" as const, input: "" } } diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 8c6e476db0..2c10ed43a2 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -90,7 +90,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType { test("recognizes generated parent and child titles", () => { @@ -7,4 +8,22 @@ describe("util.session", () => { expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue() expect(isDefaultTitle("New session - custom")).toBeFalse() }) + + test("tracks usage across undo and redo boundaries", () => { + const assistant = (id: string, input: number): SessionMessageInfo => ({ + id, + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0 }, + }) + const messages = [assistant("msg_z", 10), assistant("msg_a", 30)] + + expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30) + expect(lastAssistantWithUsage(messages, "msg_a")?.tokens.input).toBe(10) + expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined() + expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30) + }) }) diff --git a/packages/tui/test/util/tool-display.test.ts b/packages/tui/test/util/tool-display.test.ts index f77f54cbab..ca08e7d974 100644 --- a/packages/tui/test/util/tool-display.test.ts +++ b/packages/tui/test/util/tool-display.test.ts @@ -31,7 +31,7 @@ describe("toolDisplayMetadata", () => { }) test("does not expose pending or malformed metadata", () => { - expect(toolDisplayMetadata({ status: "pending", structured: { provider: "exa" } })).toEqual({}) + expect(toolDisplayMetadata({ status: "streaming", structured: { provider: "exa" } })).toEqual({}) expect(toolDisplayMetadata({ status: "completed" })).toEqual({}) expect(toolDisplayMetadata({ status: "completed", structured: null })).toEqual({}) expect(toolDisplayMetadata({ status: "completed", structured: [] })).toEqual({}) diff --git a/packages/web/src/content/docs/ar/cli.mdx b/packages/web/src/content/docs/ar/cli.mdx index 5aa9b781f9..e592b55ef5 100644 --- a/packages/web/src/content/docs/ar/cli.mdx +++ b/packages/web/src/content/docs/ar/cli.mdx @@ -608,7 +608,6 @@ opencode upgrade v0.1.48 | `OPENCODE_EXPERIMENTAL_EXA` | boolean | تفعيل ميزات Exa التجريبية | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | تمكين TY LSP لملفات python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | تفعيل وضع الخطة | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | تفعيل مهام subagent في الخلفية | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | تفعيل نظام الأحداث التجريبي | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | تفعيل مسار طلبات LLM الأصلي | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | تفعيل تنفيذ بحث الويب بالتوازي | diff --git a/packages/web/src/content/docs/bs/cli.mdx b/packages/web/src/content/docs/bs/cli.mdx index 8883e6889a..a6e8366ca6 100644 --- a/packages/web/src/content/docs/bs/cli.mdx +++ b/packages/web/src/content/docs/bs/cli.mdx @@ -606,7 +606,6 @@ Ove varijable okruženja omogućavaju eksperimentalne karakteristike koje se mog | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Omogući eksperimentalne Exa funkcije | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Omogući TY LSP za python datoteke | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Omogući Plan mod | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Omogući pozadinske zadatke subagenata | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Omogući eksperimentalni sistem događaja | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Omogući nativnu putanju LLM zahtjeva | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Omogući paralelno izvršavanje web pretrage | diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 94d9ba3c75..7484521364 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -725,7 +725,6 @@ These environment variables enable experimental features that may change or be r | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Enable experimental Exa features | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Enable TY LSP for python files | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Enable plan mode | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Enable background subagent tasks | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Enable experimental event system | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Enable native LLM request path | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Enable parallel web search execution | diff --git a/packages/web/src/content/docs/da/cli.mdx b/packages/web/src/content/docs/da/cli.mdx index 814d2b819e..3d998904cb 100644 --- a/packages/web/src/content/docs/da/cli.mdx +++ b/packages/web/src/content/docs/da/cli.mdx @@ -609,7 +609,6 @@ Disse miljøvariabler muliggør eksperimentelle funktioner, der kan ændres elle | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Aktive eksperimenter Exa-funktioner | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Aktiver TY LSP for python-filer | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Aktiver plantilstand | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Aktiver baggrundsopgaver for subagenter | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Aktiver eksperimentelt hændelsessystem | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Aktiver native LLM-anmodningssti | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Aktiver parallel udførelse af websøgning | diff --git a/packages/web/src/content/docs/de/cli.mdx b/packages/web/src/content/docs/de/cli.mdx index 2c100e7c79..fe04a17fd3 100644 --- a/packages/web/src/content/docs/de/cli.mdx +++ b/packages/web/src/content/docs/de/cli.mdx @@ -608,7 +608,6 @@ Diese Umgebungsvariablen ermöglichen experimentelle Funktionen, die sich änder | `OPENCODE_EXPERIMENTAL_EXA` | boolescher Wert | Experimentelle Exa-Funktionen aktivieren | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolescher Wert | TY LSP für Python-Dateien aktivieren | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolescher Wert | Planmodus aktivieren | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolescher Wert | Hintergrundaufgaben für Subagenten aktivieren | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolescher Wert | Experimentelles Ereignissystem aktivieren | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolescher Wert | Nativen LLM-Anfragepfad aktivieren | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolescher Wert | Parallele Websuche aktivieren | diff --git a/packages/web/src/content/docs/es/cli.mdx b/packages/web/src/content/docs/es/cli.mdx index b925385cff..f9b597e05e 100644 --- a/packages/web/src/content/docs/es/cli.mdx +++ b/packages/web/src/content/docs/es/cli.mdx @@ -608,7 +608,6 @@ Estas variables de entorno habilitan funciones experimentales que pueden cambiar | `OPENCODE_EXPERIMENTAL_EXA` | booleano | Habilitar funciones experimentales de Exa | | `OPENCODE_EXPERIMENTAL_LSP_TY` | booleano | Habilitar Habilitar TY LSP para archivos python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | booleano | Habilitar modo de plan | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | booleano | Habilitar tareas de subagentes en segundo plano | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | booleano | Habilitar sistema de eventos experimental | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | booleano | Habilitar ruta nativa de solicitud LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | booleano | Habilitar ejecución paralela de búsqueda web | diff --git a/packages/web/src/content/docs/fr/cli.mdx b/packages/web/src/content/docs/fr/cli.mdx index bc8b550cc0..3c75d9929b 100644 --- a/packages/web/src/content/docs/fr/cli.mdx +++ b/packages/web/src/content/docs/fr/cli.mdx @@ -609,7 +609,6 @@ Ces variables d'environnement activent des fonctionnalités expérimentales qui | `OPENCODE_EXPERIMENTAL_EXA` | booléen | Activer les fonctionnalités Exa expérimentales | | `OPENCODE_EXPERIMENTAL_LSP_TY` | booléen | Activer TY LSP pour les fichiers python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | booléen | Activer le mode plan | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | booléen | Activer les tâches de sous-agents en arrière-plan | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | booléen | Activer le système d'événements expérimental | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | booléen | Activer le chemin de requête LLM natif | | `OPENCODE_EXPERIMENTAL_PARALLEL` | booléen | Activer l'exécution parallèle de la recherche web | diff --git a/packages/web/src/content/docs/it/cli.mdx b/packages/web/src/content/docs/it/cli.mdx index 67cd703a9f..46fe713827 100644 --- a/packages/web/src/content/docs/it/cli.mdx +++ b/packages/web/src/content/docs/it/cli.mdx @@ -609,7 +609,6 @@ Queste variabili d'ambiente abilitano funzionalità sperimentali che potrebbero | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Abilita funzionalità Exa sperimentali | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Abilita TY LSP per i file python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Abilita plan mode | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Abilita task subagent in background | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Abilita sistema eventi sperimentale | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Abilita percorso nativo di richiesta LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Abilita esecuzione parallela della ricerca web | diff --git a/packages/web/src/content/docs/ja/cli.mdx b/packages/web/src/content/docs/ja/cli.mdx index 755b856fc7..5da4b9bedd 100644 --- a/packages/web/src/content/docs/ja/cli.mdx +++ b/packages/web/src/content/docs/ja/cli.mdx @@ -608,7 +608,6 @@ OpenCode は環境変数を使用して構成できます。 | `OPENCODE_EXPERIMENTAL_EXA` | ブール値 | 実験的な Exa 機能を有効にする | | `OPENCODE_EXPERIMENTAL_LSP_TY` | ブール値 | python ファイルの TY LSP を有効にする | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | ブール値 | プランモードを有効にする | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | ブール値 | バックグラウンド subagent タスクを有効にする | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | ブール値 | 実験的なイベントシステムを有効にする | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | ブール値 | ネイティブ LLM リクエスト経路を有効にする | | `OPENCODE_EXPERIMENTAL_PARALLEL` | ブール値 | 並列 Web 検索実行を有効にする | diff --git a/packages/web/src/content/docs/ko/cli.mdx b/packages/web/src/content/docs/ko/cli.mdx index e6ee7e1f02..1cf8900f91 100644 --- a/packages/web/src/content/docs/ko/cli.mdx +++ b/packages/web/src/content/docs/ko/cli.mdx @@ -608,7 +608,6 @@ OpenCode는 환경 변수로도 구성할 수 있습니다. | `OPENCODE_EXPERIMENTAL_EXA` | boolean | 실험적 Exa 기능 활성화 | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | python 파일에 대해 TY LSP 활성화 | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Plan mode 활성화 | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | 백그라운드 subagent 작업 활성화 | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | 실험적 이벤트 시스템 활성화 | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | 네이티브 LLM 요청 경로 활성화 | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | 병렬 웹 검색 실행 활성화 | diff --git a/packages/web/src/content/docs/nb/cli.mdx b/packages/web/src/content/docs/nb/cli.mdx index 36e9485e97..77308b45bc 100644 --- a/packages/web/src/content/docs/nb/cli.mdx +++ b/packages/web/src/content/docs/nb/cli.mdx @@ -609,7 +609,6 @@ Disse miljøvariablene muliggjør eksperimentelle funksjoner som kan endres elle | `OPENCODE_EXPERIMENTAL_EXA` | boolsk | Aktiver eksperimentelle Exa-funksjoner | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolsk | Aktiver TY LSP for python-filer | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolsk | Aktiver planmodus | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolsk | Aktiver bakgrunnsoppgaver for subagenter | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolsk | Aktiver eksperimentelt hendelsessystem | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolsk | Aktiver innebygd LLM-forespørselsvei | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolsk | Aktiver parallell kjøring av websøk | diff --git a/packages/web/src/content/docs/pl/cli.mdx b/packages/web/src/content/docs/pl/cli.mdx index eee6d3e162..8769a14d76 100644 --- a/packages/web/src/content/docs/pl/cli.mdx +++ b/packages/web/src/content/docs/pl/cli.mdx @@ -609,7 +609,6 @@ Te zmienne włączają funkcje eksperymentalne, które mogą ulec zmianie lub zo | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Włącz funkcje eksperymentalne Exa | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Włącz TY LSP dla plików python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Włącz tryb planowania | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Włącz zadania subagentów w tle | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Włącz eksperymentalny system zdarzeń | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Włącz natywną ścieżkę żądań LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Włącz równoległe wykonywanie wyszukiwania web | diff --git a/packages/web/src/content/docs/pt-br/cli.mdx b/packages/web/src/content/docs/pt-br/cli.mdx index 69b69e1f28..4a073c4b4b 100644 --- a/packages/web/src/content/docs/pt-br/cli.mdx +++ b/packages/web/src/content/docs/pt-br/cli.mdx @@ -608,7 +608,6 @@ Essas variáveis de ambiente habilitam recursos experimentais que podem mudar ou | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Habilitar recursos experimentais do Exa | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Habilitar TY LSP para arquivos python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Habilitar modo de plano | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Habilitar tarefas de subagentes em segundo plano | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Habilitar sistema de eventos experimental | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Habilitar caminho nativo de requisição LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Habilitar execução paralela de busca web | diff --git a/packages/web/src/content/docs/ru/cli.mdx b/packages/web/src/content/docs/ru/cli.mdx index ac48039aa8..ed75453988 100644 --- a/packages/web/src/content/docs/ru/cli.mdx +++ b/packages/web/src/content/docs/ru/cli.mdx @@ -609,7 +609,6 @@ opencode можно настроить с помощью переменных с | `OPENCODE_EXPERIMENTAL_EXA` | логическое значение | Включить экспериментальные функции Exa | | `OPENCODE_EXPERIMENTAL_LSP_TY` | логическое значение | Включить TY LSP для файлов python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | логическое значение | Включить режим плана | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | логическое значение | Включить фоновые задачи субагентов | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | логическое значение | Включить экспериментальную систему событий | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | логическое значение | Включить нативный путь запросов LLM | | `OPENCODE_EXPERIMENTAL_PARALLEL` | логическое значение | Включить параллельное выполнение веб-поиска | diff --git a/packages/web/src/content/docs/th/cli.mdx b/packages/web/src/content/docs/th/cli.mdx index 49df515c81..39a67bdb36 100644 --- a/packages/web/src/content/docs/th/cli.mdx +++ b/packages/web/src/content/docs/th/cli.mdx @@ -610,7 +610,6 @@ OpenCode สามารถกำหนดค่าโดยใช้ตัว | `OPENCODE_EXPERIMENTAL_EXA` | Boolean | ฟีเจอร์ Exa ทดลอง | | `OPENCODE_EXPERIMENTAL_LSP_TY` | Boolean | เปิดใช้งาน TY LSP สำหรับไฟล์ python | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | Boolean | เปิดใช้งาน Plan mode | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | Boolean | เปิดใช้งานงาน subagent เบื้องหลัง | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | Boolean | เปิดใช้งานระบบเหตุการณ์ทดลอง | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | Boolean | เปิดใช้งานเส้นทางคำขอ LLM แบบ native | | `OPENCODE_EXPERIMENTAL_PARALLEL` | Boolean | เปิดใช้งานการค้นหาเว็บแบบขนาน | diff --git a/packages/web/src/content/docs/tr/cli.mdx b/packages/web/src/content/docs/tr/cli.mdx index 4f28122bd9..8974771e06 100644 --- a/packages/web/src/content/docs/tr/cli.mdx +++ b/packages/web/src/content/docs/tr/cli.mdx @@ -609,7 +609,6 @@ Bu ortam değişkenleri değişebilecek veya kaldırılabilecek deneysel özelli | `OPENCODE_EXPERIMENTAL_EXA` | boolean | Deneysel Exa özelliklerini etkinleştirin | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | python dosyaları için TY LSP'yi etkinleştir | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Plan modunu etkinleştir | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | Arka plan alt ajan görevlerini etkinleştir | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | Deneysel olay sistemini etkinleştir | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | Yerel LLM istek yolunu etkinleştir | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | Paralel web araması yürütmesini etkinleştir | diff --git a/packages/web/src/content/docs/zh-cn/cli.mdx b/packages/web/src/content/docs/zh-cn/cli.mdx index 46f090bb72..b3d9702e3d 100644 --- a/packages/web/src/content/docs/zh-cn/cli.mdx +++ b/packages/web/src/content/docs/zh-cn/cli.mdx @@ -609,7 +609,6 @@ OpenCode 可以通过环境变量进行配置。 | `OPENCODE_EXPERIMENTAL_EXA` | boolean | 启用实验性 Exa 功能 | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | 为 python 文件启用 TY LSP | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | 启用计划模式 | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | 启用后台子代理任务 | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | 启用实验性事件系统 | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | 启用原生 LLM 请求路径 | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | 启用并行 Web 搜索执行 | diff --git a/packages/web/src/content/docs/zh-tw/cli.mdx b/packages/web/src/content/docs/zh-tw/cli.mdx index 25e7bce88e..619c7030d3 100644 --- a/packages/web/src/content/docs/zh-tw/cli.mdx +++ b/packages/web/src/content/docs/zh-tw/cli.mdx @@ -609,7 +609,6 @@ OpenCode 可以透過環境變數進行設定。 | `OPENCODE_EXPERIMENTAL_EXA` | boolean | 啟用實驗性 Exa 功能 | | `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | 為 python 檔案啟用 TY LSP | | `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | 啟用計畫模式 | -| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | boolean | 啟用背景子代理任務 | | `OPENCODE_EXPERIMENTAL_EVENT_SYSTEM` | boolean | 啟用實驗性事件系統 | | `OPENCODE_EXPERIMENTAL_NATIVE_LLM` | boolean | 啟用原生 LLM 請求路徑 | | `OPENCODE_EXPERIMENTAL_PARALLEL` | boolean | 啟用平行 Web 搜尋執行 |