diff --git a/AGENTS.md b/AGENTS.md index c89653ba95a..d6bd72d4b00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`. +Never bypass Git hooks. Do not use `--no-verify` or otherwise disable, skip, or circumvent commit or push hooks. If a hook fails, fix the failure or stop and report it to the user. + ## Style Guide ### General Principles diff --git a/bun.lock b/bun.lock index 4e7e1f72e03..df33000ba53 100644 --- a/bun.lock +++ b/bun.lock @@ -526,10 +526,10 @@ "name": "@opencode-ai/http-recorder", "version": "1.17.14", "dependencies": { - "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", }, "devDependencies": { + "@effect/platform-node": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", @@ -538,7 +538,7 @@ "typescript": "catalog:", }, "peerDependencies": { - "effect": "4.0.0-beta.83", + "effect": "catalog:", }, }, "packages/httpapi-codegen": { @@ -873,6 +873,8 @@ "name": "@opencode-ai/simulation", "version": "1.17.13", "dependencies": { + "@fontsource/adwaita-mono": "5.2.1", + "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opentui/core": "catalog:", @@ -1611,11 +1613,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=="], @@ -1723,6 +1725,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource/adwaita-mono": ["@fontsource/adwaita-mono@5.2.1", "", {}, "sha512-6+Q1UIvklJ9REijs6kv7YlRNt6yktRj0iW8H69YIugdD9P2h3eIX1AB8/9ICMfpVyVeywlsrCXg82y/LfRrjyg=="], + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.5", "", {}, "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], @@ -1983,6 +1987,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 +2305,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 +2597,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 +3169,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 +3243,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 +5053,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 +5469,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 +6091,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 +6693,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 +6717,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 +6827,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 +7131,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 +7797,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 +7809,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 +7881,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 +8463,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 e0f0d72233b..0690f02706f 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 798bf0df3b2..f411339adbe 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 a86a55cff25..a22d5cc3317 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 529081a1d9e..e6fdd5d47b6 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 f07da121c66..a591ff9470f 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 cb228c13c7a..f0871a0da3e 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 9fd2ca8d0b6..b6679ab4ed7 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 99f1acf270b..071b0300785 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 3dce37cafd9..ff857d81790 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 e5df40c7d2d..53adfd155b6 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 15d125df391..40b7acbbd37 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 4b2be505eaa..3dda5a54293 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 05cdc846438..39535abe2f9 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 86b489cd090..a117e4d0079 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 6898cc23049..a46eb744d17 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 3854bf0276e..586942399d8 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 3f44aba4883..01384a6ee97 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 2e2c65af3b3..bb7950079ec 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 c288c357068..760ace33111 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 fcd52756ab0..41f75a081e3 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 5fbca469b71..f6d768e1de2 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 0cb2504fbe9..60df039410b 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 535147e6519..0868d3a94bb 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 b06b3c45370..ed0b6878f5e 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 6ba7940ab6c..4c6214d9a85 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 ab53e9162e6..0a2510f167d 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 cabb4ee1c8b..a7824dddcf8 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 9259bd5dbee..8a475298169 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 8549b36d372..bc356eb743f 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 d8e3dcd228e..a6aedbb97d7 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 4700f7c479e..e25c29f02e4 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 4aae8e12784..091ff296eed 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 a82fea2e58c..ae854ef2e3d 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 processEffect(options).pipe( Effect.provide(Updater.layer), - Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, EffectFlock.node]))), Effect.provide(NodeServices.layer), ), ) @@ -37,13 +37,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { if (options.mode === "service") yield* Effect.sync(() => 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 serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined + const lockScope = serviceOptions === undefined ? undefined : yield* acquireServiceLock(serviceOptions.file) + if ( + serviceOptions !== undefined && + lockScope !== undefined && + (yield* Service.discover(serviceOptions)) !== undefined + ) { + yield* Scope.close(lockScope, Exit.void) + return } const environmentPassword = yield* Env.password // Keep the lease credential out of the environment inherited by tools. @@ -64,7 +66,10 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { port: Option.fromNullishOr(options.port ?? config.port), password, }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false }))) - if (options.mode === "service") yield* register(address, password) + if (lockScope !== undefined) { + yield* register(address, password) + yield* Scope.close(lockScope, Exit.void) + } const url = HttpServer.formatAddress(address) console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`) @@ -75,6 +80,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { ) }) +const acquireServiceLock = Effect.fnUntraced(function* (file: string) { + const flock = yield* EffectFlock.Service + const scope = yield* Scope.make() + yield* Effect.addFinalizer((exit) => Scope.close(scope, exit)) + yield* flock + .acquire(`service:${file}`, undefined, { staleMs: 3_000, timeoutMs: 3_000 }) + .pipe(Effect.provideService(Scope.Scope, scope)) + return scope +}) + // The latest atomic registration wins. A displaced process notices the new id, // exits, and cannot remove its successor's registration from its finalizer. const infoJson = Schema.fromJsonString(Service.Info) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 68073851c1b..ac1f9ac1cf9 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -1,7 +1,8 @@ import { NodeFileSystem } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" import { Global } from "@opencode-ai/core/global" import { expect, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -24,3 +25,47 @@ test("local channel stores service config with the local service filename", asyn await fs.rm(root, { recursive: true, force: true }) } }) + +test("concurrent service processes elect one server", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-")) + const env = { + ...process.env, + HOME: root, + OPENCODE_DB: path.join(root, "opencode.db"), + OPENCODE_TEST_HOME: root, + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_STATE_HOME: path.join(root, "state"), + } + const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"] + const first = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + const second = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) + + try { + const registration = path.join(root, "state", "opencode", "service-local.json") + const info = await waitForInfo(registration) + const winner = info.pid === first.pid ? first : second + const loser = info.pid === first.pid ? second : first + const exited = await Promise.race([loser.exited.then(() => true), Bun.sleep(10_000).then(() => false)]) + + expect(exited).toBe(true) + expect(winner.exitCode).toBe(null) + } finally { + first.kill("SIGTERM") + second.kill("SIGTERM") + await Promise.all([first.exited, second.exited]) + await fs.rm(root, { recursive: true, force: true }) + } +}) + +async function waitForInfo(file: string) { + for (let attempt = 0; attempt < 200; attempt++) { + const value = await Bun.file(file) + .json() + .catch(() => undefined) + if (value !== undefined) return Schema.decodeUnknownPromise(Service.Info)(value) + await Bun.sleep(50) + } + throw new Error("Timed out waiting for service registration") +} diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 6a83d52b998..70f1dcc5875 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 319a9f72e27..8fd9994f5c9 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 588d67cad6e..0c7b7a3af38 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,10 +912,15 @@ 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 } } export interface AppApi { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 945bd0ffc09..5175567f459 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) }, +}) const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 697d54d732d..cc1b6b18540 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,7 +181,9 @@ import type { VcsStatusOutput, VcsDiffInput, VcsDiffOutput, - DebugLocationOutput, + DebugLocationListOutput, + DebugLocationEvictInput, + DebugLocationEvictOutput, } from "./types" import { ClientError } from "./client-error" @@ -485,6 +491,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 }>( { @@ -536,7 +554,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, @@ -578,40 +601,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 }>( { @@ -813,69 +838,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) => @@ -890,6 +919,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) => @@ -950,18 +993,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 }>( { @@ -1039,41 +1084,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 }>( { @@ -1355,18 +1404,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 }>( { @@ -1483,17 +1534,31 @@ 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, + ), + }, }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 7482d237258..043f2caa3d5 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 } } @@ -2478,6 +2505,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?: { @@ -2525,13 +2582,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 @@ -3599,13 +3656,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 @@ -3622,9 +3679,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 @@ -3633,9 +3690,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"] @@ -3848,6 +3905,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 @@ -3903,7 +3961,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 @@ -3965,7 +4023,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 @@ -4027,7 +4085,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 @@ -4089,7 +4147,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 @@ -4194,7 +4252,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 } } @@ -4203,7 +4261,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 @@ -4442,7 +4500,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 } } @@ -4451,7 +4509,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 } } @@ -4460,7 +4518,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 @@ -4472,7 +4530,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 @@ -4485,16 +4543,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 } } @@ -4503,7 +4578,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 } } @@ -4512,7 +4587,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 } } @@ -4521,7 +4596,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 @@ -4549,7 +4624,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 } } @@ -4558,7 +4633,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 } } @@ -4567,7 +4642,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 } } } @@ -4576,7 +4651,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" } } @@ -4585,7 +4660,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 } } @@ -4594,7 +4669,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 @@ -4608,16 +4683,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 @@ -4640,7 +4715,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 @@ -4669,7 +4744,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 @@ -4684,7 +4759,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 @@ -4706,12 +4781,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 } + } } } | { @@ -4719,7 +4801,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 } } @@ -4741,7 +4823,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 @@ -4755,7 +4837,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 @@ -4782,7 +4864,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 @@ -4797,7 +4879,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 @@ -4824,7 +4906,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 @@ -4838,7 +4920,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 @@ -4854,7 +4936,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 @@ -4872,7 +4954,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 @@ -4883,7 +4965,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 } @@ -4894,7 +4975,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 @@ -4911,7 +4992,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 @@ -4926,7 +5007,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 } } @@ -4935,9 +5016,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 @@ -4952,7 +5038,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 @@ -4966,16 +5052,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 @@ -4983,13 +5074,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" }> } } @@ -4999,7 +5089,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 } } @@ -5008,7 +5098,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 } } @@ -5508,6 +5598,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 @@ -5957,13 +6055,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 @@ -6121,12 +6219,20 @@ 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 DebugLocationEvictInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type DebugLocationEvictOutput = void diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index a2afc9a0023..553c120f7a4 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 4f2419075f8..e67d7379422 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -33,16 +33,11 @@ test("exposes every standard HTTP API group", () => { "debug", ]) 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.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.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) @@ -50,6 +45,29 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) }) +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 2cc67fe36a4..e298ccfc57a 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 f7f798f2e1b..c388cca1bda 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 08d47cd37e8..e26538550cc 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 2825e744b5f..7f5d015d18d 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 edb7e3d94bc..7720f69775a 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 79710e1ad22..02dfa953b41 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 7d67ba6c79f..80ce828414d 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 221b5e07dfe..16c17ca0c2a 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 b20a25b25e1..c2cd5a1d213 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 db799e58943..608019da46d 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 313b9c22cc0..ce5f1e03a49 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 59262f3197d..d5870dab8de 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 2dd54123ed9..8ba976399d3 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 f7474b244a1..3330c77f4a8 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 3cfc799d72f..1bb05e0fa19 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 ec371823958..e2fa972806e 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 99ca206c1f4..5b1735573d6 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 00000000000..e921d409047 --- /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 baac20d200b..7981bb26ded 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 87745c01eeb..0f48d9a04c0 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 613b43b5f87..7765a396e85 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 19dcde8961a..eea11c95f77 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 fe2301651f7..661e9795eb6 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -150,31 +150,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 0b10ecd7e42..5f48cba6495 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 1ce8c3d3eca..7a99c159827 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 a88aebfe07c..faeee213fba 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 907920c10e1..4e0858fd16d 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 7549089bf36..e6e03a4f151 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 00000000000..4ac437d3926 --- /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 01ea0da0d3f..a109dd02e70 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,6 @@ export * as PluginHost from "./host" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" @@ -22,6 +22,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) { @@ -36,6 +37,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({ @@ -43,7 +45,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({ @@ -79,32 +81,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: { @@ -164,25 +166,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)), @@ -317,11 +323,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, @@ -330,38 +337,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, @@ -375,5 +381,5 @@ 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 }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 2789cc3c2e5..ad5553928ac 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" @@ -31,7 +31,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" @@ -127,7 +127,7 @@ const pre = [ SkillPlugin.Plugin, ModelsDevPlugin, ...ProviderPlugins, - 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 67cf0069dc1..ef59535e81f 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 bcd3db13235..8eafe52734f 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,11 +1,12 @@ export * as PluginPromise from "./promise" -import { define } from "@opencode-ai/plugin/v2/effect" -import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2/effect" 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 +17,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 +44,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 +52,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 +78,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)), + }, transform: transform(host.integration), reload: () => run(host.integration.reload()), connection: { @@ -104,12 +107,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 607e565d4a4..ea37b004531 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 5bbe953f805..ecc137bbde3 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 4c5938e51ff..bbf0d56dfbe 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 65a40c023b2..f50bbdb1ba2 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 42fea46550a..13614d6be08 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 afab0c36dfb..06025496145 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 9b4558b5d17..c3aa50fbb46 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 8b0831604ae..9284defcb14 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 e8316012ad4..f4ddf97859a 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 2e51674ba07..ae20d0d0201 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 07249391a0f..b67c0ef1798 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 ab9364260cd..139e6efd2d3 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 1a8a4a1231f..a413c85d7db 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 2c307a87256..ffc29f4bf35 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 3d2013a5238..c62d962eb59 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 d84ece21517..51b8e4c42f7 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 92bc09abecf..f5de664432e 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 3854bdcde2c..76646b4e453 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 72fe8ed6469..5e4a1b08ef0 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 58690df0b28..cca6a2dfa73 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 c27cc30207d..f00386a94a1 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 9eb5b1e246f..36cafdb2d1c 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 4a8c26e82a8..559493787dc 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 2e6cc9f9b44..45c5f095ee1 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 d9454cfd241..ea43db9e4a5 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 c9d5b163aed..9930ce831d9 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 70a27c8f672..fc392fe5e17 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 fb1d33e86ec..724a3d2567d 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 f6ceb06a8fb..3dd42a80377 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/skill.ts b/packages/core/src/plugin/skill.ts index 91c5297c0e2..439b77fd365 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 4c4e71be17f..6bd4f6bfac4 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 637d6be9e9b..c3178de1cdf 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 { Cause, DateTime, Effect, Layer, Schema, Context, Option, Stream, Scope } from "effect" +import { Cause, Effect, Layer, Schema, Context, Option, 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 type { 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 }, }) @@ -539,7 +544,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({ @@ -601,12 +606,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, }, @@ -693,6 +699,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 e90b6455dce..ab5e41e405a 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 e719366940d..f2e3122c96a 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 1c8ddf153dd..2c507d00589 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 c08439d0c17..41eb36ea5b4 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 96881e7c699..01e045ce5f6 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 625b6f1553f..dcf79ff3a19 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 80ec1874726..42bc34e6943 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 f239bde3a9b..d65e6f3c5fb 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" @@ -50,6 +52,32 @@ import { StepFailedError, UserInterruptedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" import { AgentTelemetry } from "../../observability/agent" +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. @@ -108,6 +136,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 @@ -138,7 +167,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, @@ -213,6 +242,7 @@ const layer = Layer.effect( } const resolved = yield* AgentTelemetry.stage("model_resolution", models.resolve(session)) const model = resolved.model + const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const entries = yield* AgentTelemetry.stage( "history", SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq), @@ -236,17 +266,41 @@ 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))) { const compacted = yield* AgentTelemetry.stage( "compaction", - compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }), + compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }), ) if (compacted) { yield* AgentTelemetry.compactionCompleted("automatic") @@ -260,7 +314,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, }) @@ -268,8 +322,7 @@ 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 telemetry = AgentTelemetry.modelCall({ sessionID: session.id, @@ -283,7 +336,7 @@ const layer = Layer.effect( const providerStream = AgentTelemetry.stage( "model", telemetry.run( - llm.stream(request).pipe( + llm.stream(hookedRequest).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return @@ -305,6 +358,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( @@ -327,7 +395,6 @@ const layer = Layer.effect( result: settlement.result, output: settlement.output, }), - settlement.outputPaths ?? [], settlement.error, ), ).pipe( @@ -350,6 +417,11 @@ const layer = Layer.effect( ), ) + 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>) => @@ -368,8 +440,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, }), @@ -499,7 +570,8 @@ const layer = Layer.effect( !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 @@ -601,6 +673,7 @@ const layer = Layer.effect( return yield* compaction.compactManual({ session, messages: yield* store.context(sessionID), + inputID: pending.id, }) }), ).pipe(Effect.exit) @@ -608,9 +681,27 @@ const layer = Layer.effect( yield* AgentTelemetry.compactionCompleted("manual") return true } - yield* events.publish(SessionEvent.Compaction.Failed, { sessionID }) + 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, + }) + yield* AgentTelemetry.compactionFailed("manual") + 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, + }) yield* AgentTelemetry.compactionFailed("manual") - if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause) return true }), ) @@ -685,6 +776,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 6cc0d0845d5..defd95f9c11 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 5f4eeebc84a..dfd0dca499b 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 23de3691022..7aa379ab278 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -21,45 +21,50 @@ const media = (file: FileAttachment): ContentPart => ({ metadata: file.description === undefined ? undefined : { description: file.description }, }) -const textAttachment = (file: FileAttachment) => - Message.make({ - role: "user", - content: [ - `Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`, - file.description === undefined ? undefined : `Description: ${file.description}`, - "", - Buffer.from(file.data, "base64").toString("utf8"), - ] - .filter((line): line is string => line !== undefined) - .join("\n"), - metadata: { - attachment: { - source: file.source, - name: file.name, - description: file.description, - }, +const textAttachment = (file: FileAttachment): ContentPart => ({ + type: "text", + text: `\n\n${[ + `Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`, + file.description === undefined ? undefined : `Description: ${file.description}`, + "", + Buffer.from(file.data, "base64").toString("utf8"), + ] + .filter((line): line is string => line !== undefined) + .join("\n")}`, + metadata: { + attachment: { + source: file.source, + name: file.name, + description: file.description, }, - }) + }, +}) -const directoryAttachment = (file: FileAttachment) => - Message.make({ - role: "user", - content: [ - `Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, - file.description === undefined ? undefined : `Description: ${file.description}`, - file.data.length === 0 ? undefined : "", - file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"), - ] - .filter((line): line is string => line !== undefined) - .join("\n"), - metadata: { - attachment: { - source: file.source, - name: file.name, - description: file.description, - }, +const directoryAttachment = (file: FileAttachment): ContentPart => ({ + type: "text", + text: `\n\n${[ + `Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, + file.description === undefined ? undefined : `Description: ${file.description}`, + file.data.length === 0 ? undefined : "", + file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"), + ] + .filter((line): line is string => line !== undefined) + .join("\n")}`, + metadata: { + attachment: { + source: file.source, + name: file.name, + description: file.description, }, - }) + }, +}) + +const attachmentContent = (file: FileAttachment): ContentPart[] => { + if (file.mime === "text/plain") return [textAttachment(file)] + if (file.mime === "application/x-directory") return [directoryAttachment(file)] + if (imageMimes.has(file.mime)) return [media(file)] + return [] +} const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) @@ -69,7 +74,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,7 +118,7 @@ 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 @@ -125,7 +130,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { { type: "reasoning", text: item.text, - providerMetadata: providerMetadata(model.providerID, item.state), + providerMetadata: providerMetadata(providerMetadataKey, item.state), }, ] : item.text.length > 0 @@ -133,13 +138,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 +160,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,23 +173,22 @@ 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": return [] case "user": - const files = message.files ?? [] + const content = [ + ...(message.text === "" ? [] : [Message.text(message.text)]), + ...(message.files ?? []).flatMap(attachmentContent), + ] + if (content.length === 0) return [] return [ - ...files.filter((file) => file.mime === "text/plain").map(textAttachment), - ...files.filter((file) => file.mime === "application/x-directory").map(directoryAttachment), Message.make({ id: message.id, role: "user", - content: [ - { type: "text", text: message.text }, - ...files.filter((file) => imageMimes.has(file.mime)).map(media), - ], + content, metadata: { ...message.metadata, ...(message.agents?.length ? { agents: message.agents } : {}), @@ -202,12 +206,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 +236,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 e7d3bdca58b..6e09505e3c5 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 6458aa85fb7..24c99cf515c 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 511e02af875..13b93004e4e 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 95ebe87eedb..5eae66065bc 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 9843e692262..8ef532f488d 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 4b427f98351..0b3f9c36288 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 1128243b70c..927cd3098dc 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 4c0938c3d2b..b047101c390 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 64f2a3a9d64..937963f4ca7 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 aee442169e4..e6a043a0dd4 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 a2ac9828c6c..d5217f85058 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 f6a7c474b55..001a29ba55d 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 cfbd59714f2..f24bcf48de4 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -192,12 +192,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 ca6b9f1969d..d2b8bb47448 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 95c68cb2571..1f80bd9684a 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 647872937f8..d55d259f227 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -1,11 +1,12 @@ 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 { ToolTelemetry } from "../observability/tool" +import { PermissionV2 } from "../permission" import { SessionSchema } from "../session/schema" import { Tool } from "./tool" @@ -43,6 +44,7 @@ export const Plugin = { effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const agents = yield* AgentV2.Service + const permission = yield* PermissionV2.Service const scope = yield* Scope.Scope // Concatenate the child's final completed assistant text. Distinguishes "completed with no @@ -115,6 +117,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 @@ -178,5 +194,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 189ee846b9c..96c0f10f1a2 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 ca687724d1f..3e5c89b734d 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 f0a8514fce4..52ea1087e66 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 { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index c50a3a1e6d4..a88063f4e2a 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/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index b85900118a3..4b528d26022 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -36,20 +36,24 @@ export namespace EffectFlock { export type LockError = LockTimeoutError | LockCompromisedError + export interface Options { + readonly staleMs?: number + readonly timeoutMs?: number + } + // --------------------------------------------------------------------------- - // Timing (baked in — no caller ever overrides these) + // Timing defaults // --------------------------------------------------------------------------- - const STALE_MS = 60_000 - const TIMEOUT_MS = 5 * 60_000 + const DEFAULT_STALE_MS = 60_000 + const DEFAULT_TIMEOUT_MS = 5 * 60_000 const BASE_DELAY_MS = 100 const MAX_DELAY_MS = 2_000 - const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3)) - const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( - Schedule.either(Schedule.spaced(MAX_DELAY_MS)), + const retrySchedule = (timeoutMs: number) => Schedule.exponential(BASE_DELAY_MS, 1.7).pipe( + Schedule.either(Schedule.spaced(Math.min(MAX_DELAY_MS, Math.max(BASE_DELAY_MS, Math.floor(timeoutMs / 10))))), Schedule.jittered, - Schedule.while((meta) => meta.elapsed < TIMEOUT_MS), + Schedule.while((meta) => meta.elapsed < timeoutMs), ) // --------------------------------------------------------------------------- @@ -73,7 +77,7 @@ export namespace EffectFlock { // --------------------------------------------------------------------------- export interface Interface { - readonly acquire: (key: string, dir?: string) => Effect.Effect + readonly acquire: (key: string, dir?: string, options?: Options) => Effect.Effect readonly withLock: { (key: string, dir?: string): (body: Effect.Effect) => Effect.Effect (body: Effect.Effect, key: string, dir?: string): Effect.Effect @@ -135,9 +139,9 @@ export namespace EffectFlock { ), ) - const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) { + const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string, staleMs: number) { const bs = yield* safeStat(breakerPath) - if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath) + if (bs && wall() - mtimeMs(bs) > staleMs) yield* forceRemove(breakerPath) return false }) @@ -147,26 +151,31 @@ export namespace EffectFlock { ensuredDirs.add(dir) }) - const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) { + const isStale = Effect.fnUntraced(function* ( + lockDir: string, + heartbeatPath: string, + metaPath: string, + staleMs: number, + ) { const now = wall() const hb = yield* safeStat(heartbeatPath) - if (hb) return now - mtimeMs(hb) > STALE_MS + if (hb) return now - mtimeMs(hb) > staleMs const meta = yield* safeStat(metaPath) - if (meta) return now - mtimeMs(meta) > STALE_MS + if (meta) return now - mtimeMs(meta) > staleMs const dir = yield* safeStat(lockDir) if (!dir) return false - return now - mtimeMs(dir) > STALE_MS + return now - mtimeMs(dir) > staleMs }) // -- single lock attempt -- type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string } - const tryAcquireLockDir = (lockDir: string, key: string) => + const tryAcquireLockDir = (lockDir: string, key: string, staleMs: number) => Effect.gen(function* () { const token = randomUUID() const metaPath = path.join(lockDir, "meta.json") @@ -176,7 +185,7 @@ export namespace EffectFlock { const created = yield* atomicMkdir(lockDir) if (!created) { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired() + if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs))) return yield* new NotAcquired() // Stale — race for breaker ownership const breakerPath = lockDir + ".breaker" @@ -185,7 +194,7 @@ export namespace EffectFlock { Effect.as(true), Effect.catchIf( (e) => e.reason._tag === "AlreadyExists", - () => cleanStaleBreaker(breakerPath), + () => cleanStaleBreaker(breakerPath, staleMs), ), Effect.catchIf(isPathGone, () => Effect.succeed(false)), Effect.orDie, @@ -195,7 +204,7 @@ export namespace EffectFlock { // We own the breaker — double-check staleness, nuke, recreate const recreated = yield* Effect.gen(function* () { - if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false + if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs))) return false yield* forceRemove(lockDir) return yield* atomicMkdir(lockDir) }).pipe(Effect.ensuring(forceRemove(breakerPath))) @@ -218,13 +227,21 @@ export namespace EffectFlock { // -- retry wrapper (preserves Handle type) -- - const acquireHandle = (lockfile: string, key: string): Effect.Effect => - tryAcquireLockDir(lockfile, key).pipe( + const acquireHandle = ( + lockfile: string, + key: string, + options: { staleMs: number; timeoutMs: number }, + ): Effect.Effect => + tryAcquireLockDir(lockfile, key, options.staleMs).pipe( Effect.retry({ while: (err) => err._tag === "NotAcquired", - schedule: retrySchedule, + schedule: retrySchedule(options.timeoutMs), }), Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))), + Effect.timeoutOrElse({ + duration: options.timeoutMs, + orElse: () => Effect.fail(new LockTimeoutError({ key })), + }), ) // -- release -- @@ -250,19 +267,27 @@ export namespace EffectFlock { // -- build service -- - const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) { + const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string, options: Options = {}) { const lockDir = dir ?? lockRoot + const staleMs = options.staleMs ?? DEFAULT_STALE_MS + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS yield* ensureDir(lockDir) const lockfile = path.join(lockDir, Hash.fast(key) + ".lock") // acquireRelease: acquire is uninterruptible, release is guaranteed - const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle)) + const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key, { staleMs, timeoutMs }), (handle) => + release(handle), + ) // Heartbeat fiber — scoped, so it's interrupted before release runs yield* fs .utimes(handle.heartbeatPath, new Date(), new Date()) - .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped) + .pipe( + Effect.ignore, + Effect.repeat(Schedule.spaced(Math.max(100, Math.floor(staleMs / 3)))), + Effect.forkScoped, + ) }) const withLock: Interface["withLock"] = Function.dual( diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 495c125a1e7..0a4b7dadce4 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 83f71c33986..ad667f288c2 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 f371e195438..f3d1defc686 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 e26e12bdac7..f5e15c2c00a 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 afa7f5c37e5..365d5566d2d 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 34a69ffbc50..2bfe1d6c74f 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 d1d27dc6e9b..0c7c85a1ca6 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 4f147c0f3d1..10f02a73428 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 15c851236ca..4855b0ec266 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 2c4d1f86a6e..b247a4d8b78 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 f77e32fd8ba..7441ff83db5 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 5677e288ef0..c09d1e6296e 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 f945cb2265c..dcfecdb0ae2 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 915f1f4e02d..876a01b0aac 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -3,27 +3,166 @@ 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 { ATTR_ERROR_TYPE } from "@opencode-ai/core/observability/semconv" -import { Deferred, Effect, Fiber, Layer, Stream, Tracer } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer, Stream, Tracer } 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, + }, + }), + Layer.mock(Credential.Service, {}), + ), + ), + ) +} + const mcp = Layer.mock(MCP.Service, { tools: () => Effect.succeed([ @@ -242,6 +381,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 00000000000..8927df9a05f --- /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 5d87102b7d5..79fcc5d0f60 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 ea6e65bb643..ffef32738da 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 a5f12a113da..e607b658e35 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 ed53e4b947b..0fef8fbc655 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 4daac0acd09..dac49b410d7 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 f799c968886..5dae699f6e5 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 2aa8789548a..659ec40f86f 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,4 +1,4 @@ -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" @@ -19,8 +19,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: { @@ -45,11 +44,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"), + }, transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), connection: { @@ -72,10 +75,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"), @@ -83,6 +83,9 @@ 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 }), }, } } @@ -188,11 +191,15 @@ export function integrationHost(integration: Integration.Interface): PluginConte 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 545e09e0c38..c336666c865 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 fbe4029c3a2..05a20693b5c 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -4,7 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" 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" @@ -16,7 +16,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([ @@ -46,7 +46,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") @@ -75,7 +75,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) => { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index f20091d514d..13109550c7f 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 c8b3757d3d8..f8334026f90 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 2bcf675052f..de0f745183b 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 40d9f48a199..703b098f638 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 1cfd353f545..e8fb3addecc 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 0a2e57edf58..3c4bb65a441 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 854cd73d4be..34439836a4a 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 ac2fe59496e..a6c7797e0f5 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 ce301703626..1eb7f6b6fd4 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 3e76ddbb424..34b1ca1487b 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 fb79b35d11a..396b833a6d4 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 }, }), @@ -151,7 +144,7 @@ Recent work ]) }) - test("lowers text attachments as separate user messages", () => { + test("lowers text attachments after the prompt in one user message", () => { const file = FileAttachment.make({ data: Base64.make(Buffer.from("export const value = 1").toString("base64")), mime: "text/plain", @@ -171,21 +164,18 @@ Recent work model, ) - expect(messages).toHaveLength(2) + expect(messages).toHaveLength(1) expect(messages[0]).toMatchObject({ - role: "user", - content: [ - { - type: "text", - text: "Attached file: main.ts\n\nexport const value = 1", - }, - ], - metadata: { attachment: { source: file.source, name: "main.ts" } }, - }) - expect(messages[1]).toMatchObject({ id: id("user-text-file"), role: "user", - content: [{ type: "text", text: "Review this file" }], + content: [ + { type: "text", text: "Review this file" }, + { + type: "text", + text: "\n\nAttached file: main.ts\n\nexport const value = 1", + metadata: { attachment: { source: file.source, name: "main.ts" } }, + }, + ], }) }) @@ -210,10 +200,11 @@ Recent work model, ) - expect(messages[0]?.content).toEqual([ + expect(messages[0]?.content).toMatchObject([ + { type: "text", text: "Review this file" }, { type: "text", - text: "Attached file: inline.txt\n\ninline content", + text: "\n\nAttached file: inline.txt\n\ninline content", }, ]) }) @@ -238,13 +229,79 @@ Recent work model, ) - expect(messages).toHaveLength(2) + expect(messages).toHaveLength(1) expect(messages[0]).toMatchObject({ + id: id("user-directory"), role: "user", - content: [{ type: "text", text: "Attached directory: src/\n\nlib/\nindex.ts" }], - metadata: { attachment: { source: directory.source, name: "src/" } }, + content: [ + { type: "text", text: "Review this directory" }, + { + type: "text", + text: "\n\nAttached directory: src/\n\nlib/\nindex.ts", + metadata: { attachment: { source: directory.source, name: "src/" } }, + }, + ], }) - expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }]) + }) + + test("preserves attachment order after the prompt", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-mixed-files"), + type: "user", + text: "Review these attachments", + files: [ + FileAttachment.make({ + data: Base64.make(Buffer.from("index.ts").toString("base64")), + mime: "application/x-directory", + source: { type: "uri", uri: "file:///project/src" }, + name: "src/", + }), + FileAttachment.make({ + data: Base64.make(Buffer.from("export const value = 1").toString("base64")), + mime: "text/plain", + source: { type: "uri", uri: "file:///project/main.ts" }, + name: "main.ts", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(1) + expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([ + "Review these attachments", + "\n\nAttached directory: src/\n\nindex.ts", + "\n\nAttached file: main.ts\n\nexport const value = 1", + ]) + }) + + test("omits empty prompt text before an attachment", () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-attachment-only"), + type: "user", + text: "", + files: [ + FileAttachment.make({ + data: Base64.make(Buffer.from("index.ts").toString("base64")), + mime: "application/x-directory", + source: { type: "uri", uri: "file:///project/src" }, + name: "src/", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(1) + expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }]) }) test("uses materialized image data as provider media and drops unsupported attachments", () => { @@ -282,7 +339,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 +352,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 +494,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 +518,41 @@ Recent work ]) }) + 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({ @@ -536,7 +621,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 +716,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 14bfc37bbe6..f3c666b4a97 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-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 936e6927923..42dd5332b83 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -1,5 +1,4 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { ATTR_GEN_AI_CONVERSATION_ID, ATTR_GEN_AI_USAGE_INPUT_TOKENS, @@ -49,15 +48,13 @@ import { Effect, Layer, Tracer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" -const cassette = - process.env.RECORD === "true" - ? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { - directory: path.resolve(import.meta.dir, "fixtures/recordings"), - mode: "record", - }) - : HttpRecorder.http("session-runner/openai-chat-streams-text", { - directory: path.resolve(import.meta.dir, "fixtures/recordings"), - }) +const cassetteName = "session-runner/openai-chat-streams-text" +const cassetteDirectory = path.resolve(import.meta.dir, "fixtures/recordings") +if (process.env.RECORD === "true") { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + HttpRecorder.removeCassetteSync(cassetteName, { directory: cassetteDirectory }) +} +const cassette = HttpRecorder.layerFetch(cassetteName, { directory: cassetteDirectory }) const executor = RequestExecutor.layer.pipe(Layer.provide(cassette)) const client = LLMClient.layer.pipe(Layer.provide(executor)) const permission = Layer.succeed( diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 4fdbb49e8b3..179818d8523 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 7eb4bf1d2f9..6b4087b3bcf 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -96,24 +96,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 d19eeaf7c10..b1797fca6b3 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, @@ -63,6 +63,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" @@ -136,6 +137,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 }) @@ -149,6 +164,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( @@ -361,6 +423,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* () { @@ -383,6 +447,8 @@ 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 @@ -409,6 +475,7 @@ const setup = Effect.gen(function* () { .run() .pipe(Effect.orDie) yield* insertSession(sessionID) + return yield* SessionV2.Service }) const providerUnavailable = () => @@ -433,14 +500,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 @@ -491,7 +553,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 @@ -587,7 +652,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, @@ -601,14 +666,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 @@ -634,13 +697,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) @@ -658,12 +719,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)), @@ -865,9 +925,8 @@ 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({ @@ -881,20 +940,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) @@ -925,13 +972,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, @@ -948,16 +989,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) @@ -973,8 +1008,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 @@ -984,7 +1018,6 @@ describe("SessionRunnerLLM", () => { prompt: PromptInput.Prompt.make({ text: "First" }), resume: false, }) - requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -1011,13 +1044,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, { @@ -1032,7 +1062,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) @@ -1043,11 +1073,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 }) @@ -1072,11 +1100,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) @@ -1084,7 +1110,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) @@ -1106,15 +1132,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([ @@ -1140,13 +1163,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([ @@ -1158,7 +1179,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) => @@ -1167,11 +1188,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([ @@ -1183,7 +1202,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) => { @@ -1191,11 +1210,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"]) @@ -1204,7 +1221,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) => { @@ -1217,11 +1234,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"]) @@ -1231,7 +1246,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) => { @@ -1239,11 +1254,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"]) @@ -1252,7 +1265,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) => @@ -1267,11 +1280,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"]) @@ -1281,21 +1292,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([ @@ -1308,8 +1316,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") @@ -1320,14 +1327,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([ @@ -1338,8 +1343,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(() => { @@ -1352,10 +1356,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([ @@ -1366,15 +1368,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"]) @@ -1387,14 +1386,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. @@ -1405,7 +1401,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"]) @@ -1426,7 +1422,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"]) @@ -1439,23 +1435,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([ @@ -1475,31 +1468,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([ @@ -1512,17 +1502,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, @@ -1531,7 +1519,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([ @@ -1539,26 +1527,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) @@ -1568,16 +1554,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" }), @@ -1604,18 +1583,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) @@ -1636,32 +1613,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) @@ -1686,14 +1705,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) @@ -1716,10 +1731,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) @@ -1744,12 +1759,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) @@ -1774,10 +1785,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) @@ -1795,14 +1806,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" } }, ]) }), @@ -1813,12 +1825,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 @@ -1829,26 +1841,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, @@ -1857,7 +1869,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. @@ -1868,14 +1880,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" }), @@ -1892,7 +1899,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "hello" }, providerExecuted: true, - providerMetadata: { fake: { source: "provider" } }, + providerMetadata: { openai: { source: "provider" } }, }), LLMEvent.toolResult({ id: "call-provider", @@ -1905,7 +1912,7 @@ describe("SessionRunnerLLM", () => { ], }, providerExecuted: true, - providerMetadata: { fake: { source: "provider" } }, + providerMetadata: { openai: { source: "provider" } }, }), LLMEvent.stepFinish({ index: 0, @@ -1930,6 +1937,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" }, @@ -1968,31 +1976,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) @@ -2034,25 +2021,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 @@ -2077,32 +2050,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" }), @@ -2116,7 +2087,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", @@ -2126,16 +2101,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" } }, }, ]) }), @@ -2143,11 +2122,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({ @@ -2155,14 +2132,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" }), @@ -2170,7 +2147,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) @@ -2182,7 +2159,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { fake: { itemId: "hosted-search" } }, + providerMetadata: { openai: { itemId: "hosted-search" } }, }, { type: "tool-result", @@ -2190,7 +2167,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" } }, }, ]) }), @@ -2198,17 +2175,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) => @@ -2219,7 +2191,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)), @@ -2259,25 +2230,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" }), [], ] @@ -2347,20 +2305,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() @@ -2386,23 +2334,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() @@ -2429,29 +2364,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() @@ -2476,24 +2392,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() @@ -2523,24 +2426,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() @@ -2570,28 +2460,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() @@ -2613,9 +2485,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" }), @@ -2623,19 +2494,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) @@ -2647,33 +2506,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 @@ -2715,23 +2551,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() @@ -2755,13 +2578,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() @@ -2784,20 +2603,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, { @@ -2846,20 +2660,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, { @@ -2893,7 +2702,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" } }, ]) @@ -2902,20 +2711,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, { @@ -2939,8 +2743,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" }), @@ -2948,7 +2751,6 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow @@ -2959,26 +2761,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 @@ -2989,21 +2782,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) @@ -3013,19 +2800,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() @@ -3050,12 +2833,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" }), @@ -3067,7 +2849,6 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 yield* session.resume(longSessionID) yield* session.resume(otherLongSessionID) @@ -3080,17 +2861,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() @@ -3115,30 +2888,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) @@ -3150,7 +2903,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" }, + }, }, ], }, @@ -3161,27 +2917,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) @@ -3217,8 +2956,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({ @@ -3231,22 +2969,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) @@ -3266,8 +2991,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({ @@ -3277,15 +3001,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) @@ -3310,8 +3028,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({ @@ -3324,22 +3041,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) @@ -3359,20 +3063,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) @@ -3404,8 +3098,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({ @@ -3423,19 +3116,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" })], ] @@ -3473,8 +3156,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({ @@ -3484,18 +3166,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) @@ -3521,13 +3194,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( @@ -3567,14 +3235,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([ @@ -3629,15 +3291,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() @@ -3664,23 +3319,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) @@ -3712,35 +3356,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) @@ -3764,36 +3391,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() @@ -3816,14 +3426,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") @@ -3838,11 +3443,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") @@ -3857,14 +3460,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" }), ] @@ -3875,22 +3481,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 @@ -3924,15 +3531,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" }), @@ -3957,13 +3558,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) @@ -3978,12 +3574,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 @@ -4020,12 +3614,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 @@ -4039,10 +3631,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) @@ -4093,20 +3683,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 @@ -4126,10 +3710,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 @@ -4141,16 +3723,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 @@ -4168,7 +3743,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([ @@ -4182,23 +3757,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" }), ] @@ -4225,13 +3789,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: {} }), @@ -4255,22 +3814,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)) @@ -4300,21 +3846,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" }), ] @@ -4337,13 +3873,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() @@ -4351,12 +3882,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))), ) @@ -4385,25 +3911,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), ) @@ -4435,13 +3947,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" }), @@ -4457,13 +3965,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" }), @@ -4505,11 +4009,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)) @@ -4521,23 +4021,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" }), ] @@ -4556,11 +4048,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 8f9803ef4a6..d8cd7fbb563 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 8344771d2a6..e61bb54b636 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 ae68d6c7ff3..100d44cfe26 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 b9193023bfa..1b8d846e69a 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 704f42b803b..1c5b5b28392 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 5e01fefc17d..3bc0f341013 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 6aa7c89aa28..43b20d0cbfa 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 e92465fc3db..6a6a53e171d 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 6a49fa22fad..8d81917d8c4 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 126cd0758ca..fc07d60a5cc 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -4,6 +4,7 @@ import { ATTR_OPENCODE_SUBAGENT_AGENT_NAME, ATTR_OPENCODE_SUBAGENT_SESSION_ID, } from "@opencode-ai/core/observability/semconv" +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" @@ -76,7 +77,7 @@ const executionNode = makeGlobalNode({ sessionID, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens, }) }) @@ -117,6 +118,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/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index a0f737a998d..ca2820737d8 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -134,6 +134,29 @@ describe("util.effect-flock", () => { }), ) + it.live( + "supports an acquisition timeout", + Effect.gen(function* () { + const flock = yield* EffectFlock.Service + const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-"))) + const dir = path.join(tmp, "locks") + const key = "eflock:timeout" + + yield* Effect.scoped( + Effect.gen(function* () { + yield* flock.acquire(key, dir) + const started = performance.now() + const error = yield* Effect.scoped( + flock.acquire(key, dir, { staleMs: 10_000, timeoutMs: 300 }), + ).pipe(Effect.flip) + expect(error._tag).toBe("LockTimeoutError") + expect(performance.now() - started).toBeLessThan(1_000) + }), + ) + yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true })) + }), + ) + it.live( "withLock data-first", Effect.gen(function* () { diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 68a975cf850..e8559d72f6b 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 bcad31bd32b..c2cd5a1d213 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 781bcd5cbeb..ce429323d88 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 8c8ac59f7ab..91cb50891de 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/http-recorder/README.md b/packages/http-recorder/README.md index f388e8e2539..84c539081ec 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -9,13 +9,13 @@ Use it for provider integrations, retries, polling, multi-step flows, and any te ## Install ```sh -bun add effect@4.0.0-beta.74 -bun add -d @opencode-ai/http-recorder@beta @effect/vitest vitest +bun add effect@4.0.0-beta.83 +bun add -d @opencode-ai/http-recorder @effect/vitest@4.0.0-beta.83 vitest@^4 ``` The package supports Node.js 22+ and Bun. It is not intended for browsers, workers, or Deno. -Effect `4.0.0-beta.74` has a known declaration error (`SchemaErrorTypeId` is missing). Until that upstream declaration is fixed, TypeScript consumers need: +Effect `4.0.0-beta.83` currently contains unresolved symbols in its published declarations. Until those upstream declarations are fixed, TypeScript consumers need: ```json { @@ -51,7 +51,7 @@ describe("getUser", () => { assert.strictEqual(user.id, 1) assert.strictEqual(user.name, "Leanne Graham") - }).pipe(Effect.provide(HttpRecorder.http("users/get-one"))), + }).pipe(Effect.provide(HttpRecorder.layerFetch("users/get-one"))), ) }) ``` @@ -81,49 +81,83 @@ Application code does not need to know whether a response is live or replayed. ## API ```ts -HttpRecorder.http(name, options?) -HttpRecorder.socket(name, options?) +HttpRecorder.layer(name, options?) +HttpRecorder.layerFetch(name, options?) +HttpRecorder.layerSocket(name, options?) +HttpRecorder.layerWebSocketConstructor(name, options?) +HttpRecorder.hasCassetteSync(name, options?) +HttpRecorder.removeCassetteSync(name, options?) ``` -That is the complete public API. `http` provides a fetch-backed recorded `HttpClient`. `socket` decorates a standard Effect `Socket.Socket` supplied beneath it. +That is the complete runtime API. `layer` decorates an application-provided `HttpClient`; `layerFetch` is the convenience layer that supplies Effect's fetch client. `layerWebSocketConstructor` decorates Effect's `Socket.WebSocketConstructor`, recording every dynamically selected URL and protocol. `layerSocket` is the lower-level transport-neutral decorator for an application-provided `Socket.Socket`. + +Use `hasCassetteSync` when registering fixture-gated tests. `removeCassetteSync` explicitly removes one cassette before a focused refresh; removing a missing cassette is a no-op. Both helpers use the same cassette-name validation and default directory as the recorder layers. + +Use `layer` to record through another Effect HTTP transport: + +```ts +import { NodeHttpClient } from "@effect/platform-node" +import { Layer } from "effect" + +const recorder = HttpRecorder.layer("users/get-one").pipe(Layer.provide(NodeHttpClient.layerUndici)) +``` + +The `HttpRecorder` namespace also exposes the configuration types `RecorderOptions`, `SocketRecorderOptions`, `RedactOptions`, `RequestMatcher`, `RequestSnapshot`, and `CassetteMetadata`. ## WebSockets -WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay follows that chronology: server frames are released until the next recorded client frame, then replay waits for the application to send the matching frame before continuing. +Real applications often select WebSocket URLs inside domain services. Effect represents that capability with `Socket.WebSocketConstructor`; production supplies the platform implementation, while tests can decorate it without changing application code. ```ts -import { assert, it } from "@effect/vitest" import { NodeSocket } from "@effect/platform-node" -import { Effect, Layer } from "effect" +import { it } from "@effect/vitest" +import { Deferred, Effect, Layer } from "effect" import { Socket } from "effect/unstable/socket" import { HttpRecorder } from "@opencode-ai/http-recorder" -const echo = Effect.gen(function* () { - const socket = yield* Socket.Socket +const roundTrip = Effect.fn("Echo.roundTrip")(function* (url: string, message: string) { + const socket = yield* Socket.makeWebSocket(url, { closeCodeIsError: () => false }) const write = yield* socket.writer + const echoed = yield* Deferred.make() yield* socket.runString( - (message) => - Effect.gen(function* () { - assert.strictEqual(message, "hello") - yield* write(new Socket.CloseEvent(1000)) - }), - { onOpen: write("hello") }, + (response) => { + return Deferred.succeed(echoed, response).pipe( + Effect.andThen(write(new Socket.CloseEvent(1000, "done"))), + Effect.orDie, + ) + }, + { onOpen: write(message).pipe(Effect.orDie) }, ) + + return yield* Deferred.await(echoed) }) -const recordedSocket = HttpRecorder.socket("echo/hello").pipe( - Layer.provide( - NodeSocket.layerWebSocket("wss://ws.postman-echo.com/raw", { - closeCodeIsError: (code) => code !== 1000, - }), +it.effect("round trips a message", () => + roundTrip("wss://ws.postman-echo.com/raw", "hello").pipe( + Effect.scoped, + Effect.provide( + HttpRecorder.layerWebSocketConstructor("echo/round-trip").pipe( + Layer.provide(NodeSocket.layerWebSocketConstructor), + ), + ), ), ) - -it.effect("exchanges WebSocket frames", () => echo.pipe(Effect.provide(recordedSocket))) ``` -The application owns the WebSocket URL and protocols through normal Effect layer wiring. The recorder wraps that socket without duplicating its URL in recorder configuration. Provide separate socket layers for separate endpoints or concurrent connections. +The production application supplies only `NodeSocket.layerWebSocketConstructor`. The recorder appears in test wiring and observes each call to `Socket.makeWebSocket`, including URLs selected at runtime. + +`socket.runString` owns the receive loop and finishes when the connection closes or fails. Its optional `onOpen` effect is the safe place to send protocols whose client speaks first. The writer is scoped because sending is valid only while a connection run is active. + +WebSocket cassettes preserve one ordered transcript of client and server text or binary frames. Replay releases recorded server frames until it reaches a client frame, waits for the application to write the matching frame, then continues. This preserves causal ordering without reproducing network timing. + +Client text frames containing JSON compare canonically, so object-key order does not matter. Changed fields, extra fields, non-JSON text, and binary frames must match exactly after redaction. There is intentionally no custom WebSocket matcher in this beta. + +Incoming frame handlers start in recorded order and may run concurrently, matching Effect's socket abstraction. Replay waits for all handlers before the socket run completes, but handler completion order is not guaranteed. Use Effect synchronization such as `Queue`, `Ref`, or `Deferred` instead of unsynchronized mutable state. + +A constructor cassette records the URL, requested protocols, frames, and terminal close for each connection. Replay validates the URL and protocols before opening the simulated socket. Closing before every recorded frame is consumed fails the test. + +Use `layerSocket` when a protocol layer already consumes one application-provided `Socket.Socket`, including non-WebSocket transports. Because that lower-level abstraction has no URL or protocols, its cassettes use the cassette name and connection order as identity. Text frames use the same JSON-field and body redaction as HTTP bodies. Binary frames are stored losslessly as base64. Client and server frame kinds must match during replay. @@ -143,7 +177,7 @@ There is intentionally no public overwrite mode. Deletion makes the set of recor Secure defaults remove most headers and redact common credentials in headers, URLs, and JSON bodies. Extend those defaults at layer construction: ```ts -HttpRecorder.http("anthropic/messages", { +HttpRecorder.layerFetch("anthropic/messages", { redact: { headers: ["x-project-token"], allowRequestHeaders: ["anthropic-version"], @@ -171,16 +205,16 @@ Redaction is defense in depth, not a substitute for review. Inspect cassette dif ## Matching And Ordering -A cassette contains an ordered sequence of interactions. The first runtime request is checked against the first recorded request, the second against the second, and so on. +A runtime request atomically claims the first unused recorded interaction that matches it. Distinct requests may replay in any order or concurrently. -This strict ordering correctly models repeated identical requests whose responses change, including retries, polling, and cache tests. JSON object keys are canonicalized before matching. +Repeated identical requests consume their matching responses in cassette order, which models retries, polling, and cache tests deterministically. A mismatch consumes nothing, and JSON object keys are canonicalized before matching. -Concurrent requests are recorded in request-start order even when their responses complete out of order. +Concurrent requests are recorded in request-start order even when their responses complete out of order. Each recorded interaction can be claimed only once, and leaving interactions unused fails when the recorder layer closes. Supply a custom equivalence rule when a request contains intentionally volatile data: ```ts -HttpRecorder.http("events/create", { +HttpRecorder.layerFetch("events/create", { match: (incoming, recorded) => incoming.method === recorded.method && new URL(incoming.url).pathname === new URL(recorded.url).pathname, }) @@ -191,14 +225,18 @@ HttpRecorder.http("events/create", { ```ts interface RecorderOptions { readonly directory?: string - readonly metadata?: Record + readonly metadata?: Readonly> readonly redact?: RedactOptions readonly match?: RequestMatcher } + +type SocketRecorderOptions = Omit ``` `directory` defaults to `/test/fixtures/recordings`. +See [`examples/`](./examples) for complete HTTP and WebSocket examples. + ## Cassettes Cassettes are readable JSON files intended to be committed with your tests. HTTP interactions are stored in request order. WebSocket cassettes preserve the observed order of client and server frames. Text stays readable; binary bodies and frames are stored losslessly as base64. @@ -207,7 +245,8 @@ Cassettes are readable JSON files intended to be committed with your tests. HTTP - Responses are buffered while recording and replaying, so this beta is not suitable for tests that assert streaming timing, cancellation, or backpressure. - WebSocket replay preserves frame chronology and content, not real network timing or backpressure. -- WebSocket V1 cassettes do not reproduce terminal close codes, close reasons, or transport failures. Failed and interrupted live runs are not recorded. +- Constructor-level WebSocket cassettes reproduce terminal close codes and reasons, but not selected subprotocols, handshake headers, transport timing, or transport failures. Lower-level `layerSocket` cassettes contain frames only. +- Failed and interrupted live WebSocket connections are not recorded. - WebSocket transcripts are retained in memory until the connection finishes; avoid using this beta for unbounded sessions. - The package currently requires the exact Effect beta listed above. - Cassette format version `1` has no migration tooling yet. diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 0d4f1175ad0..b3166190f68 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "version": "1.17.14", "name": "@opencode-ai/http-recorder", - "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", + "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "type": "module", "license": "MIT", "repository": { @@ -28,14 +28,14 @@ }, "scripts": { "test": "bun test --timeout 30000 --only-failures", - "typecheck": "tsgo --noEmit", + "typecheck": "tsgo --noEmit && tsgo -p test/tsconfig.json --noEmit", "build": "bun ./script/build.ts", "verify:package": "bun ./script/verify-package.ts" }, "exports": { - ".": "./src/index.ts", - "./internal": "./src/internal.ts" + ".": "./src/index.ts" }, + "sideEffects": false, "files": [ "dist", "README.md", @@ -47,14 +47,14 @@ "@types/bun": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "@effect/platform-node": "catalog:", "effect": "catalog:", "typescript": "catalog:" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83" }, "peerDependencies": { - "effect": "4.0.0-beta.83" + "effect": "catalog:" } } diff --git a/packages/http-recorder/script/build.ts b/packages/http-recorder/script/build.ts index f7d839236eb..9d9de65e625 100644 --- a/packages/http-recorder/script/build.ts +++ b/packages/http-recorder/script/build.ts @@ -14,7 +14,13 @@ const build = await Bun.build({ }) if (!build.success) throw new AggregateError(build.logs, "Failed to build @opencode-ai/http-recorder") -const publicFiles = new Set(["index.js", "index.d.ts", "effect.d.ts", "socket.d.ts", "types.d.ts"]) await Promise.all( - (await readdir("dist")).filter((file) => !publicFiles.has(file)).map((file) => rm(`dist/${file}`, { force: true })), + (await readdir("dist", { recursive: true })) + .filter((file) => file.endsWith(".d.ts") && file !== "index.d.ts" && file !== "api.d.ts") + .map((file) => rm(`dist/${file}`)), ) + +for (const file of ["dist/index.d.ts", "dist/api.d.ts"]) { + if ((await Bun.file(file).text()).includes(["import", "("].join(""))) + throw new Error(`${file} contains dynamic import syntax`) +} diff --git a/packages/http-recorder/script/pack.ts b/packages/http-recorder/script/pack.ts index 4dad7a44167..c40d83c8497 100644 --- a/packages/http-recorder/script/pack.ts +++ b/packages/http-recorder/script/pack.ts @@ -15,10 +15,6 @@ export const pack = async () => { } for (const [key, value] of Object.entries(pkg.exports)) { - if (key === "./internal") { - delete pkg.exports[key] - continue - } if (typeof value !== "string") continue const file = value.replace("./src/", "./dist/").replace(/\.ts$/, "") pkg.exports[key] = { import: `${file}.js`, types: `${file}.d.ts` } @@ -32,4 +28,14 @@ export const pack = async () => { } } +export const withPackedArchive = async (use: (archive: string) => Promise) => { + const archive = await pack() + try { + return await use(archive) + } finally { + const file = Bun.file(archive) + if (await file.exists()) await file.delete() + } +} + if (import.meta.main) await pack() diff --git a/packages/http-recorder/script/verify-package.ts b/packages/http-recorder/script/verify-package.ts index 272394c9446..04f1f2bca5c 100644 --- a/packages/http-recorder/script/verify-package.ts +++ b/packages/http-recorder/script/verify-package.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" -import { pack } from "./pack.js" +import { withPackedArchive } from "./pack.js" const run = async (command: ReadonlyArray, cwd: string) => { const process = Bun.spawn(command, { cwd, env: globalThis.process.env, stdout: "inherit", stderr: "inherit" }) @@ -10,6 +10,11 @@ const run = async (command: ReadonlyArray, cwd: string) => { if (exitCode !== 0) throw new Error(`${command.join(" ")} exited with code ${exitCode}`) } +const reject = async (command: ReadonlyArray, cwd: string) => { + const process = Bun.spawn([...command], { cwd, env: globalThis.process.env, stdout: "ignore", stderr: "ignore" }) + if ((await process.exited) === 0) throw new Error(`${command.join(" ")} unexpectedly succeeded`) +} + export const verifyPackage = async (archive: string) => { const directory = await mkdtemp(path.join(tmpdir(), "http-recorder-consumer-")) try { @@ -25,11 +30,40 @@ import { Layer } from "effect" import { HttpClient } from "effect/unstable/http" import { Socket } from "effect/unstable/socket" -const options: HttpRecorder.RecorderOptions = { redact: { jsonFields: ["access_token"] } } -HttpRecorder.http("consumer", options) satisfies Layer.Layer -HttpRecorder.socket("consumer/socket", options).pipe( +const options: HttpRecorder.RecorderOptions = { match: () => true, redact: { jsonFields: ["access_token"] } } +const socketOptions: HttpRecorder.SocketRecorderOptions = { redact: { jsonFields: ["access_token"] } } +HttpRecorder.layer("consumer", options) satisfies Layer.Layer +HttpRecorder.layerFetch("consumer", options) satisfies Layer.Layer +HttpRecorder.hasCassetteSync("consumer", { directory: "recordings" }) satisfies boolean +HttpRecorder.removeCassetteSync("consumer", { directory: "recordings" }) +HttpRecorder.layerSocket("consumer/socket", socketOptions).pipe( Layer.provide(NodeSocket.layerWebSocket("wss://example.test")), ) satisfies Layer.Layer +HttpRecorder.layerWebSocketConstructor("consumer/websocket", socketOptions).pipe( + Layer.provide(NodeSocket.layerWebSocketConstructor), +) satisfies Layer.Layer +// @ts-expect-error HTTP request matching does not apply to WebSocket frames. +HttpRecorder.layerSocket("consumer/socket", { match: () => true }) +`, + ) + await writeFile( + path.join(directory, "exports.mjs"), + `import { HttpRecorder } from "@opencode-ai/http-recorder" + +const root = Object.keys(await import("@opencode-ai/http-recorder")).sort() +if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) { + throw new Error(\`Unexpected root exports: \${root}\`) +} + +const namespace = Object.keys(HttpRecorder).sort() +if (JSON.stringify(namespace) !== JSON.stringify(["hasCassetteSync", "layer", "layerFetch", "layerSocket", "layerWebSocketConstructor", "removeCassetteSync"])) { + throw new Error(\`Unexpected HttpRecorder exports: \${namespace}\`) +} +`, + ) + await writeFile( + path.join(directory, "deep-import.mjs"), + `import "@opencode-ai/http-recorder/internal" `, ) await writeFile( @@ -41,7 +75,7 @@ HttpRecorder.socket("consumer/socket", options).pipe( moduleResolution: "NodeNext", strict: true, noEmit: true, - // Required by effect@4.0.0-beta.74: its schema.d.ts references an undeclared SchemaErrorTypeId. + // Required by effect@4.0.0-beta.83: its declarations currently contain unresolved internal symbols. skipLibCheck: true, lib: ["ES2022", "DOM", "ESNext.Disposable"], }, @@ -49,27 +83,28 @@ HttpRecorder.socket("consumer/socket", options).pipe( }), ) - await run(["npm", "install", archive, "typescript@5.8.2"], directory) await run( [ - "node", - "--input-type=module", - "-e", - 'import("@opencode-ai/http-recorder").then((module) => { const root = Object.keys(module).sort(); const namespace = Object.keys(module.HttpRecorder).sort(); if (JSON.stringify(root) !== JSON.stringify(["HttpRecorder"])) throw new Error(`Unexpected root exports: ${root}`); if (JSON.stringify(namespace) !== JSON.stringify(["http", "socket"])) throw new Error(`Unexpected namespace exports: ${namespace}`) })', + "npm", + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--package-lock=false", + archive, + "typescript@5.8.2", + "effect@4.0.0-beta.83", + "@effect/platform-node@4.0.0-beta.83", ], directory, ) + await run(["node", path.join(directory, "exports.mjs")], directory) + await run(["bun", path.join(directory, "exports.mjs")], directory) + await reject(["node", path.join(directory, "deep-import.mjs")], directory) await run([path.join(directory, "node_modules", ".bin", "tsc"), "--noEmit"], directory) } finally { await rm(directory, { recursive: true, force: true }) } } -if (import.meta.main) { - const archive = await pack() - try { - await verifyPackage(archive) - } finally { - await Bun.file(archive).delete() - } -} +if (import.meta.main) await withPackedArchive(verifyPackage) diff --git a/packages/http-recorder/src/api.ts b/packages/http-recorder/src/api.ts new file mode 100644 index 00000000000..47d652029b8 --- /dev/null +++ b/packages/http-recorder/src/api.ts @@ -0,0 +1,46 @@ +/** JSON-compatible cassette metadata value. */ +export type JsonValue = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: JsonValue } + +/** Additional JSON metadata stored with a cassette. */ +export type CassetteMetadata = Readonly> + +/** The normalized HTTP request representation used for matching. */ +export interface RequestSnapshot { + readonly method: string + readonly url: string + readonly headers: Record + readonly body: string +} + +/** Returns whether an incoming HTTP request matches a recorded request. */ +export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean + +/** Additive redaction and header-preservation policy. */ +export interface RedactOptions { + readonly headers?: ReadonlyArray + readonly allowRequestHeaders?: ReadonlyArray + readonly allowResponseHeaders?: ReadonlyArray + readonly queryParameters?: ReadonlyArray + readonly jsonFields?: ReadonlyArray + readonly url?: (url: string) => string + readonly body?: (body: string) => string +} + +/** Options shared by HTTP recorder layers. */ +export interface RecorderOptions { + readonly directory?: string + readonly metadata?: CassetteMetadata + readonly redact?: RedactOptions + readonly match?: RequestMatcher +} + +/** Recorder configuration for Effect socket and WebSocket layers. */ +export type SocketRecorderOptions = Omit + +export * as Api from "./api.js" diff --git a/packages/http-recorder/src/cassette/model.ts b/packages/http-recorder/src/cassette/model.ts new file mode 100644 index 00000000000..8667505da8a --- /dev/null +++ b/packages/http-recorder/src/cassette/model.ts @@ -0,0 +1,43 @@ +import { Schema } from "effect" +import type { CassetteMetadata, JsonValue } from "../api.js" +import { HttpInteractionSchema } from "../http/model.js" +import { WebSocketInteractionSchema } from "../websocket/model.js" + +export type { CassetteMetadata, JsonValue } from "../api.js" + +const JsonValueSchema = Schema.suspend( + (): Schema.Codec => + Schema.Union([ + Schema.Null, + Schema.Boolean, + Schema.Number, + Schema.String, + Schema.Array(JsonValueSchema), + Schema.Record(Schema.String, JsonValueSchema), + ]), +) + +export const CassetteMetadataSchema = Schema.Record(Schema.String, JsonValueSchema) + +export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( + Schema.toTaggedUnion("transport"), +) +export type Interaction = Schema.Schema.Type + +export const isHttpInteraction = InteractionSchema.guards.http +export const isWebSocketInteraction = InteractionSchema.guards.websocket +export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) +export const webSocketInteractions = (interactions: ReadonlyArray) => + interactions.filter(isWebSocketInteraction) + +export const CassetteSchema = Schema.Struct({ + version: Schema.Literal(1), + metadata: Schema.optional(CassetteMetadataSchema), + interactions: Schema.Array(InteractionSchema), +}) +export type Cassette = Schema.Schema.Type + +export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) +export const encodeCassette = Schema.encodeSync(CassetteSchema) + +export * as CassetteModel from "./model.js" diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette/store.ts similarity index 81% rename from packages/http-recorder/src/cassette.ts rename to packages/http-recorder/src/cassette/store.ts index 5138bb12dff..2079632bea9 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette/store.ts @@ -1,8 +1,8 @@ import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect" -import * as fs from "node:fs" -import * as path from "node:path" -import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js" -import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js" +import { existsSync, rmSync } from "node:fs" +import path from "node:path" +import { secretFindings, SecretFindingSchema, type SecretFinding } from "../redaction/secrets.js" +import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./model.js" const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") @@ -14,6 +14,15 @@ export class CassetteNotFoundError extends Schema.TaggedErrorClass()("InvalidCassetteError", { + cassetteName: Schema.String, + description: Schema.String, +}) { + override get message() { + return `Cassette "${this.cassetteName}" is invalid: ${this.description}` + } +} + export class UnsafeCassetteError extends Schema.TaggedErrorClass()("UnsafeCassetteError", { cassetteName: Schema.String, findings: Schema.Array(SecretFindingSchema), @@ -26,7 +35,9 @@ export class UnsafeCassetteError extends Schema.TaggedErrorClass Effect.Effect, CassetteNotFoundError> + readonly read: ( + name: string, + ) => Effect.Effect, CassetteNotFoundError | InvalidCassetteError> readonly append: ( name: string, interaction: Interaction, @@ -50,7 +61,10 @@ const cassettePath = (directory: string, name: string) => { } export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => - fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) + existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name)) + +export const removeCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + rmSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name), { force: true }) const buildCassette = ( name: string, @@ -58,14 +72,16 @@ const buildCassette = ( metadata: CassetteMetadata | undefined, ): Cassette => ({ version: 1, - metadata: { name, recordedAt: new Date().toISOString(), ...metadata }, + metadata: { ...metadata, name, recordedAt: new Date().toISOString() }, interactions, }) - const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` - const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema)) - +const invalidCassette = (name: string, error: unknown) => + new InvalidCassetteError({ + cassetteName: name, + description: error instanceof Error ? error.message : String(error), + }) const failIfUnsafe = (name: string, findings: ReadonlyArray) => findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })) @@ -79,9 +95,7 @@ export const fileSystem = ( const directory = options.directory ?? DEFAULT_RECORDINGS_DIR const recorded = new Map() const appendLock = yield* Semaphore.make(1) - const pathFor = (name: string) => cassettePath(directory, name) - const walk = (current: string): Effect.Effect> => Effect.gen(function* () { const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -98,8 +112,17 @@ export const fileSystem = ( return Service.of({ read: (name) => fs.readFileString(pathFor(name)).pipe( - Effect.map((raw) => parseCassette(raw).interactions), - Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + Effect.mapError((error) => + error.reason._tag === "NotFound" + ? new CassetteNotFoundError({ cassetteName: name }) + : invalidCassette(name, error), + ), + Effect.flatMap((raw) => + Effect.try({ + try: () => parseCassette(raw).interactions, + catch: (error) => invalidCassette(name, error), + }), + ), ), append: (name, interaction, metadata) => appendLock.withPermit( @@ -151,7 +174,6 @@ export const memory = (initial: Record> = {}) ) const accumulatedFindings = new Map() const appendLock = Semaphore.makeUnsafe(1) - return Service.of({ read: (name) => stored.has(name) @@ -162,7 +184,7 @@ export const memory = (initial: Record> = {}) Effect.suspend(() => { const interactions = [...(stored.get(name) ?? []), interaction] const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)] - const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings + const allFindings = metadata ? [...findings, ...secretFindings({ ...metadata, name })] : findings return failIfUnsafe(name, allFindings).pipe( Effect.tap(() => Effect.sync(() => { diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts deleted file mode 100644 index 583dee8ca12..00000000000 --- a/packages/http-recorder/src/effect.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import * as Layer from "effect/Layer" -import { FetchHttpClient } from "effect/unstable/http" -import type * as HttpClient from "effect/unstable/http/HttpClient" -import * as CassetteService from "./cassette.js" -import { recordingLayer } from "./internal-effect.js" -import { make } from "./redactor.js" -import type { RecorderOptions } from "./types.js" - -/** - * Provides a fetch-backed `HttpClient` with cassette recording and replay. - * - * Locally, a missing cassette is recorded from the real service. Existing - * cassettes are replayed, and `CI=true` makes a missing cassette fail. - */ -export const http = (name: string, options: RecorderOptions = {}): Layer.Layer => - recordingLayer(name, { - metadata: options.metadata, - redactor: make(options.redact), - match: options.match, - }).pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), - Layer.provide(FetchHttpClient.layer), - Layer.provide(NodeFileSystem.layer), - ) diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/http/matching.ts similarity index 54% rename from packages/http-recorder/src/matching.ts rename to packages/http-recorder/src/http/matching.ts index 731aa8b57ac..fcd446eddad 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/http/matching.ts @@ -1,64 +1,31 @@ -import { Option, Schema } from "effect" -import { REDACTED, secretFindings } from "./redaction.js" -import type { HttpInteraction, RequestMatcher, RequestSnapshot } from "./types.js" +import { HashSet, Option } from "effect" +import type { RequestMatcher, RequestSnapshot } from "../api.js" +import { canonicalizeJson, decodeJson, isJsonRecord, jsonBody, safeText } from "../replay/comparison.js" +import type { HttpInteraction } from "./model.js" -const JsonValue = Schema.fromJsonString(Schema.Unknown) -export const decodeJson = Schema.decodeUnknownOption(JsonValue) - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === "object" && !Array.isArray(value) - -export const canonicalizeJson = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(canonicalizeJson) - if (isRecord(value)) { - return Object.fromEntries( - Object.keys(value) - .toSorted() - .map((key) => [key, canonicalizeJson(value[key])]), - ) - } - return value -} - -export type { RequestMatcher } from "./types.js" +export type { RequestMatcher } from "../api.js" export const canonicalSnapshot = (snapshot: RequestSnapshot): string => JSON.stringify({ method: snapshot.method, url: snapshot.url, headers: canonicalizeJson(snapshot.headers), - body: Option.match(decodeJson(snapshot.body), { - onNone: () => snapshot.body, - onSome: canonicalizeJson, - }), + body: Option.match(decodeJson(snapshot.body), { onNone: () => snapshot.body, onSome: canonicalizeJson }), }) - export const defaultMatcher: RequestMatcher = (incoming, recorded) => canonicalSnapshot(incoming) === canonicalSnapshot(recorded) -export const safeText = (value: unknown) => { - if (value === undefined) return "undefined" - if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) - const text = JSON.stringify(value) - if (!text) return typeof value - return text.length > 300 ? `${text.slice(0, 300)}...` : text -} - -const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) - const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { if (Object.is(expected, received)) return [] - if (isRecord(expected) && isRecord(received)) { + if (isJsonRecord(expected) && isJsonRecord(received)) return [...new Set([...Object.keys(expected), ...Object.keys(received)])] .toSorted() .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit)) .slice(0, limit) - } - if (Array.isArray(expected) && Array.isArray(received)) { + if (Array.isArray(expected) && Array.isArray(received)) return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) .slice(0, limit) - } return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] } @@ -72,12 +39,9 @@ const headerDiffs = (expected: Record, received: Record => { const lines: string[] = [] - if (expected.method !== received.method) { + if (expected.method !== received.method) lines.push("method:", ` expected ${expected.method}, received ${received.method}`) - } - if (expected.url !== received.url) { - lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) - } + if (expected.url !== received.url) lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) const headers = headerDiffs(expected.headers, received.headers) if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) const expectedBody = jsonBody(expected.body) @@ -92,15 +56,22 @@ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot return lines } -export const selectSequential = ( +export const selectFirstMatching = ( interactions: ReadonlyArray, incoming: RequestSnapshot, match: RequestMatcher, - index: number, -): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { - const interaction = interactions[index] - if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } - if (!match(incoming, interaction.request)) - return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } - return { interaction, detail: "" } + used: HashSet.HashSet, +): { readonly _tag: "Matched"; readonly index: number } | { readonly _tag: "Unmatched"; readonly detail: string } => { + let firstUnused: HttpInteraction | undefined + for (let index = 0; index < interactions.length; index++) { + if (HashSet.has(used, index)) continue + const interaction = interactions[index] + firstUnused ??= interaction + if (match(incoming, interaction.request)) return { _tag: "Matched", index } + } + if (firstUnused === undefined) + return { _tag: "Unmatched", detail: `all ${interactions.length} recorded interactions have already been consumed` } + return { _tag: "Unmatched", detail: requestDiff(firstUnused.request, incoming).join("\n") } } + +export * as HttpMatching from "./matching.js" diff --git a/packages/http-recorder/src/http/model.ts b/packages/http-recorder/src/http/model.ts new file mode 100644 index 00000000000..861d502f882 --- /dev/null +++ b/packages/http-recorder/src/http/model.ts @@ -0,0 +1,30 @@ +import { Schema } from "effect" +import type { RequestSnapshot } from "../api.js" + +export const RequestSnapshotSchema = Schema.Struct({ + method: Schema.String, + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, +}) + +export type { RequestSnapshot } from "../api.js" + +export const ResponseSnapshotSchema = Schema.Struct({ + status: Schema.Number, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, + bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), +}) + +export interface ResponseSnapshot extends Schema.Schema.Type {} + +export const HttpInteractionSchema = Schema.Struct({ + transport: Schema.tag("http"), + request: RequestSnapshotSchema, + response: ResponseSnapshotSchema, +}) + +export interface HttpInteraction extends Schema.Schema.Type {} + +export * as HttpModel from "./model.js" diff --git a/packages/http-recorder/src/internal-effect.ts b/packages/http-recorder/src/http/recorder.ts similarity index 67% rename from packages/http-recorder/src/internal-effect.ts rename to packages/http-recorder/src/http/recorder.ts index 6b886311a99..568b101743a 100644 --- a/packages/http-recorder/src/internal-effect.ts +++ b/packages/http-recorder/src/http/recorder.ts @@ -1,27 +1,22 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { Deferred, Effect, Layer, Option, Ref } from "effect" +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Deferred, Effect, Layer, Ref } from "effect" import { FetchHttpClient, - Headers, - HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, - UrlParams, } from "effect/unstable/http" -import * as CassetteService from "./cassette.js" -import { defaultMatcher, selectSequential } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import { make, type Redactor } from "./redactor.js" -import { redactUrl } from "./redaction.js" -import { httpInteractions } from "./schema.js" -import type { CassetteMetadata, HttpInteraction, RequestMatcher, ResponseSnapshot } from "./types.js" +import { fileSystem, Service } from "../cassette/store.js" +import type { RecorderOptions } from "../options.js" +import { make, redactUrl, type Redactor } from "../redaction/redactor.js" +import { makeReplayPoolState, resolveAutoMode } from "../replay/state.js" +import { httpInteractions, type CassetteMetadata } from "../cassette/model.js" +import { defaultMatcher, selectFirstMatching, type RequestMatcher } from "./matching.js" +import type { HttpInteraction, ResponseSnapshot } from "./model.js" export { defaultMatcher } - export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" - export interface RecordReplayOptions { readonly mode?: RecordReplayMode readonly directory?: string @@ -40,7 +35,6 @@ const TEXT_CONTENT_TYPES = new Set([ "application/yaml", "image/svg+xml", ]) - const isTextContentType = (contentType: string | undefined) => { const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() if (!mediaType) return false @@ -51,7 +45,6 @@ const isTextContentType = (contentType: string | undefined) => { TEXT_CONTENT_TYPES.has(mediaType) ) } - const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => response.arrayBuffer.pipe( Effect.map((bytes) => @@ -60,10 +53,8 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co : { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const }, ), ) - const decodeResponseBody = (snapshot: ResponseSnapshot) => snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body - const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snapshot: ResponseSnapshot) => HttpClientResponse.fromWeb( request, @@ -75,35 +66,28 @@ const responseFromSnapshot = (request: HttpClientRequest.HttpClientRequest, snap ), ) -export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => - HttpClientRequest.makeWith( - request.method, - redactUrl(request.url), - UrlParams.empty, - Option.none(), - Headers.empty, - HttpBody.empty, - ) - -const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => +export const redactedErrorRequest = ( + request: HttpClientRequest.HttpClientRequest, + redactedUrl = redactUrl(request.url), +) => HttpClientRequest.make(request.method)(redactedUrl) +const transportError = (request: HttpClientRequest.HttpClientRequest, description: string, redactedUrl?: string) => new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), + reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request, redactedUrl), description }), }) export const recordingLayer = ( name: string, options: Omit = {}, -): Layer.Layer => +): Layer.Layer => Layer.effect( HttpClient.HttpClient, Effect.gen(function* () { const upstream = yield* HttpClient.HttpClient - const cassetteService = yield* CassetteService.Service + const cassette = yield* Service const redactor = options.redactor ?? make() const match = options.match ?? defaultMatcher const requested = options.mode ?? "auto" - const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested - + const mode = requested === "auto" ? yield* resolveAutoMode(cassette, name) : requested const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) @@ -114,9 +98,7 @@ export const recordingLayer = ( body: yield* Effect.promise(() => web.text()), }) }) - if (mode === "passthrough") return upstream - if (mode === "record") { const initial = yield* Deferred.make() yield* Deferred.succeed(initial, undefined) @@ -127,6 +109,7 @@ export const recordingLayer = ( const previous = yield* Ref.modify(tail, (current) => [current, completed]) return yield* Effect.gen(function* () { const incoming = yield* snapshotRequest(request) + const requestError = (description: string) => transportError(request, description, incoming.url) const response = yield* upstream.execute(request) const captured = yield* captureResponseBody(response, response.headers["content-type"]) const responseSnapshot: ResponseSnapshot = { @@ -140,39 +123,32 @@ export const recordingLayer = ( response: redactor.response(responseSnapshot), } yield* Deferred.await(previous) - yield* cassetteService + yield* cassette .append(name, interaction, options.metadata) - .pipe( - Effect.catchTag("UnsafeCassetteError", (error) => - Effect.fail(transportError(request, error.message)), - ), - ) + .pipe(Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(requestError(error.message)))) return responseFromSnapshot(request, responseSnapshot) }).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))) }), ) } - - const replay = yield* makeReplayState(cassetteService, name, httpInteractions) + const replay = yield* makeReplayPoolState(cassette, name, httpInteractions) return HttpClient.make((request) => Effect.gen(function* () { const incoming = yield* snapshotRequest(request) + const requestError = (description: string) => transportError(request, description, incoming.url) const claimed = yield* replay - .claim((interaction, index, interactions) => { - const result = selectSequential(interactions, incoming, match, index) - if (result.interaction) return Effect.void + .claim((interactions, used) => { + const result = selectFirstMatching(interactions, incoming, match, used) + if (result._tag === "Matched") return Effect.succeed(result.index) return Effect.fail( - transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), + requestError(`Fixture "${name}" does not match the current request: ${result.detail}.`), ) }) .pipe( Effect.mapError((error) => error._tag === "CassetteNotFoundError" - ? transportError( - request, - `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`, - ) - : error, + ? requestError(`Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`) + : requestError(error.message), ), ) return responseFromSnapshot(request, claimed.interaction.response) @@ -183,7 +159,18 @@ export const recordingLayer = ( export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => recordingLayer(name, options).pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), + Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), ) + +export const layer = ( + name: string, + options: RecorderOptions = {}, +): Layer.Layer => + recordingLayer(name, { metadata: options.metadata, redactor: make(options.redact), match: options.match }).pipe( + Layer.provide(fileSystem({ directory: options.directory })), + Layer.provide(NodeFileSystem.layer), + ) +export const layerFetch = (name: string, options: RecorderOptions = {}): Layer.Layer => + layer(name, options).pipe(Layer.provide(FetchHttpClient.layer)) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index eac4b75b4bb..2dcba03e6fc 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -1,18 +1,43 @@ -import { http } from "./effect.js" -import { socket } from "./socket.js" +import { Layer } from "effect" +import { HttpClient } from "effect/unstable/http" +import { Socket } from "effect/unstable/socket" +import { Api } from "./api.js" +import { hasCassetteSync, removeCassetteSync } from "./cassette/store.js" +import { layer, layerFetch } from "./http/recorder.js" +import { layerSocket, layerWebSocketConstructor } from "./websocket/recorder.js" /** HTTP and WebSocket cassette recording. */ -export const HttpRecorder = { http, socket } as const +export const HttpRecorder: { + readonly layer: ( + name: string, + options?: Api.RecorderOptions, + ) => Layer.Layer + readonly layerFetch: (name: string, options?: Api.RecorderOptions) => Layer.Layer + readonly layerSocket: ( + name: string, + options?: Api.SocketRecorderOptions, + ) => Layer.Layer + readonly layerWebSocketConstructor: ( + name: string, + options?: Api.SocketRecorderOptions, + ) => Layer.Layer + readonly hasCassetteSync: (name: string, options?: { readonly directory?: string }) => boolean + readonly removeCassetteSync: (name: string, options?: { readonly directory?: string }) => void +} = { hasCassetteSync, layer, layerFetch, layerSocket, layerWebSocketConstructor, removeCassetteSync } export namespace HttpRecorder { /** Additional JSON metadata stored with a cassette. */ - export type CassetteMetadata = import("./types.js").CassetteMetadata + export type JsonValue = Api.JsonValue + /** Additional JSON metadata stored with a cassette. */ + export type CassetteMetadata = Api.CassetteMetadata /** Recorder configuration. */ - export type RecorderOptions = import("./types.js").RecorderOptions + export type RecorderOptions = Api.RecorderOptions /** Additive redaction and header-preservation policy. */ - export type RedactOptions = import("./types.js").RedactOptions + export type RedactOptions = Api.RedactOptions /** Returns whether an incoming HTTP request matches a recorded request. */ - export type RequestMatcher = import("./types.js").RequestMatcher + export type RequestMatcher = Api.RequestMatcher /** The normalized HTTP request representation used for matching. */ - export type RequestSnapshot = import("./types.js").RequestSnapshot + export type RequestSnapshot = Api.RequestSnapshot + /** Recorder configuration for Effect socket and WebSocket layers. */ + export type SocketRecorderOptions = Api.SocketRecorderOptions } diff --git a/packages/http-recorder/src/internal.ts b/packages/http-recorder/src/internal.ts deleted file mode 100644 index 7faecf0db06..00000000000 --- a/packages/http-recorder/src/internal.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { CassetteNotFoundError, hasCassetteSync, UnsafeCassetteError } from "./cassette.js" -export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./internal-effect.js" -export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction.js" -export { socketLayer } from "./socket.js" -export { - makeWebSocketExecutor, - type WebSocketConnection, - type WebSocketExecutor, - type WebSocketRecordReplayOptions, - type WebSocketRequest, -} from "./websocket.js" -export * as Cassette from "./cassette.js" -export * as Redactor from "./redactor.js" - -export * as HttpRecorderInternal from "./internal.js" diff --git a/packages/http-recorder/src/options.ts b/packages/http-recorder/src/options.ts new file mode 100644 index 00000000000..df62d6a2470 --- /dev/null +++ b/packages/http-recorder/src/options.ts @@ -0,0 +1 @@ +export type { RecorderOptions, RedactOptions, SocketRecorderOptions } from "./api.js" diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts deleted file mode 100644 index c58afde4633..00000000000 --- a/packages/http-recorder/src/recorder.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Effect, Scope, SynchronizedRef } from "effect" -import type * as CassetteService from "./cassette.js" -import type { CassetteNotFoundError } from "./cassette.js" -import type { Interaction } from "./schema.js" - -const isCI = () => { - const value = process.env.CI - return value !== undefined && value !== "" && value !== "false" && value !== "0" -} - -export const resolveAutoMode = ( - cassette: CassetteService.Interface, - name: string, -): Effect.Effect<"record" | "replay" | "passthrough"> => - Effect.gen(function* () { - if (isCI()) return "replay" - return (yield* cassette.exists(name)) ? "replay" : "record" - }) - -export interface ReplayState { - readonly claim: ( - validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray) => Effect.Effect, - ) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E> -} - -export const makeReplayState = ( - cassette: CassetteService.Interface, - name: string, - project: (interactions: ReadonlyArray) => ReadonlyArray, -): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) - const position = yield* SynchronizedRef.make(0) - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const used = yield* SynchronizedRef.get(position) - if (used === 0) return yield* Effect.void - const interactions = yield* load.pipe(Effect.orDie) - if (used < interactions.length) - return yield* Effect.die( - new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`), - ) - return yield* Effect.void - }), - ) - - return { - claim: (validate) => - Effect.flatMap(load, (interactions) => - SynchronizedRef.modifyEffect(position, (index) => - Effect.gen(function* () { - const interaction = interactions[index] - yield* validate(interaction, index, interactions) - if (interaction === undefined) - return yield* Effect.die("Replay validation accepted a missing interaction") - return [{ interaction, index }, index + 1] as const - }), - ), - ), - } - }) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts deleted file mode 100644 index b84996adecb..00000000000 --- a/packages/http-recorder/src/redaction.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Schema } from "effect" - -export const REDACTED = "[REDACTED]" - -const DEFAULT_REDACT_HEADERS = [ - "authorization", - "cookie", - "proxy-authorization", - "set-cookie", - "x-api-key", - "x-amz-security-token", - "x-goog-api-key", -] - -const DEFAULT_REDACT_QUERY = [ - "access_token", - "api-key", - "api_key", - "apikey", - "code", - "key", - "signature", - "sig", - "token", - "x-amz-credential", - "x-amz-security-token", - "x-amz-signature", -] - -const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ - { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, - { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, - { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, - { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, - { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, - { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, - { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, -] - -const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i -const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) - -const envSecrets = () => - Object.entries(process.env).flatMap(([name, value]) => { - if (!value) return [] - if (!ENV_SECRET_NAMES.test(name)) return [] - if (value.length < 12) return [] - if (SAFE_ENV_VALUES.has(value.toLowerCase())) return [] - return [{ name, value }] - }) - -const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) - -const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { - if (typeof value === "string") return [{ path: base, value }] - if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) - if (value && typeof value === "object") { - return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) - } - return [] -} - -const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => - new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) - -export type UrlRedactor = (url: string) => string - -export const redactUrl = ( - raw: string, - query: ReadonlyArray = DEFAULT_REDACT_QUERY, - urlRedactor?: UrlRedactor, -) => { - if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw - const url = new URL(raw) - if (url.username) url.username = REDACTED - if (url.password) url.password = REDACTED - const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) - for (const key of url.searchParams.keys()) { - if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) - } - return urlRedactor?.(url.toString()) ?? url.toString() -} - -export const redactHeaders = ( - headers: Record, - allow: ReadonlyArray, - redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, -) => { - const allowed = new Set(allow.map((name) => name.toLowerCase())) - const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) - return Object.fromEntries( - Object.entries(headers) - .map(([name, value]) => [name.toLowerCase(), value] as const) - .filter(([name]) => allowed.has(name)) - .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) - .toSorted(([a], [b]) => a.localeCompare(b)), - ) -} - -export const SecretFindingSchema = Schema.Struct({ - path: Schema.String, - reason: Schema.String, -}) -export type SecretFinding = Schema.Schema.Type - -export const secretFindings = (value: unknown): ReadonlyArray => { - const environment = envSecrets() - return stringEntries(value).flatMap((entry) => [ - ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ - path: entry.path, - reason: item.label, - })), - ...environment - .filter((item) => entry.value.includes(item.value)) - .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), - ]) -} diff --git a/packages/http-recorder/src/redaction/redactor.ts b/packages/http-recorder/src/redaction/redactor.ts new file mode 100644 index 00000000000..d921f55d73f --- /dev/null +++ b/packages/http-recorder/src/redaction/redactor.ts @@ -0,0 +1,173 @@ +import { Option, Schema } from "effect" +import type { RequestSnapshot, ResponseSnapshot } from "../http/model.js" +import type { RedactOptions } from "../options.js" + +export type { RedactOptions } from "../options.js" +export const REDACTED = "[REDACTED]" + +const DEFAULT_REDACT_HEADERS = [ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + "x-api-key", + "x-amz-security-token", + "x-goog-api-key", +] +const DEFAULT_REDACT_QUERY = [ + "access_token", + "api-key", + "api_key", + "apikey", + "code", + "key", + "signature", + "sig", + "token", + "x-amz-credential", + "x-amz-security-token", + "x-amz-signature", +] +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => + new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) + +export const redactUrl = ( + raw: string, + query: ReadonlyArray = DEFAULT_REDACT_QUERY, + transform?: (url: string) => string, +) => { + if (!URL.canParse(raw)) return transform?.(raw) ?? raw + const url = new URL(raw) + if (url.username) url.username = REDACTED + if (url.password) url.password = REDACTED + const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) + for (const key of url.searchParams.keys()) if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) + return transform?.(url.toString()) ?? url.toString() +} + +export const redactHeaders = ( + headers: Record, + allow: ReadonlyArray, + redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, +) => { + const allowed = new Set(allow.map((name) => name.toLowerCase())) + const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) + return Object.fromEntries( + Object.entries(headers) + .map(([name, value]) => [name.toLowerCase(), value] as const) + .filter(([name]) => allowed.has(name)) + .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) + .toSorted(([a], [b]) => a.localeCompare(b)), + ) +} + +const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] +const identity = (value: T) => value + +export interface Redactor { + readonly request: (snapshot: RequestSnapshot) => RequestSnapshot + readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot +} + +export const compose = (...redactors: ReadonlyArray>): Redactor => { + const requests = redactors + .map((redactor) => redactor.request) + .filter((fn): fn is Redactor["request"] => fn !== undefined) + const responses = redactors + .map((redactor) => redactor.response) + .filter((fn): fn is Redactor["response"] => fn !== undefined) + return { + request: requests.length === 0 ? identity : (snapshot) => requests.reduce((value, fn) => fn(value), snapshot), + response: responses.length === 0 ? identity : (snapshot) => responses.reduce((value, fn) => fn(value), snapshot), + } +} + +interface HeaderOptions { + readonly allow?: ReadonlyArray + readonly redact?: ReadonlyArray +} +const requestHeaders = (options: HeaderOptions = {}): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), + }), +}) +const responseHeaders = (options: HeaderOptions = {}): Partial => ({ + response: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), + }), +}) + +interface UrlOptions { + readonly query?: ReadonlyArray + readonly transform?: (url: string) => string +} +const url = (options: UrlOptions = {}): Partial => ({ + request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), +}) + +const DEFAULT_REDACT_JSON_FIELDS = [ + "access_token", + "api_key", + "apikey", + "client_secret", + "password", + "refresh_token", + "secret", + "token", +] +const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase() +interface RedactedJson { + readonly value: unknown + readonly changed: boolean +} +const redactJsonFields = (value: unknown, fields: ReadonlySet): RedactedJson => { + if (Array.isArray(value)) { + const items = value.map((item) => redactJsonFields(item, fields)) + return { value: items.map((item) => item.value), changed: items.some((item) => item.changed) } + } + if (!value || typeof value !== "object") return { value, changed: false } + let changed = false + const entries = Object.entries(value).map(([key, child]) => { + if (fields.has(normalizeField(key))) { + if (child !== REDACTED) changed = true + return [key, REDACTED] as const + } + const redacted = redactJsonFields(child, fields) + if (redacted.changed) changed = true + return [key, redacted.value] as const + }) + return { value: Object.fromEntries(entries), changed } +} +const redactBody = (value: string, fields: ReadonlySet, transform: ((body: string) => string) | undefined) => { + const redacted = Option.match(decodeJson(value), { + onNone: () => value, + onSome: (parsed) => { + const result = redactJsonFields(parsed, fields) + return result.changed ? JSON.stringify(result.value) : value + }, + }) + return transform?.(redacted) ?? redacted +} + +export const make = (options: RedactOptions = {}): Redactor => { + const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField)) + return compose( + requestHeaders({ + allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])], + redact: options.headers, + }), + responseHeaders({ + allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])], + redact: options.headers, + }), + url({ query: options.queryParameters, transform: options.url }), + { + request: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }), + response: (snapshot) => ({ ...snapshot, body: redactBody(snapshot.body, fields, options.body) }), + }, + ) +} diff --git a/packages/http-recorder/src/redaction/secrets.ts b/packages/http-recorder/src/redaction/secrets.ts new file mode 100644 index 00000000000..b3ffd3c3a63 --- /dev/null +++ b/packages/http-recorder/src/redaction/secrets.ts @@ -0,0 +1,47 @@ +import { Schema } from "effect" + +const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ + { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, + { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, + { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, + { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, + { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, + { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, +] + +const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i +const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) + +const envSecrets = () => + Object.entries(process.env).flatMap(([name, value]) => { + if (!value || !ENV_SECRET_NAMES.test(name) || value.length < 12 || SAFE_ENV_VALUES.has(value.toLowerCase())) + return [] + return [{ name, value }] + }) + +const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) + +const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { + if (typeof value === "string") return [{ path: base, value }] + if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) + if (value && typeof value === "object") + return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) + return [] +} + +export const SecretFindingSchema = Schema.Struct({ path: Schema.String, reason: Schema.String }) +export type SecretFinding = Schema.Schema.Type + +export const secretFindings = (value: unknown): ReadonlyArray => { + const environment = envSecrets() + return stringEntries(value).flatMap((entry) => [ + ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ + path: entry.path, + reason: item.label, + })), + ...environment + .filter((item) => entry.value.includes(item.value)) + .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), + ]) +} diff --git a/packages/http-recorder/src/redactor.ts b/packages/http-recorder/src/redactor.ts deleted file mode 100644 index 582c647702a..00000000000 --- a/packages/http-recorder/src/redactor.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Option } from "effect" -import { decodeJson } from "./matching.js" -import { REDACTED, redactHeaders, redactUrl } from "./redaction.js" -import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js" - -export type { RedactOptions } from "./types.js" - -export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] -export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] - -const identity = (value: T) => value - -export interface Redactor { - readonly request: (snapshot: RequestSnapshot) => RequestSnapshot - readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot -} - -export const compose = (...redactors: ReadonlyArray>): Redactor => { - const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined) - const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined) - return { - request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot), - response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot), - } -} - -export interface HeaderOptions { - readonly allow?: ReadonlyArray - readonly redact?: ReadonlyArray -} - -export const requestHeaders = (options: HeaderOptions = {}): Partial => ({ - request: (snapshot) => ({ - ...snapshot, - headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), - }), -}) - -export const responseHeaders = (options: HeaderOptions = {}): Partial => ({ - response: (snapshot) => ({ - ...snapshot, - headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), - }), -}) - -export interface UrlOptions { - readonly query?: ReadonlyArray - readonly transform?: (url: string) => string -} - -export const url = (options: UrlOptions = {}): Partial => ({ - request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), -}) - -export const body = (transform: (parsed: unknown) => unknown): Partial => ({ - request: (snapshot) => ({ - ...snapshot, - body: Option.match(decodeJson(snapshot.body), { - onNone: () => snapshot.body, - onSome: (parsed) => JSON.stringify(transform(parsed)), - }), - }), -}) - -export interface DefaultRedactorOverrides { - readonly requestHeaders?: HeaderOptions - readonly responseHeaders?: HeaderOptions - readonly url?: UrlOptions - readonly body?: (parsed: unknown) => unknown -} - -const DEFAULT_REDACT_JSON_FIELDS = [ - "access_token", - "api_key", - "apikey", - "client_secret", - "password", - "refresh_token", - "secret", - "token", -] - -const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase() - -const redactJsonFields = (value: unknown, fields: ReadonlySet): unknown => { - if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields)) - if (!value || typeof value !== "object") return value - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [ - key, - fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields), - ]), - ) -} - -const redactBody = (value: string, fields: ReadonlySet, transform: ((body: string) => string) | undefined) => { - const redacted = Option.match(decodeJson(value), { - onNone: () => value, - onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)), - }) - return transform?.(redacted) ?? redacted -} - -export const make = (options: RedactOptions = {}): Redactor => { - const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField)) - return compose( - requestHeaders({ - allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])], - redact: options.headers, - }), - responseHeaders({ - allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])], - redact: options.headers, - }), - url({ query: options.queryParameters, transform: options.url }), - { - request: (snapshot) => ({ - ...snapshot, - body: redactBody(snapshot.body, fields, options.body), - }), - response: (snapshot) => ({ - ...snapshot, - body: redactBody(snapshot.body, fields, options.body), - }), - }, - ) -} - -export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor => - compose( - requestHeaders(overrides.requestHeaders), - responseHeaders(overrides.responseHeaders), - url(overrides.url), - ...(overrides.body ? [body(overrides.body)] : []), - ) diff --git a/packages/http-recorder/src/replay/comparison.ts b/packages/http-recorder/src/replay/comparison.ts new file mode 100644 index 00000000000..106dbd33a47 --- /dev/null +++ b/packages/http-recorder/src/replay/comparison.ts @@ -0,0 +1,29 @@ +import { Option, Schema } from "effect" +import { REDACTED } from "../redaction/redactor.js" +import { secretFindings } from "../redaction/secrets.js" + +export const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value) + +export const canonicalizeJson = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalizeJson) + if (isRecord(value)) + return Object.fromEntries( + Object.keys(value) + .toSorted() + .map((key) => [key, canonicalizeJson(value[key])]), + ) + return value +} + +export const safeText = (value: unknown) => { + if (value === undefined) return "undefined" + if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) + const text = JSON.stringify(value) + if (!text) return typeof value + return text.length > 300 ? `${text.slice(0, 300)}...` : text +} + +export const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) +export const isJsonRecord = isRecord diff --git a/packages/http-recorder/src/replay/state.ts b/packages/http-recorder/src/replay/state.ts new file mode 100644 index 00000000000..79f6edfb3ae --- /dev/null +++ b/packages/http-recorder/src/replay/state.ts @@ -0,0 +1,96 @@ +import { Effect, Exit, HashSet, Ref, Scope, SynchronizedRef } from "effect" +import type { Interaction } from "../cassette/model.js" +import type { CassetteNotFoundError, Interface, InvalidCassetteError } from "../cassette/store.js" + +const isCI = () => { + const value = process.env.CI + return value !== undefined && value !== "" && value !== "false" && value !== "0" +} + +export const resolveAutoMode = ( + cassette: Interface, + name: string, +): Effect.Effect<"record" | "replay" | "passthrough"> => + Effect.gen(function* () { + if (isCI()) return "replay" + return (yield* cassette.exists(name)) ? "replay" : "record" + }) + +export interface ReplayState { + readonly claim: ( + validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect< + { readonly interaction: T; readonly index: number }, + CassetteNotFoundError | InvalidCassetteError | E + > +} +export interface ReplayPoolState { + readonly claim: ( + select: (interactions: ReadonlyArray, used: HashSet.HashSet) => Effect.Effect, + ) => Effect.Effect< + { readonly interaction: T; readonly index: number }, + CassetteNotFoundError | InvalidCassetteError | E + > +} + +export const makeReplayPoolState = ( + cassette: Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) + const claimed = yield* SynchronizedRef.make(HashSet.empty()) + const attempted = yield* Ref.make(false) + yield* Effect.addFinalizer((exit) => + Exit.isFailure(exit) + ? Effect.void + : Effect.gen(function* () { + const used = yield* SynchronizedRef.get(claimed) + if (HashSet.isEmpty(used) && (yield* Ref.get(attempted))) return yield* Effect.void + const interactions = yield* load.pipe( + Effect.catchTag("CassetteNotFoundError", () => Effect.succeed([] as ReadonlyArray)), + Effect.orDie, + ) + if (HashSet.size(used) < interactions.length) + return yield* Effect.die( + new Error( + `Unused recorded interactions in ${name}: used ${HashSet.size(used)} of ${interactions.length}`, + ), + ) + return yield* Effect.void + }), + ) + return { + claim: (select) => + Ref.set(attempted, true).pipe( + Effect.andThen(load), + Effect.flatMap((interactions) => + SynchronizedRef.modifyEffect(claimed, (used) => + Effect.gen(function* () { + const index = yield* select(interactions, used) + const interaction = interactions[index] + if (interaction === undefined || HashSet.has(used, index)) + return yield* Effect.die("Replay selected an unavailable interaction") + return [{ interaction, index }, HashSet.add(used, index)] as const + }), + ), + ), + ), + } + }) + +export const makeReplayState = ( + cassette: Interface, + name: string, + project: (interactions: ReadonlyArray) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + makeReplayPoolState(cassette, name, project).pipe( + Effect.map((pool) => ({ + claim: (validate) => + pool.claim((interactions, used) => { + const index = HashSet.size(used) + return validate(interactions[index], index, interactions).pipe(Effect.as(index)) + }), + })), + ) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts deleted file mode 100644 index a9e67378043..00000000000 --- a/packages/http-recorder/src/schema.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Schema } from "effect" -import type { - CassetteMetadata, - HttpInteraction, - RequestSnapshot, - ResponseSnapshot, - WebSocketEvent, - WebSocketInteraction, -} from "./types.js" - -export type { - CassetteMetadata, - HttpInteraction, - RequestSnapshot, - ResponseSnapshot, - WebSocketEvent, - WebSocketInteraction, -} from "./types.js" - -export const RequestSnapshotSchema = Schema.Struct({ - method: Schema.String, - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, -}) - -export const ResponseSnapshotSchema = Schema.Struct({ - status: Schema.Number, - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.String, - bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), -}) - -export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown) - -export const HttpInteractionSchema = Schema.Struct({ - transport: Schema.tag("http"), - request: RequestSnapshotSchema, - response: ResponseSnapshotSchema, -}) - -export const WebSocketEventSchema = Schema.Union([ - Schema.Struct({ - direction: Schema.Literals(["client", "server"]), - kind: Schema.tag("text"), - body: Schema.String, - }), - Schema.Struct({ - direction: Schema.Literals(["client", "server"]), - kind: Schema.tag("binary"), - body: Schema.String, - bodyEncoding: Schema.Literal("base64"), - }), -]) - -export const WebSocketInteractionSchema = Schema.Struct({ - transport: Schema.tag("websocket"), - open: Schema.Struct({ - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String), - }), - events: Schema.Array(WebSocketEventSchema), -}) - -export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( - Schema.toTaggedUnion("transport"), -) -export type Interaction = Schema.Schema.Type - -export const isHttpInteraction = InteractionSchema.guards.http - -export const isWebSocketInteraction = InteractionSchema.guards.websocket - -export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) - -export const webSocketInteractions = (interactions: ReadonlyArray) => - interactions.filter(isWebSocketInteraction) - -export const CassetteSchema = Schema.Struct({ - version: Schema.Literal(1), - metadata: Schema.optional(CassetteMetadataSchema), - interactions: Schema.Array(InteractionSchema), -}) -export type Cassette = Schema.Schema.Type - -export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) -export const encodeCassette = Schema.encodeSync(CassetteSchema) diff --git a/packages/http-recorder/src/socket.ts b/packages/http-recorder/src/socket.ts deleted file mode 100644 index c7486db4aa2..00000000000 --- a/packages/http-recorder/src/socket.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { Deferred, Effect, Exit, FiberSet, Layer, Ref, Scope, Semaphore } from "effect" -import { Socket } from "effect/unstable/socket" -import * as CassetteService from "./cassette.js" -import { canonicalizeJson, decodeJson, safeText } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import { make, type Redactor } from "./redactor.js" -import { webSocketInteractions } from "./schema.js" -import type { - RecorderOptions, - WebSocketEvent, - WebSocketInteraction, - WebSocketRecorderOptions, - WebSocketRequest, -} from "./types.js" - -interface ActiveReplay { - readonly interaction: WebSocketInteraction - readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred }> - readonly writeLock: Semaphore.Semaphore - readonly closed: Ref.Ref -} - -interface ActiveRecording { - readonly events: Array - readonly eventLock: Semaphore.Semaphore - readonly accepting: Ref.Ref - opened: boolean - valid: boolean -} - -type Frame = string | Uint8Array - -const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent => - typeof message === "string" - ? { direction, kind: "text", body: message } - : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } - -const decodeEvent = (event: WebSocketEvent): Frame => - event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) - -const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => { - if (event.kind === "binary") return event - const body = - event.direction === "client" - ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body - : redactor.response({ status: 101, headers: {}, body: event.body }).body - return { ...event, body } -} - -const comparable = (event: WebSocketEvent, asJson: boolean) => { - if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event)) - const decoded = decodeJson(event.body) - return JSON.stringify( - canonicalizeJson({ - ...event, - body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value), - }), - ) -} - -const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => - Effect.sync(() => { - if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return - throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) - }) - -const runHandler = (handler: (value: A) => Effect.Effect | void, value: A) => - Effect.suspend(() => { - const result = handler(value) - return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void - }) - -const runReplay = ( - state: ActiveReplay, - handler: (value: A) => Effect.Effect | void, - decode: (event: WebSocketEvent) => A, - onOpen: Effect.Effect | undefined, -) => - Effect.scoped( - Effect.gen(function* () { - const handlers = yield* FiberSet.make() - const run = yield* FiberSet.runtime(handlers)() - if (onOpen) yield* onOpen - - const drive = Effect.gen(function* () { - while (true) { - const current = yield* Ref.get(state.progress) - const event = state.interaction.events[current.position] - if (!event) return - if (yield* Ref.get(state.closed)) - return yield* Effect.die( - new Error( - `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, - ), - ) - if (event.direction === "server") { - yield* Ref.set(state.progress, { - position: current.position + 1, - changed: yield* Deferred.make(), - }) - run(runHandler(handler, decode(event))) - continue - } - yield* Deferred.await(current.changed) - } - }) - - yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers))) - yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers))) - }), - ) - -const openSnapshot = (request: WebSocketRequest, redactor: Redactor) => { - const snapshot = redactor.request({ method: "GET", url: request.url, headers: request.headers ?? {}, body: "" }) - return { url: snapshot.url, headers: snapshot.headers } -} - -const makeRecordingSocket = ( - upstream: Socket.Socket, - cassette: CassetteService.Interface, - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - redactor: Redactor, -) => - Effect.gen(function* () { - const active = yield* Ref.make(undefined) - const writeLock = yield* Semaphore.make(1) - - return Socket.make({ - runRaw: (handler, runOptions) => - Effect.gen(function* () { - const state: ActiveRecording = { - events: [], - eventLock: yield* Semaphore.make(1), - accepting: yield* Ref.make(true), - opened: false, - valid: true, - } - const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) - if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported") - yield* upstream - .runRaw( - (message) => { - if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing") - state.events.push(redactEvent(encodeEvent("server", message), redactor)) - return handler(message) - }, - { - ...runOptions, - onOpen: Effect.gen(function* () { - state.opened = true - if (runOptions?.onOpen) yield* runOptions.onOpen - }), - }, - ) - .pipe( - Effect.onExit((exit) => - writeLock.withPermit( - state.eventLock.withPermit( - Effect.gen(function* () { - yield* Ref.set(state.accepting, false) - yield* Ref.set(active, undefined) - if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return - yield* cassette - .append( - name, - { - transport: "websocket", - open: openSnapshot(request, redactor), - events: [...state.events], - }, - options.metadata, - ) - .pipe(Effect.orDie) - }), - ), - ), - ), - ) - }), - writer: upstream.writer.pipe( - Effect.map( - (write) => (message) => - writeLock.withPermit( - Effect.gen(function* () { - if (Socket.isCloseEvent(message)) return yield* write(message) - const state = yield* Ref.get(active) - if (!state || !(yield* Ref.get(state.accepting))) - return yield* Effect.die("WebSocket writer used without an active socket run") - const event = redactEvent(encodeEvent("client", message), redactor) - yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event))) - return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false)))) - }), - ), - ), - ), - }) - }) - -const makeReplaySocket = ( - cassette: CassetteService.Interface, - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - redactor: Redactor, -): Effect.Effect => - Effect.gen(function* () { - const replay = yield* makeReplayState(cassette, name, webSocketInteractions) - const active = yield* Ref.make(undefined) - - return Socket.make({ - runRaw: (handler, runOptions) => - Effect.gen(function* () { - const claimed = yield* replay - .claim((interaction, index) => - Effect.sync(() => { - const incoming = openSnapshot(request, redactor) - if ( - interaction && - JSON.stringify(canonicalizeJson(incoming)) === JSON.stringify(canonicalizeJson(interaction.open)) - ) - return - throw new Error( - `WebSocket open ${index + 1}: expected ${safeText(interaction?.open)}, received ${safeText(incoming)}`, - ) - }), - ) - .pipe(Effect.orDie) - const progress = yield* Ref.make({ position: 0, changed: yield* Deferred.make() }) - const writeLock = yield* Semaphore.make(1) - const state = { - interaction: claimed.interaction, - progress, - writeLock, - closed: yield* Ref.make(false), - } - const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) - if (occupied) return yield* Effect.die("Concurrent runs of a replayed WebSocket are not supported") - yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe( - Effect.ensuring(Ref.set(active, undefined)), - ) - }), - writer: Effect.succeed((message) => { - return Ref.get(active).pipe( - Effect.flatMap((state) => - state - ? state.writeLock.withPermit( - Effect.gen(function* () { - const current = yield* Ref.get(state.progress) - if (Socket.isCloseEvent(message)) { - yield* Ref.set(state.closed, true) - yield* Deferred.succeed(current.changed, undefined) - if (current.position === state.interaction.events.length) return - return yield* Effect.die( - new Error( - `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, - ), - ) - } - const actual = redactEvent(encodeEvent("client", message), redactor) - yield* assertEvent( - actual, - state.interaction.events[current.position], - current.position, - options.compareClientMessagesAsJson === true, - ) - yield* Ref.set(state.progress, { - position: current.position + 1, - changed: yield* Deferred.make(), - }) - yield* Deferred.succeed(current.changed, undefined) - }), - ) - : Effect.die("WebSocket writer used without an active socket run"), - ), - ) - }), - }) - }) - -const recordingLayer = ( - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions, - forcedMode?: "record" | "replay", -): Layer.Layer => - Layer.effect( - Socket.Socket, - Effect.gen(function* () { - const upstream = yield* Socket.Socket - const cassette = yield* CassetteService.Service - const redactor = make(options.redact) - if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record") - return yield* makeRecordingSocket(upstream, cassette, name, request, options, redactor) - return yield* makeReplaySocket(cassette, name, request, options, redactor) - }), - ) - -/** - * Wraps a provided `Socket.Socket` with cassette recording and replay. - * - * Supply the ordinary URL-bound Effect socket layer beneath this decorator. - * The cassette name identifies the connection; recorder configuration does not - * duplicate the transport URL. - */ -export const socket = (name: string, options: RecorderOptions = {}): Layer.Layer => - provideCassette(recordingLayer(name, { url: "" }, { ...options, compareClientMessagesAsJson: true }), options) - -/** @internal */ -export const socketLayer = ( - name: string, - request: WebSocketRequest, - options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, -): Layer.Layer => - provideCassette(recordingLayer(name, request, options, options.mode), options) - -const provideCassette = ( - layer: Layer.Layer, - options: WebSocketRecorderOptions, -) => - layer.pipe( - Layer.provide(CassetteService.fileSystem({ directory: options.directory })), - Layer.provide(NodeFileSystem.layer), - ) diff --git a/packages/http-recorder/src/types.ts b/packages/http-recorder/src/types.ts deleted file mode 100644 index 1ee01142058..00000000000 --- a/packages/http-recorder/src/types.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** Additional JSON metadata stored with a cassette. */ -export type CassetteMetadata = Record - -/** The normalized HTTP request representation used for matching. */ -export interface RequestSnapshot { - /** HTTP method. */ - readonly method: string - /** Fully qualified URL after redaction. */ - readonly url: string - /** Allowed and redacted request headers. */ - readonly headers: Record - /** Request body after redaction. */ - readonly body: string -} - -/** @internal */ -export interface ResponseSnapshot { - /** HTTP status code. */ - readonly status: number - /** Allowed and redacted response headers. */ - readonly headers: Record - /** Text body or base64-encoded binary body. */ - readonly body: string - /** Encoding used by `body`; omitted for ordinary text. */ - readonly bodyEncoding?: "text" | "base64" -} - -/** @internal */ -export interface HttpInteraction { - readonly transport: "http" - readonly request: RequestSnapshot - readonly response: ResponseSnapshot -} - -/** @internal */ -export type WebSocketEvent = - | { readonly direction: "client" | "server"; readonly kind: "text"; readonly body: string } - | { - readonly direction: "client" | "server" - readonly kind: "binary" - readonly body: string - readonly bodyEncoding: "base64" - } - -/** @internal */ -export interface WebSocketInteraction { - readonly transport: "websocket" - readonly open: { - readonly url: string - readonly headers: Record - } - readonly events: ReadonlyArray -} - -/** Returns whether an incoming HTTP request matches a recorded request. */ -export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean - -/** Additive redaction and header-preservation policy. */ -export interface RedactOptions { - /** Additional sensitive headers to retain as `[REDACTED]`. */ - readonly headers?: ReadonlyArray - /** Additional non-sensitive request headers to preserve for matching. */ - readonly allowRequestHeaders?: ReadonlyArray - /** Additional non-sensitive response headers to preserve for replay. */ - readonly allowResponseHeaders?: ReadonlyArray - /** Additional sensitive URL query parameter names. */ - readonly queryParameters?: ReadonlyArray - /** Additional JSON field names to redact recursively. */ - readonly jsonFields?: ReadonlyArray - /** Stabilizes a URL after built-in redaction. */ - readonly url?: (url: string) => string - /** Stabilizes a request, response, or text-frame body after built-in redaction. */ - readonly body?: (body: string) => string -} - -/** Options shared by HTTP recorder layers. */ -export interface RecorderOptions { - /** Cassette directory. Defaults to `/test/fixtures/recordings`. */ - readonly directory?: string - /** Additional metadata stored in the cassette. */ - readonly metadata?: CassetteMetadata - /** Additive redaction and header-preservation policy. */ - readonly redact?: RedactOptions - /** Custom HTTP request equivalence. */ - readonly match?: RequestMatcher -} - -/** @internal */ -export interface WebSocketRequest { - /** WebSocket URL. */ - readonly url: string - /** Headers used for redacted matching; the recorder does not send them. */ - readonly headers?: Record -} - -/** @internal */ -export interface WebSocketRecorderOptions { - /** Cassette directory. Defaults to `/test/fixtures/recordings`. */ - readonly directory?: string - /** Additional metadata stored in the cassette. */ - readonly metadata?: CassetteMetadata - /** Additive handshake and text-frame redaction policy. */ - readonly redact?: RedactOptions - /** Compare text client frames as canonical JSON instead of exact strings. */ - readonly compareClientMessagesAsJson?: boolean - /** WebSocket subprotocols used by `layerWebSocket`. */ - readonly protocols?: string | Array -} diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts deleted file mode 100644 index 869cd3bf90e..00000000000 --- a/packages/http-recorder/src/websocket.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Effect, Option, Ref, Scope, Semaphore, Stream, SynchronizedRef } from "effect" -import type { Headers } from "effect/unstable/http" -import * as CassetteService from "./cassette.js" -import { canonicalizeJson, decodeJson, safeText } from "./matching.js" -import { makeReplayState, resolveAutoMode } from "./recorder.js" -import type { RecordReplayMode } from "./internal-effect.js" -import { make, type Redactor } from "./redactor.js" -import { webSocketInteractions, type CassetteMetadata, type WebSocketEvent } from "./schema.js" - -export interface WebSocketRequest { - readonly url: string - readonly headers: Headers.Headers -} - -export interface WebSocketConnection { - readonly sendText: (message: string) => Effect.Effect - readonly messages: Stream.Stream - readonly close: Effect.Effect -} - -export interface WebSocketExecutor { - readonly open: (request: WebSocketRequest) => Effect.Effect, E> -} - -export interface WebSocketRecordReplayOptions { - readonly name: string - readonly mode?: RecordReplayMode - readonly metadata?: CassetteMetadata - readonly cassette: CassetteService.Interface - readonly live: WebSocketExecutor - readonly redactor?: Redactor - readonly compareClientMessagesAsJson?: boolean -} - -const headersRecord = (headers: Headers.Headers): Record => - Object.fromEntries( - Object.entries(headers as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ) - -const textEvent = (direction: "client" | "server", body: string): WebSocketEvent => ({ - direction, - kind: "text", - body, -}) - -const decodeEvent = (event: WebSocketEvent) => - event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) - -const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) - -const assertClientEvent = (actual: string, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => - Effect.sync(() => { - const matches = - expected?.direction === "client" && - expected.kind === "text" && - JSON.stringify(asJson ? jsonOrText(actual) : actual) === - JSON.stringify(asJson ? jsonOrText(expected.body) : expected.body) - if (matches) return - throw new Error(`WebSocket client frame ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) - }) - -export const makeWebSocketExecutor = ( - options: WebSocketRecordReplayOptions, -): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - const mode = options.mode ?? (yield* resolveAutoMode(options.cassette, options.name)) - const redactor = options.redactor ?? make() - const openSnapshot = (request: WebSocketRequest) => { - const snapshot = redactor.request({ - method: "GET", - url: request.url, - headers: headersRecord(request.headers), - body: "", - }) - return { url: snapshot.url, headers: snapshot.headers } - } - const redactEvent = (event: WebSocketEvent) => { - if (event.kind === "binary") return event - const body = - event.direction === "client" - ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body - : redactor.response({ status: 101, headers: {}, body: event.body }).body - return { ...event, body } - } - - if (mode === "passthrough") return options.live - - if (mode === "record") { - return { - open: (request) => - Effect.gen(function* () { - const events: WebSocketEvent[] = [] - const connection = yield* options.live.open(request) - const closed = yield* Ref.make(false) - const closeLock = yield* Semaphore.make(1) - return { - sendText: (message) => - Effect.sync(() => events.push(redactEvent(textEvent("client", message)))).pipe( - Effect.andThen(connection.sendText(message)), - ), - messages: connection.messages.pipe( - Stream.tap((message) => - Effect.sync(() => - events.push( - typeof message === "string" - ? redactEvent(textEvent("server", message)) - : { - direction: "server", - kind: "binary", - body: Buffer.from(message).toString("base64"), - bodyEncoding: "base64", - }, - ), - ), - ), - ), - close: closeLock.withPermit( - Effect.gen(function* () { - if (yield* Ref.get(closed)) return - yield* connection.close - yield* options.cassette - .append( - options.name, - { transport: "websocket", open: openSnapshot(request), events }, - options.metadata, - ) - .pipe(Effect.orDie) - yield* Ref.set(closed, true) - }), - ), - } - }), - } - } - - const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) - return { - open: (request) => - Effect.gen(function* () { - const claimed = yield* replay - .claim((interaction, index) => - Effect.sync(() => { - const incoming = canonicalizeJson(openSnapshot(request)) - if (interaction && JSON.stringify(incoming) === JSON.stringify(canonicalizeJson(interaction.open))) - return - throw new Error(`WebSocket open ${index + 1} does not match ${safeText(incoming)}`) - }), - ) - .pipe(Effect.orDie) - const client = claimed.interaction.events.filter((event) => event.direction === "client") - const server = claimed.interaction.events.filter((event) => event.direction === "server") - const position = yield* SynchronizedRef.make(0) - return { - sendText: (message) => - SynchronizedRef.updateEffect(position, (index) => - assertClientEvent(message, client[index], index, options.compareClientMessagesAsJson === true).pipe( - Effect.as(index + 1), - ), - ), - messages: Stream.fromIterable(server).pipe(Stream.map(decodeEvent)), - close: Effect.gen(function* () { - const used = yield* SynchronizedRef.get(position) - if (used !== client.length) - return yield* Effect.die( - new Error(`WebSocket client frame count: expected ${client.length}, received ${used}`), - ) - }), - } - }), - } - }) diff --git a/packages/http-recorder/src/websocket/model.ts b/packages/http-recorder/src/websocket/model.ts new file mode 100644 index 00000000000..b5930141bb0 --- /dev/null +++ b/packages/http-recorder/src/websocket/model.ts @@ -0,0 +1,35 @@ +import { Schema } from "effect" + +export const WebSocketEventSchema = Schema.Union([ + Schema.Struct({ + direction: Schema.Literals(["client", "server"]), + kind: Schema.tag("text"), + body: Schema.String, + }), + Schema.Struct({ + direction: Schema.Literals(["client", "server"]), + kind: Schema.tag("binary"), + body: Schema.String, + bodyEncoding: Schema.Literal("base64"), + }), +]) + +export type WebSocketEvent = Schema.Schema.Type + +export const WebSocketInteractionSchema = Schema.Struct({ + transport: Schema.tag("websocket"), + connection: Schema.optional( + Schema.Struct({ + sequence: Schema.Number, + url: Schema.String, + protocols: Schema.Array(Schema.String), + close: Schema.Struct({ + code: Schema.Number, + reason: Schema.String, + }), + }), + ), + events: Schema.Array(WebSocketEventSchema), +}) + +export interface WebSocketInteraction extends Schema.Schema.Type {} diff --git a/packages/http-recorder/src/websocket/recorder.ts b/packages/http-recorder/src/websocket/recorder.ts new file mode 100644 index 00000000000..3d232c4db95 --- /dev/null +++ b/packages/http-recorder/src/websocket/recorder.ts @@ -0,0 +1,584 @@ +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Deferred, Effect, Exit, FiberSet, Layer, Option, Ref, Scope, Semaphore } from "effect" +import { Socket } from "effect/unstable/socket" +import { fileSystem, type Interface, Service } from "../cassette/store.js" +import type { SocketRecorderOptions } from "../options.js" +import { make, type Redactor } from "../redaction/redactor.js" +import { canonicalizeJson, decodeJson, safeText } from "../replay/comparison.js" +import { makeReplayState, resolveAutoMode } from "../replay/state.js" +import { webSocketInteractions, type Interaction } from "../cassette/model.js" +import type { WebSocketEvent, WebSocketInteraction } from "./model.js" + +interface WebSocketRecorderOptions extends SocketRecorderOptions { + readonly compareClientMessagesAsJson?: boolean +} +interface ActiveReplay { + readonly interaction: WebSocketInteraction + readonly progress: Ref.Ref<{ readonly position: number; readonly changed: Deferred.Deferred }> + readonly writeLock: Semaphore.Semaphore + readonly closed: Ref.Ref +} +interface ActiveRecording { + readonly events: Array + readonly eventLock: Semaphore.Semaphore + readonly accepting: Ref.Ref + opened: boolean + valid: boolean +} +interface PendingRecordings { + readonly promises: Set> + readonly errors: Array +} +type Frame = string | Uint8Array + +const normalizeProtocols = (protocols?: string | Array): Array => + protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : [...protocols] +const frameFromWebSocketData = async (data: unknown): Promise => { + if (typeof data === "string") return data + if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer()) + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice() + throw new Error(`Unsupported WebSocket frame: ${Object.prototype.toString.call(data)}`) +} +const closeEvent = (code: number, reason: string): CloseEvent => { + if (typeof globalThis.CloseEvent === "function") + return new globalThis.CloseEvent("close", { code, reason, wasClean: code === 1000 }) + const event = new Event("close") + Object.defineProperties(event, { + code: { value: code }, + reason: { value: reason }, + wasClean: { value: code === 1000 }, + }) + return event as CloseEvent +} +const errorEvent = (error: unknown): ErrorEvent => { + if (typeof globalThis.ErrorEvent === "function") + return new globalThis.ErrorEvent("error", { + error, + message: error instanceof Error ? error.message : String(error), + }) + const event = new Event("error") + Object.defineProperties(event, { + error: { value: error }, + message: { value: error instanceof Error ? error.message : String(error) }, + }) + return event as ErrorEvent +} +const webSocketFacade = ( + target: EventTarget, + properties: { + readonly url: () => string + readonly readyState: () => number + readonly protocol: () => string + readonly extensions: () => string + readonly bufferedAmount: () => number + readonly send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void + readonly close: (code?: number, reason?: string) => void + }, +): globalThis.WebSocket => { + Object.defineProperties(target, { + url: { get: properties.url }, + readyState: { get: properties.readyState }, + protocol: { get: properties.protocol }, + extensions: { get: properties.extensions }, + bufferedAmount: { get: properties.bufferedAmount }, + binaryType: { value: "blob", writable: true }, + send: { value: properties.send }, + close: { value: properties.close }, + CONNECTING: { value: 0 }, + OPEN: { value: 1 }, + CLOSING: { value: 2 }, + CLOSED: { value: 3 }, + }) + for (const name of ["open", "message", "error", "close"] as const) { + let handler: ((event: Event) => unknown) | null = null + Object.defineProperty(target, `on${name}`, { + get: () => handler, + set: (next) => { + if (handler) target.removeEventListener(name, handler) + handler = typeof next === "function" ? next : null + if (handler) target.addEventListener(name, handler) + }, + }) + } + return target as globalThis.WebSocket +} + +const encodeEvent = (direction: "client" | "server", message: Frame): WebSocketEvent => + typeof message === "string" + ? { direction, kind: "text", body: message } + : { direction, kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } +const decodeEvent = (event: WebSocketEvent): Frame => + event.kind === "text" ? event.body : new Uint8Array(Buffer.from(event.body, "base64")) +const redactEvent = (event: WebSocketEvent, redactor: Redactor): WebSocketEvent => { + if (event.kind === "binary") return event + const body = + event.direction === "client" + ? redactor.request({ method: "WEBSOCKET", url: "", headers: {}, body: event.body }).body + : redactor.response({ status: 101, headers: {}, body: event.body }).body + return { ...event, body } +} +const comparable = (event: WebSocketEvent, asJson: boolean) => { + if (!asJson || event.kind === "binary") return JSON.stringify(canonicalizeJson(event)) + const decoded = decodeJson(event.body) + return JSON.stringify( + canonicalizeJson({ ...event, body: decoded._tag === "None" ? event.body : canonicalizeJson(decoded.value) }), + ) +} +const assertEvent = (actual: WebSocketEvent, expected: WebSocketEvent | undefined, index: number, asJson: boolean) => + Effect.sync(() => { + if (expected && comparable(actual, asJson) === comparable(expected, asJson)) return + throw new Error(`WebSocket event ${index + 1}: expected ${safeText(expected)}, received ${safeText(actual)}`) + }) +const runHandler = (handler: (value: A) => Effect.Effect | void, value: A) => + Effect.suspend(() => { + const result = handler(value) + return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void + }) +const runReplay = ( + state: ActiveReplay, + handler: (value: A) => Effect.Effect | void, + decode: (event: WebSocketEvent) => A, + onOpen: Effect.Effect | undefined, +) => + Effect.scoped( + Effect.gen(function* () { + const handlers = yield* FiberSet.make() + const run = yield* FiberSet.runtime(handlers)() + if (onOpen) yield* onOpen + const drive = Effect.gen(function* () { + while (true) { + const current = yield* Ref.get(state.progress) + const event = state.interaction.events[current.position] + if (!event) return + if (yield* Ref.get(state.closed)) + return yield* Effect.die( + new Error( + `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + ), + ) + if (event.direction === "server") { + yield* Ref.set(state.progress, { position: current.position + 1, changed: yield* Deferred.make() }) + run(runHandler(handler, decode(event))) + continue + } + yield* Deferred.await(current.changed) + } + }) + yield* drive.pipe(Effect.raceFirst(FiberSet.join(handlers))) + yield* FiberSet.awaitEmpty(handlers).pipe(Effect.raceFirst(FiberSet.join(handlers))) + }), + ) + +const makeRecordingSocket = ( + upstream: Socket.Socket, + cassette: Interface, + name: string, + options: WebSocketRecorderOptions, + redactor: Redactor, +) => + Effect.gen(function* () { + const active = yield* Ref.make(undefined) + const writeLock = yield* Semaphore.make(1) + return Socket.make({ + runRaw: (handler, runOptions) => + Effect.gen(function* () { + const state: ActiveRecording = { + events: [], + eventLock: yield* Semaphore.make(1), + accepting: yield* Ref.make(true), + opened: false, + valid: true, + } + const occupied = yield* Ref.modify(active, (current) => [current !== undefined, current ?? state]) + if (occupied) return yield* Effect.die("Concurrent runs of a recorded WebSocket are not supported") + yield* upstream + .runRaw( + (message) => { + if (!Ref.getUnsafe(state.accepting)) throw new Error("WebSocket received a frame after closing") + state.events.push(redactEvent(encodeEvent("server", message), redactor)) + return handler(message) + }, + { + ...runOptions, + onOpen: Effect.gen(function* () { + state.opened = true + if (runOptions?.onOpen) yield* runOptions.onOpen + }), + }, + ) + .pipe( + Effect.onExit((exit) => + writeLock.withPermit( + state.eventLock.withPermit( + Effect.gen(function* () { + yield* Ref.set(state.accepting, false) + yield* Ref.set(active, undefined) + if (!Exit.isSuccess(exit) || !state.opened || !state.valid) return + yield* cassette + .append( + name, + { + transport: "websocket", + events: [...state.events], + }, + options.metadata, + ) + .pipe(Effect.orDie) + }), + ), + ), + ), + ) + }), + writer: upstream.writer.pipe( + Effect.map( + (write) => (message) => + writeLock.withPermit( + Effect.gen(function* () { + if (Socket.isCloseEvent(message)) return yield* write(message) + const state = yield* Ref.get(active) + if (!state || !(yield* Ref.get(state.accepting))) + return yield* Effect.die("WebSocket writer used without an active socket run") + const event = redactEvent(encodeEvent("client", message), redactor) + yield* state.eventLock.withPermit(Effect.sync(() => state.events.push(event))) + return yield* write(message).pipe(Effect.onError(() => Effect.sync(() => (state.valid = false)))) + }), + ), + ), + ), + }) + }) + +const makeReplaySocket = ( + cassette: Interface, + name: string, + options: WebSocketRecorderOptions, + redactor: Redactor, +): Effect.Effect => + Effect.gen(function* () { + const replay = yield* makeReplayState(cassette, name, webSocketInteractions) + const active = yield* Ref.make(undefined) + const runLock = yield* Semaphore.make(1) + return Socket.make({ + runRaw: (handler, runOptions) => + runLock + .withPermitsIfAvailable(1)( + Effect.gen(function* () { + const claimed = yield* replay + .claim((interaction) => + interaction ? Effect.void : Effect.die("Missing recorded WebSocket interaction"), + ) + .pipe(Effect.orDie) + const state = { + interaction: claimed.interaction, + progress: yield* Ref.make({ position: 0, changed: yield* Deferred.make() }), + writeLock: yield* Semaphore.make(1), + closed: yield* Ref.make(false), + } + yield* Ref.set(active, state) + yield* runReplay(state, handler, decodeEvent, runOptions?.onOpen).pipe( + Effect.ensuring(Ref.set(active, undefined)), + ) + }), + ) + .pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("Concurrent runs of a replayed WebSocket are not supported"), + onSome: () => Effect.void, + }), + ), + ), + writer: Effect.succeed((message) => + Ref.get(active).pipe( + Effect.flatMap((state) => + state + ? state.writeLock.withPermit( + Effect.gen(function* () { + const current = yield* Ref.get(state.progress) + if (Socket.isCloseEvent(message)) { + yield* Ref.set(state.closed, true) + yield* Deferred.succeed(current.changed, undefined) + if (current.position === state.interaction.events.length) return + return yield* Effect.die( + new Error( + `WebSocket closed with unconsumed events: used ${current.position} of ${state.interaction.events.length}`, + ), + ) + } + const actual = redactEvent(encodeEvent("client", message), redactor) + yield* assertEvent( + actual, + state.interaction.events[current.position], + current.position, + options.compareClientMessagesAsJson === true, + ) + yield* Ref.set(state.progress, { + position: current.position + 1, + changed: yield* Deferred.make(), + }) + yield* Deferred.succeed(current.changed, undefined) + }), + ) + : Effect.die("WebSocket writer used without an active socket run"), + ), + ), + ), + }) + }) + +const recordingLayer = ( + name: string, + options: WebSocketRecorderOptions, + forcedMode?: "record" | "replay", +): Layer.Layer => + Layer.effect( + Socket.Socket, + Effect.gen(function* () { + const upstream = yield* Socket.Socket + const cassette = yield* Service + const redactor = make(options.redact) + if ((forcedMode ?? (yield* resolveAutoMode(cassette, name))) === "record") + return yield* makeRecordingSocket(upstream, cassette, name, options, redactor) + return yield* makeReplaySocket(cassette, name, options, redactor) + }), + ) + +export const layerSocket = ( + name: string, + options: SocketRecorderOptions = {}, +): Layer.Layer => + provideCassette(recordingLayer(name, { ...options, compareClientMessagesAsJson: true }), options) +/** @internal */ +export const layerSocketWithMode = ( + name: string, + options: WebSocketRecorderOptions & { readonly mode: "record" | "replay" }, +): Layer.Layer => + provideCassette(recordingLayer(name, options, options.mode), options) +const provideCassette = (layer: Layer.Layer, options: WebSocketRecorderOptions) => + layer.pipe(Layer.provide(fileSystem({ directory: options.directory })), Layer.provide(NodeFileSystem.layer)) + +const makeRecordingWebSocketConstructor = ( + upstream: Socket.WebSocketConstructor["Service"], + cassette: Interface, + name: string, + metadata: SocketRecorderOptions["metadata"], + redactor: Redactor, + pending: PendingRecordings, +): Socket.WebSocketConstructor["Service"] => { + let nextSequence = 0 + return (url, protocols) => { + const sequence = nextSequence++ + const requestedProtocols = normalizeProtocols(protocols) + const native = upstream(url, requestedProtocols) + const events: WebSocketEvent[] = [] + let opened = false + let failed = false + let closed = false + let queue = Promise.resolve() + const appendEvent = (direction: "client" | "server", data: unknown) => { + queue = queue.then(async () => { + if (failed || closed) return + try { + events.push(redactEvent(encodeEvent(direction, await frameFromWebSocketData(data)), redactor)) + } catch { + failed = true + } + }) + } + const onOpen = () => { + opened = true + } + const onMessage = (event: MessageEvent) => { + appendEvent("server", event.data) + } + const onError = () => { + failed = true + } + const onClose = (event: CloseEvent) => { + native.removeEventListener("open", onOpen) + native.removeEventListener("message", onMessage) + native.removeEventListener("error", onError) + native.removeEventListener("close", onClose) + const completion = queue.then(async () => { + closed = true + if (opened && !failed) { + const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" }) + const interaction: WebSocketInteraction = { + transport: "websocket", + connection: { + sequence, + url: request.url, + protocols: requestedProtocols, + close: { code: event.code, reason: event.reason }, + }, + events: [...events], + } + events.length = 0 + await Effect.runPromise(cassette.append(name, interaction, metadata).pipe(Effect.orDie)) + } + }) + pending.promises.add(completion) + void completion.then( + () => pending.promises.delete(completion), + (error) => { + pending.promises.delete(completion) + pending.errors.push(error) + }, + ) + } + native.addEventListener("open", onOpen) + native.addEventListener("message", onMessage) + native.addEventListener("error", onError) + native.addEventListener("close", onClose) + return new Proxy(native, { + get: (target, property) => { + if (property === "send") + return (data: string | ArrayBufferLike | Blob | ArrayBufferView) => { + Reflect.apply(target.send, target, [data]) + appendEvent("client", data) + } + const value: unknown = Reflect.get(target, property, target) + return typeof value === "function" ? value.bind(target) : value + }, + set: (target, property, value) => Reflect.set(target, property, value, target), + }) + } +} + +const constructorWebSocketInteractions = (interactions: ReadonlyArray) => + webSocketInteractions(interactions) + .filter((interaction) => interaction.connection !== undefined) + .map((interaction, index) => ({ interaction, index })) + .toSorted((a, b) => a.interaction.connection!.sequence - b.interaction.connection!.sequence) + .map(({ interaction }) => interaction) + +const makeReplayWebSocketConstructor = ( + cassette: Interface, + name: string, + redactor: Redactor, +): Effect.Effect => + Effect.gen(function* () { + const replay = yield* makeReplayState(cassette, name, constructorWebSocketInteractions) + return (url, protocols) => { + const target = new EventTarget() + const requestedProtocols = normalizeProtocols(protocols) + const request = redactor.request({ method: "WEBSOCKET", url, headers: {}, body: "" }) + let readyState = 0 + let interaction: WebSocketInteraction | undefined + let position = 0 + let finished = false + let closeRequested = false + let operations = Promise.resolve() + const fail = (error: unknown) => { + if (finished) return + finished = true + readyState = 3 + target.dispatchEvent(errorEvent(error)) + } + const finish = () => { + if (finished || !interaction || position !== interaction.events.length) return + finished = true + readyState = 3 + const terminal = interaction.connection?.close ?? { code: 1000, reason: "" } + target.dispatchEvent(closeEvent(terminal.code, terminal.reason)) + } + const drive = () => { + if (!interaction || finished) return + while (interaction.events[position]?.direction === "server") { + const event = interaction.events[position++] + if (!event) break + target.dispatchEvent(new MessageEvent("message", { data: decodeEvent(event) })) + } + if (position === interaction.events.length) setTimeout(finish, 0) + } + Effect.runPromise( + replay + .claim((recorded, index) => + Effect.sync(() => { + if (!recorded) throw new Error(`Missing recorded WebSocket connection ${index + 1}`) + const connection = recorded.connection + if (!connection) throw new Error(`WebSocket interaction ${index + 1} has no connection metadata`) + if (connection.url !== request.url) + throw new Error( + `WebSocket connection ${index + 1}: expected URL ${safeText(connection.url)}, received ${safeText(request.url)}`, + ) + if ( + connection.protocols.length !== requestedProtocols.length || + connection.protocols.some((protocol, protocolIndex) => protocol !== requestedProtocols[protocolIndex]) + ) + throw new Error( + `WebSocket connection ${index + 1}: expected protocols ${safeText(connection.protocols)}, received ${safeText(requestedProtocols)}`, + ) + }), + ) + .pipe(Effect.orDie), + ).then((claimed) => { + if (closeRequested) return fail(new Error("WebSocket closed before it opened")) + interaction = claimed.interaction + readyState = 1 + target.dispatchEvent(new Event("open")) + drive() + }, fail) + return webSocketFacade(target, { + url: () => url, + readyState: () => readyState, + protocol: () => requestedProtocols[0] ?? "", + extensions: () => "", + bufferedAmount: () => 0, + send: (data) => { + if (!interaction || readyState !== 1 || closeRequested) throw new Error("WebSocket is not open") + operations = operations.then(async () => { + try { + const frame = await frameFromWebSocketData(data) + const actual = redactEvent(encodeEvent("client", frame), redactor) + Effect.runSync(assertEvent(actual, interaction?.events[position], position, true)) + position += 1 + drive() + } catch (error) { + fail(error) + } + }) + }, + close: () => { + if (closeRequested || readyState === 3) return + closeRequested = true + readyState = 2 + operations = operations.then(() => { + if (!interaction) return + if (position !== interaction.events.length) + return fail( + new Error(`WebSocket closed with unconsumed events: used ${position} of ${interaction.events.length}`), + ) + finish() + }) + }, + }) + } + }) + +export const layerWebSocketConstructor = ( + name: string, + options: SocketRecorderOptions = {}, +): Layer.Layer => + provideCassette( + Layer.effect( + Socket.WebSocketConstructor, + Effect.gen(function* () { + const upstream = yield* Socket.WebSocketConstructor + const cassette = yield* Service + const redactor = make(options.redact) + if ((yield* resolveAutoMode(cassette, name)) === "replay") + return yield* makeReplayWebSocketConstructor(cassette, name, redactor) + const pending: PendingRecordings = { promises: new Set(), errors: [] } + yield* Effect.addFinalizer(() => + Effect.promise(() => Promise.all(pending.promises)).pipe( + Effect.flatMap(() => (pending.errors.length === 0 ? Effect.void : Effect.die(pending.errors[0]))), + ), + ) + return makeRecordingWebSocketConstructor(upstream, cassette, name, options.metadata, redactor, pending) + }), + ), + options, + ) diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts index 64441936d7a..f25b9714550 100644 --- a/packages/http-recorder/sst-env.d.ts +++ b/packages/http-recorder/sst-env.d.ts @@ -7,4 +7,4 @@ /// import "sst" -export {} \ No newline at end of file +export {} diff --git a/packages/http-recorder/test/cassette.test.ts b/packages/http-recorder/test/cassette.test.ts new file mode 100644 index 00000000000..d15e7628dea --- /dev/null +++ b/packages/http-recorder/test/cassette.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { existsSync, readdirSync, writeFileSync } from "node:fs" +import type { Interaction } from "../src/cassette/model" +import { HttpRecorder } from "../src" +import { Service, hasCassetteSync, memory } from "../src/cassette/store" +import { cassetteLayer } from "../src/http/recorder" +import { failureText, post, readCassette, runFileCassette, seedCassetteDirectory, tempDirectory } from "./support" + +describe("cassette", () => { + test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => { + using server = Bun.serve({ + port: 0, + fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234"), + }) + const url = `http://127.0.0.1:${server.port}/leaky` + using directory = tempDirectory("http-recorder-unsafe-") + + const exit = await Effect.runPromise( + Effect.exit( + post(url, { ok: true }).pipe( + Effect.provide( + cassetteLayer("unsafe-record", { + directory: directory.path, + mode: "record", + }), + ), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("contains possible secrets") + expect(existsSync(`${directory.path}/unsafe-record.json`)).toBe(false) + }) + + test("failed memory appends leave cassette state unchanged", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* Service + const interaction: Interaction = { + transport: "http", + request: { + method: "GET", + url: "https://example.test", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "safe" }, + } + yield* cassette.append("transactional", interaction) + yield* cassette + .append("transactional", { + ...interaction, + response: { + ...interaction.response, + body: "Bearer abcdefghijklmnopqrstuvwxyz1234", + }, + }) + .pipe(Effect.flip) + + expect(yield* cassette.read("transactional")).toEqual([interaction]) + }).pipe(Effect.provide(memory())), + ) + }) + + test("concurrent file appends preserve every interaction", async () => { + using directory = tempDirectory("http-recorder-concurrent-") + await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + yield* Effect.forEach( + Array.from({ length: 20 }, (_, index) => index), + (index) => + cassette.append("concurrent", { + transport: "http", + request: { + method: "GET", + url: `https://example.test/${index}`, + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: String(index) }, + }), + { concurrency: "unbounded" }, + ) + }), + ) + + const cassette = readCassette(`${directory.path}/concurrent.json`) + expect(cassette.interactions).toHaveLength(20) + expect(readdirSync(directory.path).filter((file) => file.endsWith(".tmp"))).toEqual([]) + }) + + test("generated metadata cannot be overridden", async () => { + using directory = tempDirectory("http-recorder-metadata-") + await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + yield* cassette.append( + "metadata", + { + transport: "http", + request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, + response: { status: 200, headers: {}, body: "safe" }, + }, + { name: "wrong", recordedAt: "wrong" }, + ) + }), + ) + + const cassette = readCassette(`${directory.path}/metadata.json`) + expect(cassette.metadata?.name).toBe("metadata") + expect(cassette.metadata?.recordedAt).not.toBe("wrong") + }) + + test("reports malformed cassettes as invalid", async () => { + using directory = tempDirectory("http-recorder-invalid-") + writeFileSync(`${directory.path}/invalid.json`, "{not-json") + + const error = await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + return yield* cassette.read("invalid").pipe(Effect.flip) + }), + ) + + expect(error._tag).toBe("InvalidCassetteError") + }) + + test("rejects cassette paths outside the recordings directory", () => { + using directory = tempDirectory("http-recorder-path-") + expect(() => hasCassetteSync("../outside", { directory: directory.path })).toThrow("Invalid cassette name") + expect(() => hasCassetteSync("C:\\outside", { directory: directory.path })).toThrow("Invalid cassette name") + }) + + test("public cassette lifecycle helpers check and remove a recording", async () => { + using directory = tempDirectory("http-recorder-lifecycle-") + const options = { directory: directory.path } + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false) + + await seedCassetteDirectory(directory.path, "nested/example", [ + { + transport: "http", + request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, + response: { status: 200, headers: {}, body: "safe" }, + }, + ]) + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(true) + + HttpRecorder.removeCassetteSync("nested/example", options) + expect(HttpRecorder.hasCassetteSync("nested/example", options)).toBe(false) + expect(() => HttpRecorder.removeCassetteSync("nested/example", options)).not.toThrow() + expect(() => HttpRecorder.removeCassetteSync("../outside", options)).toThrow("Invalid cassette name") + }) + + test("Cassette.list enumerates recorded cassette names", async () => { + using directory = tempDirectory("http-recorder-list-") + await seedCassetteDirectory(directory.path, "alpha/one", [ + { + transport: "http", + request: { + method: "GET", + url: "https://x.test/a", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "a" }, + }, + ]) + await seedCassetteDirectory(directory.path, "beta", [ + { + transport: "http", + request: { + method: "GET", + url: "https://x.test/b", + headers: {}, + body: "", + }, + response: { status: 200, headers: {}, body: "b" }, + }, + ]) + + const names = await runFileCassette( + directory.path, + Effect.gen(function* () { + const cassette = yield* Service + return yield* cassette.list() + }), + ) + expect(names).toEqual(["alpha/one", "beta"]) + }) +}) diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json b/packages/http-recorder/test/fixtures/recordings/http/multi-step.json similarity index 100% rename from packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json rename to packages/http-recorder/test/fixtures/recordings/http/multi-step.json diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json b/packages/http-recorder/test/fixtures/recordings/http/retry.json similarity index 100% rename from packages/http-recorder/test/fixtures/recordings/record-replay/retry.json rename to packages/http-recorder/test/fixtures/recordings/http/retry.json diff --git a/packages/http-recorder/test/http.test.ts b/packages/http-recorder/test/http.test.ts new file mode 100644 index 00000000000..ebba965c7df --- /dev/null +++ b/packages/http-recorder/test/http.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Exit } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { existsSync } from "node:fs" +import { isHttpInteraction } from "../src/cassette/model" +import { HttpRecorder } from "../src" +import { failureText, post, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support" + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch("http/multi-step")))) + +const runWith = ( + name: string, + options: HttpRecorder.RecorderOptions, + effect: Effect.Effect, +) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch(name, options)))) + +describe("HTTP", () => { + test("decorates a provided HTTP client", async () => { + await Effect.runPromise( + Effect.all([post("https://example.test/echo", { step: 1 }), post("https://example.test/echo", { step: 2 })]).pipe( + Effect.provide(HttpRecorder.layer("http/multi-step")), + Effect.provide(FetchHttpClient.layer), + ), + ) + }) + + test("replay returns recorded responses in order for identical requests", async () => { + await runWith( + "http/retry", + {}, + Effect.gen(function* () { + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') + }), + ) + }) + + test("replay reports exhaustion when more requests are made than recorded", async () => { + await run( + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + yield* post("https://example.test/echo", { step: 2 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + test("a mismatch does not consume an interaction", async () => { + await run( + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("$.step expected 2, received 3") + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + }), + ) + }) + + test("distinct requests replay in any order", async () => { + await run( + Effect.gen(function* () { + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') + }), + ) + }) + + test("concurrent distinct requests atomically claim their matching interactions", async () => { + const results = await run( + Effect.all([post("https://example.test/echo", { step: 2 }), post("https://example.test/echo", { step: 1 })], { + concurrency: "unbounded", + }), + ) + + expect(results).toEqual(['{"reply":"second"}', '{"reply":"first"}']) + }) + + test("concurrent replay claims each interaction once", async () => { + const results = await runWith( + "http/retry", + {}, + Effect.all( + [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })], + { concurrency: "unbounded" }, + ), + ) + + expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}']) + }) + + test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { + await run( + Effect.gen(function* () { + const exit = yield* Effect.exit( + post("https://example.test/echo?api_key=secret-value", { + step: 3, + token: "sk-123456789012345678901234", + }), + ) + const message = failureText(exit) + expect(message).toContain("url:") + expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") + expect(message).toContain("body:") + expect(message).toContain("$.step expected 1, received 3") + expect(message).toContain('$.token expected undefined, received "[REDACTED]"') + expect(message).not.toContain("sk-123456789012345678901234") + }), + ) + }) + + test("applies custom URL redaction to mismatch errors", async () => { + const secret = "private-account" + const exit = await Effect.runPromiseExit( + post(`https://example.test/${secret}`, { step: 1 }).pipe( + Effect.provide( + HttpRecorder.layerFetch("http/multi-step", { + redact: { url: (url) => url.replace(secret, "{account}") }, + }), + ), + ), + ) + const message = failureText(exit) + + expect(message).toContain("https://example.test/{account}") + expect(message).not.toContain(secret) + }) + + test("fails when a non-empty replay cassette is completely unused", async () => { + const exit = await Effect.runPromiseExit( + Effect.void.pipe(Effect.scoped, Effect.provide(HttpRecorder.layerFetch("http/multi-step"))), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("Unused recorded interactions in http/multi-step: used 0 of 2") + }) + + test("allows an unused replay layer when the cassette is missing", async () => { + using directory = tempDirectory("http-recorder-unused-missing-") + await withEnvironment("CI", "true", () => + Effect.runPromise( + Effect.void.pipe( + Effect.scoped, + Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })), + ), + ), + ) + }) + + describe("auto mode", () => { + test("replays when the cassette exists", async () => { + using directory = tempDirectory("http-recorder-auto-") + await seedCassetteDirectory(directory.path, "auto-replay", [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/echo", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }, + response: { + status: 200, + headers: { "content-type": "application/json" }, + body: '{"reply":"hi"}', + }, + }, + ]) + + const result = await runWith( + "auto-replay", + { directory: directory.path }, + post("https://example.test/echo", { step: 1 }), + ) + expect(result).toBe('{"reply":"hi"}') + }) + + test("forces replay when CI=true even if cassette is missing", async () => { + using directory = tempDirectory("http-recorder-auto-ci-") + await withEnvironment("CI", "true", async () => { + const exit = await Effect.runPromise( + Effect.exit( + post("https://example.test/echo", { step: 1 }).pipe( + Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') + }) + }) + + test("records to disk when the cassette is missing", async () => { + using directory = tempDirectory("http-recorder-auto-record-") + using server = Bun.serve({ + port: 0, + fetch: () => + new Response('{"reply":"recorded"}', { + headers: { "content-type": "application/json" }, + }), + }) + const url = `http://127.0.0.1:${server.port}/echo` + await withEnvironment("CI", undefined, async () => { + const result = await runWith("auto-record", { directory: directory.path }, post(url, { step: 1 })) + expect(result).toBe('{"reply":"recorded"}') + expect(existsSync(`${directory.path}/auto-record.json`)).toBe(true) + }) + }) + + test("records concurrent requests in request-start order", async () => { + using directory = tempDirectory("http-recorder-order-") + const first = Promise.withResolvers() + const completed: string[] = [] + using server = Bun.serve({ + port: 0, + fetch: async (request) => { + const name = new URL(request.url).pathname.slice(1) + if (name === "first") { + await first.promise + completed.push(name) + return new Response(name) + } + completed.push(name) + first.resolve() + return new Response(name) + }, + }) + await withEnvironment("CI", undefined, async () => { + const request = (name: string) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`)) + return yield* response.text + }) + const responses = await Effect.runPromise( + Effect.all([request("first"), request("second")], { + concurrency: "unbounded", + }).pipe(Effect.provide(HttpRecorder.layerFetch("concurrent-order", { directory: directory.path }))), + ) + const cassette = readCassette(`${directory.path}/concurrent-order.json`) + + expect(completed).toEqual(["second", "first"]) + expect(responses).toEqual(["first", "second"]) + expect(cassette.interactions.filter(isHttpInteraction).map((interaction) => interaction.request.url)).toEqual([ + `http://127.0.0.1:${server.port}/first`, + `http://127.0.0.1:${server.port}/second`, + ]) + }) + }) + + test("returns the live response while persisting its redacted snapshot", async () => { + using directory = tempDirectory("http-recorder-live-response-") + using server = Bun.serve({ + port: 0, + fetch: () => + new Response(JSON.stringify({ access_token: "live-secret", safe: true }), { + headers: { + "content-type": "application/json", + "x-request-id": "request-1", + }, + }), + }) + await withEnvironment("CI", undefined, async () => { + const body = await runWith( + "live-response", + { directory: directory.path }, + post(`http://127.0.0.1:${server.port}/response`, { ok: true }), + ) + const cassette = readCassette(`${directory.path}/live-response.json`) + const interaction = cassette.interactions.find(isHttpInteraction) + + expect(body).toBe('{"access_token":"live-secret","safe":true}') + expect(interaction?.response.body).toBe('{"access_token":"[REDACTED]","safe":true}') + }) + }) + + test("reconstructs responses with null-body statuses", async () => { + using directory = tempDirectory("http-recorder-no-content-") + using server = Bun.serve({ + port: 0, + fetch: () => new Response(null, { status: 204 }), + }) + await withEnvironment("CI", undefined, async () => { + const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`)) + }) + const response = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("no-content", { directory: directory.path }))), + ) + + expect(response.status).toBe(204) + }) + }) + + test("records and replays arbitrary binary responses without changing bytes", async () => { + using directory = tempDirectory("http-recorder-binary-") + const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80]) + using server = Bun.serve({ + port: 0, + fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }), + }) + const url = `http://127.0.0.1:${server.port}/image.png` + await withEnvironment("CI", undefined, async () => { + const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get(url)) + return new Uint8Array(yield* response.arrayBuffer) + }) + const record = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))), + ) + await server.stop() + const replay = await Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))), + ) + const cassette = readCassette(`${directory.path}/binary.json`) + const interaction = cassette.interactions.find(isHttpInteraction) + + expect(record).toEqual(expected) + expect(replay).toEqual(expected) + expect(interaction?.response.bodyEncoding).toBe("base64") + }) + }) + }) +}) diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts deleted file mode 100644 index 93d7bbaded5..00000000000 --- a/packages/http-recorder/test/record-replay.test.ts +++ /dev/null @@ -1,879 +0,0 @@ -import { NodeFileSystem } from "@effect/platform-node" -import { describe, expect, test } from "bun:test" -import { Cause, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" -import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" -import { Socket } from "effect/unstable/socket" -import * as fs from "node:fs" -import * as os from "node:os" -import * as path from "node:path" -import { HttpRecorder } from "../src" -import { HttpRecorderInternal } from "../src/internal" -import { redactedErrorRequest } from "../src/internal-effect" -import type { Interaction } from "../src/schema" - -const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => - Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - -const post = (url: string, body: object) => - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const request = HttpClientRequest.post(url, { - headers: { "content-type": "application/json" }, - body: HttpBody.text(JSON.stringify(body), "application/json"), - }) - const response = yield* http.execute(request) - return yield* response.text - }) - -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http("record-replay/multi-step")))) - -const runWith = ( - name: string, - options: HttpRecorder.RecorderOptions, - effect: Effect.Effect, -) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.http(name, options)))) - -const runRecorder = (effect: Effect.Effect) => - Effect.runPromise( - Effect.scoped( - effect.pipe( - Effect.provide( - HttpRecorderInternal.Cassette.fileSystem({ - directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")), - }), - ), - Effect.provide(NodeFileSystem.layer), - ), - ), - ) - -const failureText = (exit: Exit.Exit) => { - if (Exit.isSuccess(exit)) return "" - return Cause.prettyErrors(exit.cause).join("\n") -} - -describe("http-recorder", () => { - test("redacts sensitive URL query parameters", () => { - expect( - HttpRecorderInternal.redactUrl( - "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", - ), - ).toBe( - "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", - ) - }) - - test("redacts URL credentials", () => { - expect(HttpRecorderInternal.redactUrl("https://user:password@example.test/path?safe=value")).toBe( - "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", - ) - }) - - test("applies custom URL redaction after built-in redaction", () => { - expect( - HttpRecorderInternal.redactUrl( - "https://example.test/accounts/real-account/path?key=secret-key", - undefined, - (url) => url.replace("/accounts/real-account/", "/accounts/{account}/"), - ), - ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") - }) - - test("redacts sensitive headers when allow-listed", () => { - expect( - HttpRecorderInternal.redactHeaders( - { - authorization: "Bearer secret-token", - "content-type": "application/json", - "x-custom-token": "custom-secret", - "x-api-key": "secret-key", - "x-goog-api-key": "secret-google-key", - }, - ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], - ["x-custom-token"], - ), - ).toEqual({ - authorization: "[REDACTED]", - "content-type": "application/json", - "x-api-key": "[REDACTED]", - "x-custom-token": "[REDACTED]", - "x-goog-api-key": "[REDACTED]", - }) - }) - - test("redacts error requests without retaining headers, params, or body", () => { - const request = HttpClientRequest.post("https://example.test/path", { - headers: { authorization: "Bearer super-secret" }, - body: HttpBody.text("super-secret-body", "text/plain"), - }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) - - expect(redactedErrorRequest(request).toJSON()).toMatchObject({ - url: "https://example.test/path", - urlParams: { params: [] }, - headers: {}, - body: { _tag: "Empty" }, - }) - }) - - test("detects secret-looking values without returning the secret", () => { - expect( - HttpRecorderInternal.secretFindings({ - version: 1, - interactions: [ - { - transport: "http", - request: { - method: "POST", - url: "https://example.test/path?key=sk-123456789012345678901234", - headers: {}, - body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }), - }, - response: { - status: 200, - headers: {}, - body: "Bearer abcdefghijklmnopqrstuvwxyz", - }, - }, - ], - }), - ).toEqual([ - { path: "interactions[0].request.url", reason: "API key" }, - { path: "interactions[0].request.body", reason: "Google API key" }, - { path: "interactions[0].response.body", reason: "bearer token" }, - ]) - }) - - test("detects secret-looking values inside metadata", () => { - expect( - HttpRecorderInternal.secretFindings({ - version: 1, - metadata: { token: "sk-123456789012345678901234" }, - interactions: [], - }), - ).toEqual([{ path: "metadata.token", reason: "API key" }]) - }) - - test("redacts configured and common sensitive JSON fields", () => { - const redactor = HttpRecorderInternal.Redactor.make({ jsonFields: ["account_id"] }) - const request = redactor.request({ - method: "POST", - url: "https://example.test/path", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - password: "secret-password", - accessToken: "access-token", - nested: { account_id: "account-123", safe: "visible" }, - }), - }) - - expect(JSON.parse(request.body)).toEqual({ - password: "[REDACTED]", - accessToken: "[REDACTED]", - nested: { account_id: "[REDACTED]", safe: "visible" }, - }) - }) - - test("extends default header redaction and allow lists", () => { - const redactor = HttpRecorderInternal.Redactor.make({ - headers: ["x-custom-token"], - allowRequestHeaders: ["anthropic-version", "x-custom-token"], - }) - - expect( - redactor.request({ - method: "GET", - url: "https://example.test/path", - headers: { - authorization: "Bearer secret", - "content-type": "application/json", - "anthropic-version": "2023-06-01", - "x-custom-token": "secret", - }, - body: "", - }).headers, - ).toEqual({ - "anthropic-version": "2023-06-01", - "content-type": "application/json", - "x-custom-token": "[REDACTED]", - }) - }) - - test("records WebSocket frames in observed client/server order", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const response = JSON.stringify({ type: "response.completed", token: "server-secret" }) - let receive: ((message: string | Uint8Array) => Effect.Effect | void) | undefined - const upstream = Socket.make({ - runRaw: (handler, options) => - Effect.gen(function* () { - receive = handler - if (options?.onOpen) yield* options.onOpen - receive = undefined - }), - writer: Effect.succeed(() => - Effect.suspend(() => { - const result = receive?.(response) - return Effect.isEffect(result) ? Effect.asVoid(result) : Effect.void - }), - ), - }) - - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runRaw(() => {}, { - onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })), - }) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/record", - { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - { directory, metadata: { provider: "test" }, mode: "record" }, - ).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))), - ), - ), - ) - - expect(JSON.parse(fs.readFileSync(path.join(directory, "websocket/record.json"), "utf8"))).toMatchObject({ - interactions: [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - events: [ - { direction: "client", kind: "text", body: '{"type":"response.create","token":"[REDACTED]"}' }, - { direction: "server", kind: "text", body: '{"type":"response.completed","token":"[REDACTED]"}' }, - ], - }, - ], - }) - }) - - test("WebSocket replay preserves causal frame ordering", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/replay", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [ - { direction: "server", kind: "text", body: '{"type":"session.created"}' }, - { direction: "client", kind: "text", body: '{"type":"response.create","prompt":"hello"}' }, - { direction: "server", kind: "text", body: '{"type":"response.completed"}' }, - ], - }, - ]) - - const received: string[] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runRaw((message) => { - if (typeof message !== "string") return - received.push(message) - if (JSON.parse(message).type === "session.created") - return write('{"prompt":"hello","type":"response.create"}') - }) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/replay", - { url: "wss://example.test/realtime" }, - { directory, compareClientMessagesAsJson: true, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}']) - }) - - test("the public socket decorator replays a provided Effect socket", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/public-layer", [ - { - transport: "websocket", - open: { url: "", headers: {} }, - events: [ - { direction: "client", kind: "text", body: "hello" }, - { direction: "server", kind: "text", body: "hello" }, - ], - }, - ]) - - const received: string[] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - yield* socket.runString( - (message) => - Effect.gen(function* () { - received.push(message) - yield* write(new Socket.CloseEvent(1000)) - }), - { onOpen: write("hello") }, - ) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorder.socket("websocket/public-layer", { directory }).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual(["hello"]) - }) - - test("WebSocket replay runs message handlers concurrently", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/concurrent-handlers", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [ - { direction: "server", kind: "text", body: "first" }, - { direction: "server", kind: "text", body: "second" }, - ], - }, - ]) - - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const second = yield* Deferred.make() - yield* socket.runString((message) => - message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined), - ) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/concurrent-handlers", - { url: "wss://example.test/realtime" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - }) - - test("WebSocket replay rejects close with unconsumed events", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - await seedCassetteDirectory(directory, "websocket/early-close", [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: {} }, - events: [{ direction: "client", kind: "text", body: "expected" }], - }, - ]) - - const exit = await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - return yield* Effect.exit(socket.runRaw(() => {}, { onOpen: write(new Socket.CloseEvent(1000)) })) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/early-close", - { url: "wss://example.test/realtime" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(failureText(exit)).toContain("closed with unconsumed events") - }) - - test("failed WebSocket runs do not write complete cassettes", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const exit = await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - return yield* Effect.exit(socket.runRaw(() => {})) - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/failed-run", - { url: "wss://example.test/realtime" }, - { directory, mode: "record" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("connection failed")), - writer: Effect.succeed(() => Effect.void), - }), - ), - ), - ), - ), - ), - ) - - expect(Exit.isFailure(exit)).toBe(true) - expect(fs.existsSync(path.join(directory, "websocket/failed-run.json"))).toBe(false) - }) - - test("WebSocket replay preserves binary frame kinds across reconnects", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-websocket-")) - const interaction = { - transport: "websocket" as const, - open: { url: "wss://example.test/binary", headers: {} }, - events: [ - { - direction: "client" as const, - kind: "binary" as const, - body: Buffer.from([1, 2]).toString("base64"), - bodyEncoding: "base64" as const, - }, - { - direction: "server" as const, - kind: "binary" as const, - body: Buffer.from([3, 4]).toString("base64"), - bodyEncoding: "base64" as const, - }, - ], - } - await seedCassetteDirectory(directory, "websocket/binary", [interaction, interaction]) - - const received: number[][] = [] - await Effect.runPromise( - Effect.gen(function* () { - const socket = yield* Socket.Socket - const write = yield* socket.writer - const run = socket.runRaw( - (message) => { - if (typeof message === "string") throw new Error("Expected a binary WebSocket frame") - received.push([...message]) - }, - { onOpen: write(new Uint8Array([1, 2])) }, - ) - yield* run - yield* run - }).pipe( - Effect.scoped, - Effect.provide( - HttpRecorderInternal.socketLayer( - "websocket/binary", - { url: "wss://example.test/binary" }, - { directory, mode: "replay" }, - ).pipe( - Layer.provide( - Layer.succeed( - Socket.Socket, - Socket.make({ - runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), - writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), - }), - ), - ), - ), - ), - ), - ) - - expect(received).toEqual([ - [3, 4], - [3, 4], - ]) - }) - - test("replay returns recorded responses in order for identical requests", async () => { - await runWith( - "record-replay/retry", - {}, - Effect.gen(function* () { - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') - }), - ) - }) - - test("replay reports cursor exhaustion when more requests are made than recorded", async () => { - await run( - Effect.gen(function* () { - yield* post("https://example.test/echo", { step: 1 }) - yield* post("https://example.test/echo", { step: 2 }) - const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - }) - - test("replay validates each recorded request in order", async () => { - await run( - Effect.gen(function* () { - yield* post("https://example.test/echo", { step: 1 }) - const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain("$.step expected 2, received 3") - expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') - }), - ) - }) - - test("concurrent replay claims each interaction once", async () => { - const results = await runWith( - "record-replay/retry", - {}, - Effect.all( - [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })], - { concurrency: "unbounded" }, - ), - ) - - expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}']) - }) - - test("replays when the cassette exists", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) - await seedCassetteDirectory(directory, "auto-replay", [ - { - transport: "http", - request: { - method: "POST", - url: "https://example.test/echo", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ step: 1 }), - }, - response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, - }, - ]) - - const result = await runWith("auto-replay", { directory }, post("https://example.test/echo", { step: 1 })) - expect(result).toBe('{"reply":"hi"}') - }) - - test("forces replay when CI=true even if cassette is missing", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-")) - const previous = process.env.CI - process.env.CI = "true" - try { - const exit = await Effect.runPromise( - Effect.exit( - post("https://example.test/echo", { step: 1 }).pipe( - Effect.provide(HttpRecorder.http("missing-cassette", { directory })), - ), - ), - ) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') - } finally { - if (previous === undefined) delete process.env.CI - else process.env.CI = previous - } - }) - - test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { - await run( - Effect.gen(function* () { - const exit = yield* Effect.exit( - post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), - ) - const message = failureText(exit) - expect(message).toContain("url:") - expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") - expect(message).toContain("body:") - expect(message).toContain("$.step expected 1, received 3") - expect(message).toContain('$.token expected undefined, received "[REDACTED]"') - expect(message).not.toContain("sk-123456789012345678901234") - }), - ) - }) - - test("records to disk when the cassette is missing", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-record-")) - using server = Bun.serve({ - port: 0, - fetch: () => new Response('{"reply":"recorded"}', { headers: { "content-type": "application/json" } }), - }) - const url = `http://127.0.0.1:${server.port}/echo` - // CI=true forces replay; clear it so we exercise the local-dev auto-record path. - const previous = process.env.CI - delete process.env.CI - try { - const result = await runWith("auto-record", { directory }, post(url, { step: 1 })) - expect(result).toBe('{"reply":"recorded"}') - expect(fs.existsSync(path.join(directory, "auto-record.json"))).toBe(true) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("records concurrent requests in request-start order", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-order-")) - const first = Promise.withResolvers() - const completed: string[] = [] - using server = Bun.serve({ - port: 0, - fetch: async (request) => { - const name = new URL(request.url).pathname.slice(1) - if (name === "first") { - await first.promise - completed.push(name) - return new Response(name) - } - completed.push(name) - first.resolve() - return new Response(name) - }, - }) - const previous = process.env.CI - delete process.env.CI - try { - const request = (name: string) => - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`)) - return yield* response.text - }) - const responses = await Effect.runPromise( - Effect.all([request("first"), request("second")], { concurrency: "unbounded" }).pipe( - Effect.provide(HttpRecorder.http("concurrent-order", { directory })), - ), - ) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent-order.json"), "utf8")) - - expect(completed).toEqual(["second", "first"]) - expect(responses).toEqual(["first", "second"]) - expect(cassette.interactions.map((interaction: Interaction) => interaction.request.url)).toEqual([ - `http://127.0.0.1:${server.port}/first`, - `http://127.0.0.1:${server.port}/second`, - ]) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("returns the live response while persisting its redacted snapshot", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-live-response-")) - using server = Bun.serve({ - port: 0, - fetch: () => - new Response(JSON.stringify({ access_token: "live-secret", safe: true }), { - headers: { "content-type": "application/json", "x-request-id": "request-1" }, - }), - }) - const previous = process.env.CI - delete process.env.CI - try { - const body = await runWith( - "live-response", - { directory }, - post(`http://127.0.0.1:${server.port}/response`, { ok: true }), - ) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "live-response.json"), "utf8")) - - expect(body).toBe('{"access_token":"live-secret","safe":true}') - expect(cassette.interactions[0].response.body).toBe('{"access_token":"[REDACTED]","safe":true}') - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("reconstructs responses with null-body statuses", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-no-content-")) - using server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 204 }) }) - const previous = process.env.CI - delete process.env.CI - try { - const program = Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`)) - }) - const response = await Effect.runPromise( - program.pipe(Effect.provide(HttpRecorder.http("no-content", { directory }))), - ) - - expect(response.status).toBe(204) - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("records and replays arbitrary binary responses without changing bytes", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-binary-")) - const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80]) - using server = Bun.serve({ - port: 0, - fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }), - }) - const url = `http://127.0.0.1:${server.port}/image.png` - const previous = process.env.CI - delete process.env.CI - try { - const program = Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - const response = yield* http.execute(HttpClientRequest.get(url)) - return new Uint8Array(yield* response.arrayBuffer) - }) - const record = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory })))) - await server.stop() - const replay = await Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.http("binary", { directory })))) - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "binary.json"), "utf8")) - - expect(record).toEqual(expected) - expect(replay).toEqual(expected) - expect(cassette.interactions[0].response.bodyEncoding).toBe("base64") - } finally { - if (previous !== undefined) process.env.CI = previous - } - }) - - test("UnsafeCassetteError fails the request when a recording would write a known secret", async () => { - using server = Bun.serve({ port: 0, fetch: () => new Response("Bearer abcdefghijklmnopqrstuvwxyz1234") }) - const url = `http://127.0.0.1:${server.port}/leaky` - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-unsafe-")) - - const exit = await Effect.runPromise( - Effect.exit( - post(url, { ok: true }).pipe( - Effect.provide(HttpRecorderInternal.cassetteLayer("unsafe-record", { directory, mode: "record" })), - ), - ), - ) - expect(Exit.isFailure(exit)).toBe(true) - expect(failureText(exit)).toContain("contains possible secrets") - expect(fs.existsSync(path.join(directory, "unsafe-record.json"))).toBe(false) - }) - - test("failed memory appends leave cassette state unchanged", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - const interaction: Interaction = { - transport: "http", - request: { method: "GET", url: "https://example.test", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "safe" }, - } - yield* cassette.append("transactional", interaction) - yield* cassette - .append("transactional", { - ...interaction, - response: { ...interaction.response, body: "Bearer abcdefghijklmnopqrstuvwxyz1234" }, - }) - .pipe(Effect.flip) - - expect(yield* cassette.read("transactional")).toEqual([interaction]) - }).pipe(Effect.provide(HttpRecorderInternal.Cassette.memory())), - ) - }) - - test("concurrent file appends preserve every interaction", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-concurrent-")) - await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - yield* Effect.forEach( - Array.from({ length: 20 }, (_, index) => index), - (index) => - cassette.append("concurrent", { - transport: "http", - request: { method: "GET", url: `https://example.test/${index}`, headers: {}, body: "" }, - response: { status: 200, headers: {}, body: String(index) }, - }), - { concurrency: "unbounded" }, - ) - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - - const cassette = JSON.parse(fs.readFileSync(path.join(directory, "concurrent.json"), "utf8")) - expect(cassette.interactions).toHaveLength(20) - expect(fs.readdirSync(directory).filter((file) => file.endsWith(".tmp"))).toEqual([]) - }) - - test("rejects cassette paths outside the recordings directory", () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-path-")) - expect(() => HttpRecorderInternal.hasCassetteSync("../outside", { directory })).toThrow("Invalid cassette name") - expect(() => HttpRecorderInternal.hasCassetteSync("C:\\outside", { directory })).toThrow("Invalid cassette name") - }) - - test("Cassette.list enumerates recorded cassette names", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-list-")) - await seedCassetteDirectory(directory, "alpha/one", [ - { - transport: "http", - request: { method: "GET", url: "https://x.test/a", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "a" }, - }, - ]) - await seedCassetteDirectory(directory, "beta", [ - { - transport: "http", - request: { method: "GET", url: "https://x.test/b", headers: {}, body: "" }, - response: { status: 200, headers: {}, body: "b" }, - }, - ]) - - const names = await Effect.runPromise( - Effect.gen(function* () { - const cassette = yield* HttpRecorderInternal.Cassette.Service - return yield* cassette.list() - }).pipe( - Effect.provide(HttpRecorderInternal.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), - ) - expect(names).toEqual(["alpha/one", "beta"]) - }) -}) diff --git a/packages/http-recorder/test/redaction.test.ts b/packages/http-recorder/test/redaction.test.ts new file mode 100644 index 00000000000..64e139b01fa --- /dev/null +++ b/packages/http-recorder/test/redaction.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test" +import { HttpBody, HttpClientRequest } from "effect/unstable/http" +import { redactedErrorRequest } from "../src/http/recorder" +import { make, redactHeaders, redactUrl } from "../src/redaction/redactor" +import { secretFindings } from "../src/redaction/secrets" + +describe("redaction", () => { + test("redacts sensitive URL query parameters", () => { + expect( + redactUrl( + "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", + ), + ).toBe( + "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", + ) + }) + + test("redacts URL credentials", () => { + expect(redactUrl("https://user:password@example.test/path?safe=value")).toBe( + "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", + ) + }) + + test("applies custom URL redaction after built-in redaction", () => { + expect( + redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) => + url.replace("/accounts/real-account/", "/accounts/{account}/"), + ), + ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") + }) + + test("redacts sensitive headers when allow-listed", () => { + expect( + redactHeaders( + { + authorization: "Bearer secret-token", + "content-type": "application/json", + "x-custom-token": "custom-secret", + "x-api-key": "secret-key", + "x-goog-api-key": "secret-google-key", + }, + ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], + ["x-custom-token"], + ), + ).toEqual({ + authorization: "[REDACTED]", + "content-type": "application/json", + "x-api-key": "[REDACTED]", + "x-custom-token": "[REDACTED]", + "x-goog-api-key": "[REDACTED]", + }) + }) + + test("redacts error requests without retaining headers, params, or body", () => { + const request = HttpClientRequest.post("https://example.test/path", { + headers: { authorization: "Bearer super-secret" }, + body: HttpBody.text("super-secret-body", "text/plain"), + }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) + + expect(redactedErrorRequest(request).toJSON()).toMatchObject({ + url: "https://example.test/path", + urlParams: { params: [] }, + headers: {}, + body: { _tag: "Empty" }, + }) + }) + + test("detects secret-looking values without returning the secret", () => { + expect( + secretFindings({ + version: 1, + interactions: [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/path?key=sk-123456789012345678901234", + headers: {}, + body: JSON.stringify({ + nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE", + }), + }, + response: { + status: 200, + headers: {}, + body: "Bearer abcdefghijklmnopqrstuvwxyz", + }, + }, + ], + }), + ).toEqual([ + { path: "interactions[0].request.url", reason: "API key" }, + { path: "interactions[0].request.body", reason: "Google API key" }, + { path: "interactions[0].response.body", reason: "bearer token" }, + ]) + }) + + test("detects secret-looking values inside metadata", () => { + expect( + secretFindings({ + version: 1, + metadata: { token: "sk-123456789012345678901234" }, + interactions: [], + }), + ).toEqual([{ path: "metadata.token", reason: "API key" }]) + }) + + test("redacts configured and common sensitive JSON fields", () => { + const redactor = make({ + jsonFields: ["account_id"], + }) + const request = redactor.request({ + method: "POST", + url: "https://example.test/path", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + password: "secret-password", + accessToken: "access-token", + nested: { account_id: "account-123", safe: "visible" }, + }), + }) + + expect(JSON.parse(request.body)).toEqual({ + password: "[REDACTED]", + accessToken: "[REDACTED]", + nested: { account_id: "[REDACTED]", safe: "visible" }, + }) + }) + + test("preserves JSON text when no fields are redacted", () => { + const body = '{\n "id": 9007199254740993,\n "safe": true\n}' + + expect( + make().request({ + method: "POST", + url: "https://example.test/path", + headers: { "content-type": "application/json" }, + body, + }).body, + ).toBe(body) + }) + + test("extends default header redaction and allow lists", () => { + const redactor = make({ + headers: ["x-custom-token"], + allowRequestHeaders: ["anthropic-version", "x-custom-token"], + }) + + expect( + redactor.request({ + method: "GET", + url: "https://example.test/path", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "x-custom-token": "secret", + }, + body: "", + }).headers, + ).toEqual({ + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "x-custom-token": "[REDACTED]", + }) + }) +}) diff --git a/packages/http-recorder/test/support.ts b/packages/http-recorder/test/support.ts new file mode 100644 index 00000000000..30fa8205f22 --- /dev/null +++ b/packages/http-recorder/test/support.ts @@ -0,0 +1,61 @@ +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Cause, Effect, Exit } from "effect" +import { HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { decodeCassette, type Interaction } from "../src/cassette/model" +import { Service, fileSystem } from "../src/cassette/store" + +export const tempDirectory = (prefix: string) => { + const directory = mkdtempSync(join(tmpdir(), prefix)) + return { + path: directory, + [Symbol.dispose]() { + rmSync(directory, { recursive: true, force: true }) + }, + } +} + +export const post = (url: string, body: object) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute( + HttpClientRequest.post(url, { + headers: { "content-type": "application/json" }, + body: HttpBody.text(JSON.stringify(body), "application/json"), + }), + ) + return yield* response.text + }) + +export const readCassette = (file: string) => decodeCassette(JSON.parse(readFileSync(file, "utf8"))) + +export const runFileCassette = (directory: string, effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(fileSystem({ directory })), Effect.provide(NodeFileSystem.layer))) + +export const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => + runFileCassette( + directory, + Effect.gen(function* () { + const cassette = yield* Service + yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) + }), + ) + +export const withEnvironment = async (name: string, value: string | undefined, run: () => Promise) => { + const previous = process.env[name] + if (value === undefined) delete process.env[name] + else process.env[name] = value + try { + return await run() + } finally { + if (previous === undefined) delete process.env[name] + else process.env[name] = previous + } +} + +export const failureText = (exit: Exit.Exit) => { + if (Exit.isSuccess(exit)) return "" + return Cause.prettyErrors(exit.cause).join("\n") +} diff --git a/packages/http-recorder/test/tsconfig.json b/packages/http-recorder/test/tsconfig.json new file mode 100644 index 00000000000..97888caa286 --- /dev/null +++ b/packages/http-recorder/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "declaration": false, + "module": "preserve", + "moduleResolution": "bundler" + }, + "include": ["../src", "."] +} diff --git a/packages/http-recorder/test/websocket.test.ts b/packages/http-recorder/test/websocket.test.ts new file mode 100644 index 00000000000..2d4d30cbf26 --- /dev/null +++ b/packages/http-recorder/test/websocket.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, test } from "bun:test" +import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Socket } from "effect/unstable/socket" +import { existsSync } from "node:fs" +import { HttpRecorder } from "../src" +import { layerSocketWithMode } from "../src/websocket/recorder" +import { failureText, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support" + +const unavailableSocket = Socket.make({ + runRaw: () => Effect.die(new Error("unexpected live WebSocket run")), + writer: Effect.succeed(() => Effect.die(new Error("unexpected live WebSocket write"))), +}) + +class EchoWebSocket extends EventTarget { + readonly protocol = "" + readonly extensions = "" + bufferedAmount = 0 + binaryType: BinaryType = "blob" + readyState = 0 + + constructor(readonly url: string) { + super() + queueMicrotask(() => { + this.readyState = 1 + this.dispatchEvent(new Event("open")) + }) + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { + queueMicrotask(() => this.dispatchEvent(new MessageEvent("message", { data }))) + } + + close(code = 1000, reason = "") { + if (this.readyState === 3) return + this.readyState = 3 + this.dispatchEvent(new CloseEvent("close", { code, reason, wasClean: code === 1000 })) + } +} + +describe("WebSocket", () => { + test("constructor recording is complete when the recorder layer closes", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-record", { + directory: directory.path, + }).pipe( + Layer.provide( + Layer.succeed(Socket.WebSocketConstructor, (url) => new EchoWebSocket(url) as unknown as globalThis.WebSocket), + ), + ) + + await withEnvironment("CI", undefined, () => + Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://echo.example.test/one", { + protocols: ["echo.v1"], + closeCodeIsError: () => false, + }) + const write = yield* socket.writer + yield* socket.runString(() => write(new Socket.CloseEvent(1000, "complete")).pipe(Effect.orDie), { + onOpen: write("hello").pipe(Effect.orDie), + }) + }).pipe(Effect.scoped, Effect.provide(recorder)), + ), + ) + + expect(readCassette(`${directory.path}/websocket/constructor-record.json`).interactions).toEqual([ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://echo.example.test/one", + protocols: ["echo.v1"], + close: { code: 1000, reason: "complete" }, + }, + events: [ + { direction: "client", kind: "text", body: "hello" }, + { direction: "server", kind: "text", body: "hello" }, + ], + }, + ]) + }) + + test("constructor replay validates dynamic URLs and protocols without opening a live socket", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + await seedCassetteDirectory(directory.path, "websocket/constructor", [ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://events.example.test/workspaces/one", + protocols: ["events.v1"], + close: { code: 1000, reason: "complete" }, + }, + events: [ + { direction: "client", kind: "text", body: '{"type":"subscribe"}' }, + { direction: "server", kind: "text", body: '{"type":"ready"}' }, + ], + }, + ]) + const unavailableConstructor = () => { + throw new Error("unexpected live WebSocket construction") + } + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor", { + directory: directory.path, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, unavailableConstructor))) + + const received = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/one", { + protocols: ["events.v1"], + closeCodeIsError: () => false, + }) + const write = yield* socket.writer + const received: string[] = [] + yield* socket.runString( + (message) => { + received.push(message) + }, + { + onOpen: write('{"type":"subscribe"}').pipe(Effect.orDie), + }, + ) + return received + }).pipe(Effect.scoped, Effect.provide(recorder)), + ) + + expect(received).toEqual(['{"type":"ready"}']) + }) + + test("constructor replay rejects a different dynamic URL", async () => { + using directory = tempDirectory("http-recorder-websocket-constructor-") + await seedCassetteDirectory(directory.path, "websocket/constructor-mismatch", [ + { + transport: "websocket", + connection: { + sequence: 0, + url: "wss://events.example.test/workspaces/one", + protocols: [], + close: { code: 1000, reason: "complete" }, + }, + events: [], + }, + ]) + const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-mismatch", { + directory: directory.path, + }).pipe( + Layer.provide( + Layer.succeed(Socket.WebSocketConstructor, () => { + throw new Error("unexpected live WebSocket construction") + }), + ), + ) + + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.makeWebSocket("wss://events.example.test/workspaces/two") + yield* socket.runString(() => {}) + }).pipe(Effect.scoped, Effect.exit, Effect.provide(recorder)), + ) + + expect(Exit.isFailure(exit)).toBe(true) + }) + + test("records WebSocket frames in observed client/server order", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const response = JSON.stringify({ + type: "response.completed", + token: "server-secret", + }) + const upstream = Socket.make({ + runRaw: (handler, options) => + Effect.gen(function* () { + if (options?.onOpen) yield* options.onOpen + const result = handler(response) + if (Effect.isEffect(result)) yield* result + }), + writer: Effect.succeed(() => Effect.void), + }) + + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runRaw(() => {}, { + onOpen: write(JSON.stringify({ type: "response.create", token: "client-secret" })).pipe(Effect.orDie), + }) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/record", { + directory: directory.path, + metadata: { provider: "test" }, + mode: "record", + }).pipe(Layer.provide(Layer.succeed(Socket.Socket, upstream))), + ), + ), + ) + + expect(readCassette(`${directory.path}/websocket/record.json`)).toMatchObject({ + interactions: [ + { + transport: "websocket", + events: [ + { + direction: "client", + kind: "text", + body: '{"type":"response.create","token":"[REDACTED]"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","token":"[REDACTED]"}', + }, + ], + }, + ], + }) + }) + + test("WebSocket replay preserves causal frame ordering", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/replay", [ + { + transport: "websocket", + events: [ + { + direction: "server", + kind: "text", + body: '{"type":"session.created"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"hello"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed"}', + }, + ], + }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runRaw((message) => + Effect.gen(function* () { + if (typeof message !== "string") return + received.push(message) + const event: unknown = JSON.parse(message) + if (typeof event !== "object" || event === null || !("type" in event)) return + if (event.type === "session.created") yield* write('{"prompt":"hello","type":"response.create"}') + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/replay", { + directory: directory.path, + compareClientMessagesAsJson: true, + mode: "replay", + }).pipe(Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket))), + ), + ), + ) + + expect(received).toEqual(['{"type":"session.created"}', '{"type":"response.completed"}']) + }) + + test("the public socket decorator replays a causal provider conversation", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/public-layer", [ + { + transport: "websocket", + events: [ + { + direction: "server", + kind: "text", + body: '{"type":"session.created"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"first"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","id":"first"}', + }, + { + direction: "client", + kind: "text", + body: '{"type":"response.create","prompt":"second"}', + }, + { + direction: "server", + kind: "text", + body: '{"type":"response.completed","id":"second"}', + }, + ], + }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + yield* socket.runString((message) => + Effect.gen(function* () { + received.push(message) + const event: unknown = JSON.parse(message) + if (typeof event !== "object" || event === null) return + if ("type" in event && event.type === "session.created") { + yield* write('{"prompt":"first","type":"response.create"}') + return + } + if ("id" in event && event.id === "first") { + yield* write('{"prompt":"second","type":"response.create"}') + return + } + yield* write(new Socket.CloseEvent(1000, "done")) + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + HttpRecorder.layerSocket("websocket/public-layer", { directory: directory.path }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual([ + '{"type":"session.created"}', + '{"type":"response.completed","id":"first"}', + '{"type":"response.completed","id":"second"}', + ]) + }) + + test("WebSocket replay runs message handlers concurrently", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/concurrent-handlers", [ + { + transport: "websocket", + events: [ + { direction: "server", kind: "text", body: "first" }, + { direction: "server", kind: "text", body: "second" }, + ], + }, + ]) + + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const second = yield* Deferred.make() + yield* socket.runString((message) => + message === "first" ? Deferred.await(second) : Deferred.succeed(second, undefined), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/concurrent-handlers", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + }) + + test("rejected concurrent replay does not consume the next interaction", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/concurrent-runs", [ + { transport: "websocket", events: [{ direction: "server", kind: "text", body: "first" }] }, + { transport: "websocket", events: [{ direction: "server", kind: "text", body: "second" }] }, + ]) + + const received: string[] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const first = yield* socket + .runString((message) => + Effect.gen(function* () { + received.push(message) + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + + const concurrent = yield* Effect.exit(socket.runString(() => Effect.void)) + expect(failureText(concurrent)).toContain("Concurrent runs") + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(first) + yield* socket.runString((message) => Effect.sync(() => received.push(message))) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/concurrent-runs", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual(["first", "second"]) + }) + + test("WebSocket replay rejects close with unconsumed events", async () => { + using directory = tempDirectory("http-recorder-websocket-") + await seedCassetteDirectory(directory.path, "websocket/early-close", [ + { + transport: "websocket", + events: [{ direction: "client", kind: "text", body: "expected" }], + }, + ]) + + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + return yield* Effect.exit( + socket.runRaw(() => {}, { + onOpen: write(new Socket.CloseEvent(1000)).pipe(Effect.orDie), + }), + ) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/early-close", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(failureText(exit)).toContain("closed with unconsumed events") + }) + + test("failed WebSocket runs do not write complete cassettes", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const exit = await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + return yield* Effect.exit(socket.runRaw(() => {})) + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/failed-run", { directory: directory.path, mode: "record" }).pipe( + Layer.provide( + Layer.succeed( + Socket.Socket, + Socket.make({ + runRaw: () => Effect.die(new Error("connection failed")), + writer: Effect.succeed(() => Effect.void), + }), + ), + ), + ), + ), + ), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(existsSync(`${directory.path}/websocket/failed-run.json`)).toBe(false) + }) + + test("WebSocket replay preserves binary frame kinds across reconnects", async () => { + using directory = tempDirectory("http-recorder-websocket-") + const interaction = { + transport: "websocket" as const, + events: [ + { + direction: "client" as const, + kind: "binary" as const, + body: Buffer.from([1, 2]).toString("base64"), + bodyEncoding: "base64" as const, + }, + { + direction: "server" as const, + kind: "binary" as const, + body: Buffer.from([3, 4]).toString("base64"), + bodyEncoding: "base64" as const, + }, + ], + } + await seedCassetteDirectory(directory.path, "websocket/binary", [interaction, interaction]) + + const received: number[][] = [] + await Effect.runPromise( + Effect.gen(function* () { + const socket = yield* Socket.Socket + const write = yield* socket.writer + const run = socket.runRaw( + (message) => { + if (typeof message === "string") throw new Error("Expected a binary WebSocket frame") + received.push([...message]) + }, + { onOpen: write(new Uint8Array([1, 2])).pipe(Effect.orDie) }, + ) + yield* run + yield* run + }).pipe( + Effect.scoped, + Effect.provide( + layerSocketWithMode("websocket/binary", { directory: directory.path, mode: "replay" }).pipe( + Layer.provide(Layer.succeed(Socket.Socket, unavailableSocket)), + ), + ), + ), + ) + + expect(received).toEqual([ + [3, 4], + [3, 4], + ]) + }) +}) diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index f6ad6b02d7f..30822e57bb8 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 f6a55e73a0d..f949f70d46d 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/AGENTS.md b/packages/llm/AGENTS.md index c4edaba2b49..e883a9e47d1 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -113,6 +113,23 @@ Keep provider facades small and explicit: `Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior. +### Provider Package Entrypoints + +Catalog-selected native providers use package-like export paths from `@opencode-ai/llm`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays. + +```ts +import { model } from "@opencode-ai/llm/providers/openai/responses" + +const selected = model("gpt-5", { + apiKey, + transport: "websocket", +}) +``` + +Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `Model`. + +Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`. + ### Folder layout ``` @@ -303,7 +320,7 @@ recorded.effect("streams text", () => ) ``` -Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. +Replay is the default. `RECORD=true` records fresh cassettes locally and requires the listed env vars; unset `CI` before recording because CI always forces replay. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file: @@ -316,6 +333,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re **Binary response bodies.** Most providers stream text (SSE, JSON). The recorder treats known textual media types (`text/*`, JSON/XML structured types, JavaScript, forms, YAML, and SVG) as text and stores every other response as base64 with `bodyEncoding: "base64"`. This preserves binary formats such as AWS event-stream frames without a lossy UTF-8 round trip. -**Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. +**Matching strategy.** A runtime request atomically claims the first unused recorded interaction that matches its method, URL, allow-listed headers, and canonical JSON body. Distinct requests may replay in any order or concurrently. Repeated identical requests consume their matching responses in cassette order, preserving deterministic retry and polling behavior. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed. diff --git a/packages/llm/README.md b/packages/llm/README.md index 330222de93a..477d01701ab 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -106,6 +106,32 @@ const gateway = CloudflareAIGateway.configure({ Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. +### Package-like entrypoints + +Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/llm` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays. + +```ts +import { model } from "@opencode-ai/llm/providers/openai/responses" + +const selected = model("gpt-5", { + apiKey: process.env.OPENAI_API_KEY, + transport: "websocket", + headers: { "x-application": "opencode" }, + limits: { context: 200_000, output: 64_000 }, +}) +``` + +OpenAI Chat and OpenAI Responses are separate semantic entrypoints: + +- `@opencode-ai/llm/providers/openai/chat` +- `@opencode-ai/llm/providers/openai/responses` + +Responses HTTP versus WebSocket is a scoped `transport` setting on the Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Anthropic, OpenAI-compatible Chat, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths. + +Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path. + +Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package. + ## Provider options & HTTP overlays Three escape hatches in order of stability: diff --git a/packages/llm/STATUS.md b/packages/llm/STATUS.md index 874862d9af0..872023e665b 100644 --- a/packages/llm/STATUS.md +++ b/packages/llm/STATUS.md @@ -1,6 +1,6 @@ # LLM Provider Parity Status -Last reviewed: 2026-07-02 +Last reviewed: 2026-07-08 This file tracks the gap between the native `@opencode-ai/llm` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. @@ -64,26 +64,27 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. -8. Package/namespace boundaries need to be made explicit in docs and exports. Protocol namespaces exist, but planned public groupings should call out OpenAI Chat, OpenAI Responses, OpenAI-compatible Chat, OpenAI-compatible Responses, Anthropic Messages, Gemini, Vertex Gemini, Vertex Anthropic Messages, Bedrock Converse, and Bedrock Mantle as separate API slices. +8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for OpenAI-compatible Responses, Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle. 9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. -## Proposed Native Namespace Shape +## Native Namespace Shape These are implementation/API slices, not separate npm packages. -| Namespace | Purpose | -| --- | --- | -| `OpenAI.Chat` or `OpenAIChat` | OpenAI `/chat/completions` semantics. | -| `OpenAI.Responses` or `OpenAIResponses` | OpenAI `/responses` HTTP and WebSocket semantics. | -| `OpenAICompatible.Chat` or `OpenAICompatibleChat` | Generic OpenAI-compatible `/chat/completions`. | -| `OpenAICompatible.Responses` or `OpenAICompatibleResponses` | Generic OpenAI-compatible `/responses`. Missing today. | -| `Anthropic.Messages` or `AnthropicMessages` | Anthropic Messages API. | -| `Google.Gemini` or `Gemini` | Gemini Developer API. | -| `GoogleVertex.Gemini` | Vertex Gemini API. Missing today. | -| `GoogleVertex.AnthropicMessages` | Vertex-hosted Anthropic Messages API. Missing today. | -| `Bedrock.Converse` or `BedrockConverse` | AWS Bedrock Converse API. | -| `Bedrock.Mantle` | AWS Bedrock Mantle OpenAI-compatible APIs. Missing today. | -| `Azure.OpenAIChat` / `Azure.OpenAIResponses` | Azure deployment specializations over OpenAI protocols. | +| API slice | Package-like entrypoint | Purpose | +| --- | --- | --- | +| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. | +| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | +| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | +| OpenAI-compatible Responses | Missing | Generic OpenAI-compatible `/responses`. | +| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. | +| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. | +| Vertex Gemini | Missing | Vertex Gemini API. | +| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. | +| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. | +| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | +| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. | +| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. | ## Suggested Next Work Slices diff --git a/packages/llm/example/call-sites.md b/packages/llm/example/call-sites.md index 093f74e51de..7c5a411cbac 100644 --- a/packages/llm/example/call-sites.md +++ b/packages/llm/example/call-sites.md @@ -342,14 +342,24 @@ const response = ) ``` -HTTP versus WebSocket is represented as named route selectors, not as model or -request overrides. Same protocol, different transport, different route: +For direct provider-facade calls, HTTP versus WebSocket is represented as named +route selectors, not as model or request overrides. Same protocol, different +transport, different route: ```ts OpenAI.responses("gpt-4o") OpenAI.responsesWebSocket("gpt-4o") ``` +The package-like OpenAI Responses entrypoint instead keeps transport scoped to +Responses settings while preserving the same `model(...)` contract: + +```ts +import { model } from "@opencode-ai/llm/providers/openai/responses" + +model("gpt-4o", { apiKey, transport: "websocket" }) +``` + The client should not require a different public layer just because a selected route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime capabilities available; routes that do not need WebSocket simply never touch it. @@ -468,10 +478,10 @@ const model = ``` That boundary can branch on durable config/catalog metadata and call typed -provider APIs directly. Transport selection belongs there too: map metadata like -`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use -the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes -the route carried by the model. +provider APIs directly. A direct provider-facade boundary maps metadata like +`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading +boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint. +The client runtime only executes the route carried by the resulting model. ## Competitive Shape @@ -507,8 +517,9 @@ App boundary = explicit durable-config -> typed-provider call id. - No `model(id, overrides)` escape hatch. Model selection takes the model id; endpoint/auth/deployment customization happens by configuring the route first. -- No transport override on model/request. HTTP SSE versus WebSocket is a named - route selector such as `responses` versus `responsesWebSocket`. +- No transport override on an executable model or request. Direct provider + facades use `responses` versus `responsesWebSocket`; the package-like Responses + entrypoint maps its scoped `transport` setting before constructing the model. - No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one client layer with the available transport capabilities. - No executable `ModelRef`. The executable handle is `Model`; durable model diff --git a/packages/llm/package.json b/packages/llm/package.json index 7c4084aacd3..2895cf1d22a 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -20,6 +20,8 @@ "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", "./providers/anthropic": "./src/providers/anthropic.ts", "./providers/azure": "./src/providers/azure.ts", + "./providers/azure/responses": "./src/providers/azure/responses.ts", + "./providers/azure/chat": "./src/providers/azure/chat.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/google": "./src/providers/google.ts", diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 56fc90224ce..178b6919f68 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 0c6b0598ffd..4984e323658 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 c4bb9476a49..82a69b059ef 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 eb3b7141c62..cba67ce0a92 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 ce3f0a83d75..9ae9a53b437 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 e0546f47ed0..e2889d5b6d6 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 8d3f2a8f63a..321bd7927ef 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/providers/azure.ts b/packages/llm/src/providers/azure.ts index bfac2d1cad3..dd0691a5391 100644 --- a/packages/llm/src/providers/azure.ts +++ b/packages/llm/src/providers/azure.ts @@ -1,6 +1,7 @@ import { Auth } from "../route/auth" import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" import type { Route as RouteDef, RouteDefaultsInput } from "../route/client" +import type { ProviderPackage } from "../provider-package" import { ProviderID, type ModelID } from "../schema" import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" @@ -23,6 +24,14 @@ export type ModelOptions = AzureURL & } export type Config = ModelOptions +export type Settings = ProviderPackage.Settings & + AzureURL & { + readonly apiKey?: string + readonly apiVersion?: string + readonly queryParams?: Readonly> + readonly providerOptions?: OpenAIProviderOptionsInput + } + const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1` const responsesRoute = OpenAIResponses.route.with({ @@ -108,3 +117,24 @@ export const provider = { id, configure, } + +const config = (settings: Settings): Config => { + const common = { + apiKey: settings.apiKey, + apiVersion: settings.apiVersion, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams }, + } + if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL } + if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName } + throw new Error("Azure requires resourceName or baseURL") +} + +export const responsesModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).responses(modelID) +export const chatModel: ProviderPackage.Definition["model"] = (modelID, settings) => + configure(config(settings)).chat(modelID) +export const model = responsesModel diff --git a/packages/llm/src/providers/azure/chat.ts b/packages/llm/src/providers/azure/chat.ts new file mode 100644 index 00000000000..ff3e474332e --- /dev/null +++ b/packages/llm/src/providers/azure/chat.ts @@ -0,0 +1,2 @@ +export { chatModel as model } from "../azure" +export type { Settings } from "../azure" diff --git a/packages/llm/src/providers/azure/responses.ts b/packages/llm/src/providers/azure/responses.ts new file mode 100644 index 00000000000..e7b8ab15ae4 --- /dev/null +++ b/packages/llm/src/providers/azure/responses.ts @@ -0,0 +1,2 @@ +export { responsesModel as model } from "../azure" +export type { Settings } from "../azure" diff --git a/packages/llm/src/providers/google.ts b/packages/llm/src/providers/google.ts index c8a72c31f67..6cf9ac21ec3 100644 --- a/packages/llm/src/providers/google.ts +++ b/packages/llm/src/providers/google.ts @@ -1,7 +1,8 @@ import type { RouteDefaultsInput } from "../route/client" import { Auth } from "../route/auth" import type { ProviderAuthOption } from "../route/auth-options" -import { ProviderID, type ModelID } from "../schema" +import type { ProviderPackage } from "../provider-package" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import * as Gemini from "../protocols/gemini" export const id = ProviderID.make("google") @@ -10,6 +11,12 @@ export const routes = [Gemini.route] export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly providerOptions?: ProviderOptions +} + const auth = (options: ProviderAuthOption<"optional">) => { if ("auth" in options && options.auth) return options.auth return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey") @@ -32,4 +39,12 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model = provider.model +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers === undefined ? undefined : { ...settings.headers }, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + limits: settings.limits, + providerOptions: settings.providerOptions, + }).model(modelID) diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 24d72a365f0..65a6798fd6b 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: Auth @@ -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 00000000000..3622c894541 --- /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/llm/test/provider-package.test.ts b/packages/llm/test/provider-package.test.ts index 0d3ddc811c4..346b6b86ee3 100644 --- a/packages/llm/test/provider-package.test.ts +++ b/packages/llm/test/provider-package.test.ts @@ -10,10 +10,15 @@ describe("provider package entrypoints", () => { import("@opencode-ai/llm/providers/anthropic"), import("@opencode-ai/llm/providers/openai-compatible"), import("@opencode-ai/llm/providers/amazon-bedrock"), + import("@opencode-ai/llm/providers/azure"), + import("@opencode-ai/llm/providers/azure/responses"), + import("@opencode-ai/llm/providers/azure/chat"), + import("@opencode-ai/llm/providers/google"), ]) for (const module of modules) expect(module.model).toBeFunction() expect(modules[0].model).toBe(modules[1].model) + expect(modules[6].model).toBe(modules[7].model) }) test("maps package settings onto the executable model", () => { @@ -49,4 +54,49 @@ describe("provider package entrypoints", () => { "OpenAI-Project": "proj_123", }) }) + + test("selects Azure API entrypoints with the same model contract", async () => { + const Azure = await import("@opencode-ai/llm/providers/azure") + const AzureChat = await import("@opencode-ai/llm/providers/azure/chat") + const AzureResponses = await import("@opencode-ai/llm/providers/azure/responses") + const settings = { + apiKey: "fixture", + resourceName: "opencode-test", + headers: { "x-application": "opencode" }, + body: { service_tier: "priority" }, + limits: { context: 200_000, output: 64_000 }, + } + + const responses = AzureResponses.model("deployment", settings) + const chat = AzureChat.model("deployment", settings) + + expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses") + expect(responses.route.id).toBe("azure-openai-responses") + expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1") + expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" }) + expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 }) + expect(chat.route.id).toBe("azure-openai-chat") + }) + + test("maps Google package settings onto the Gemini model", async () => { + const Google = await import("@opencode-ai/llm/providers/google") + const selected = Google.model("gemini-2.5-flash", { + apiKey: "fixture", + baseURL: "https://generativelanguage.test/v1beta", + headers: { "x-application": "opencode" }, + body: { safetySettings: [] }, + limits: { context: 1_000_000, output: 65_536 }, + providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } }, + }) + + expect(selected.route.id).toBe("gemini") + expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta") + expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" }) + expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] }) + expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 }) + expect(selected.route.defaults.providerOptions).toEqual({ + gemini: { thinkingConfig: { thinkingBudget: 1_024 } }, + }) + }) }) diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index ef67c866d8d..ae09cf7120f 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -12,7 +12,6 @@ const openAI = OpenAI.configure({ }) const openAIChat = openAI.chat("gpt-4o-mini") const openAIResponses = openAI.responses("gpt-5.5") -const openAIResponsesWebSocket = openAI.responsesWebSocket("gpt-4.1-mini") const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", }) @@ -89,14 +88,6 @@ describeRecordedGoldenScenarios([ { id: "image-tool-result", temperature: false, maxTokens: 40 }, ], }, - { - name: "OpenAI Responses WebSocket gpt-4.1-mini", - prefix: "openai-responses-websocket", - model: openAIResponsesWebSocket, - transport: "websocket", - requires: ["OPENAI_API_KEY"], - scenarios: ["tool-loop"], - }, { name: "Anthropic Haiku 4.5", prefix: "anthropic-messages", diff --git a/packages/llm/test/recorded-golden.ts b/packages/llm/test/recorded-golden.ts index 540662b2993..404a5028b2a 100644 --- a/packages/llm/test/recorded-golden.ts +++ b/packages/llm/test/recorded-golden.ts @@ -28,7 +28,7 @@ type TargetInput = { readonly transport?: Transport readonly prefix?: string readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata readonly options?: HttpRecorder.RecorderOptions readonly scenarios: ReadonlyArray } @@ -43,7 +43,7 @@ const defaultPrefix = (target: TargetInput) => { const metadata = (target: TargetInput) => ({ provider: target.model.provider, - protocol: target.protocol, + ...(target.protocol ? { protocol: target.protocol } : {}), route: target.model.route.id, transport: target.transport ?? "http", model: target.model.id, diff --git a/packages/llm/test/recorded-runner.ts b/packages/llm/test/recorded-runner.ts index 97d9b03f546..904a9366aca 100644 --- a/packages/llm/test/recorded-runner.ts +++ b/packages/llm/test/recorded-runner.ts @@ -1,3 +1,4 @@ +import type { HttpRecorder } from "@opencode-ai/http-recorder" import { test, type TestOptions } from "bun:test" import { Effect, type Layer } from "effect" import { testEffect } from "./lib/effect" @@ -11,7 +12,7 @@ export type RecordedGroupOptions = { readonly protocol?: string readonly requires?: ReadonlyArray readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata } export type RecordedCaseOptions = { @@ -21,7 +22,7 @@ export type RecordedCaseOptions = { readonly protocol?: string readonly requires?: ReadonlyArray readonly tags?: ReadonlyArray - readonly metadata?: Record + readonly metadata?: HttpRecorder.CassetteMetadata } export const recordedEffectGroup = < @@ -36,7 +37,7 @@ export const recordedEffectGroup = < readonly layer: (input: { readonly cassette: string readonly tags: ReadonlyArray - readonly metadata: Record + readonly metadata: HttpRecorder.CassetteMetadata readonly recording: boolean readonly options: Options readonly caseOptions: CaseOptions diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts index 669b8de5c5a..da400857797 100644 --- a/packages/llm/test/recorded-test.ts +++ b/packages/llm/test/recorded-test.ts @@ -1,11 +1,8 @@ -import { NodeFileSystem } from "@effect/platform-node" import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" import * as path from "node:path" import { fileURLToPath } from "node:url" -import { LLMClient, RequestExecutor } from "../src/route" +import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route" import type { Service as LLMClientService } from "../src/route/client" import type { Service as RequestExecutorService } from "../src/route/executor" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" @@ -14,7 +11,6 @@ import { type RecordedCaseOptions as RunnerCaseOptions, type RecordedGroupOptions, } from "./recorded-runner" -import { webSocketCassetteLayer } from "./recorded-websocket" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") @@ -64,31 +60,27 @@ export const recordedTests = (options: RecordedTestsOptions) => recordedEffectGroup({ duplicateLabel: "recorded cassette", options, - cassetteExists: (cassette) => HttpRecorderInternal.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), + cassetteExists: (cassette) => HttpRecorder.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), layer: ({ cassette, metadata, options, caseOptions, recording }) => { const recorderOptions = mergeOptions(options.options, caseOptions.options) const recorderMetadata = { ...recorderOptions?.metadata, ...metadata, } - const mode = recording ? "record" : "replay" - const cassetteService = HttpRecorderInternal.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( - Layer.provide(NodeFileSystem.layer), - ) + if (recording) { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR }) + } const requestExecutor = RequestExecutor.layer.pipe( Layer.provide( - HttpRecorderInternal.recordingLayer(cassette, { - mode, + HttpRecorder.layerFetch(cassette, { + ...recorderOptions, + directory: FIXTURES_DIR, metadata: recorderMetadata, - redactor: HttpRecorderInternal.Redactor.make(recorderOptions?.redact), - match: recorderOptions?.match, - }).pipe(Layer.provide(FetchHttpClient.layer)), + }), ), ) - const deps = Layer.mergeAll( - requestExecutor, - webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }), - ) - return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))).pipe(Layer.provide(cassetteService)) + const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer) + return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))) }, }) diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts deleted file mode 100644 index afeee09b77e..00000000000 --- a/packages/llm/test/recorded-websocket.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" -import { Effect, Layer } from "effect" -import { WebSocketExecutor } from "../src/route" -import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" - -const liveWebSocket = WebSocketExecutor.open - -export const webSocketCassetteLayer = ( - cassette: string, - input: { readonly metadata?: Record; readonly mode: HttpRecorderInternal.RecordReplayMode }, -): Layer.Layer => - Layer.effect( - WebSocketExecutor.Service, - Effect.gen(function* () { - const cassetteService = yield* HttpRecorderInternal.Cassette.Service - const executor = yield* HttpRecorderInternal.makeWebSocketExecutor({ - name: cassette, - mode: input.mode, - metadata: input.metadata, - cassette: cassetteService, - live: { open: liveWebSocket }, - compareClientMessagesAsJson: true, - }) - return WebSocketExecutor.Service.of(executor) - }), - ) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 65e02f07636..95dc7f0b188 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 18d64188a42..2e2292f1afa 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 641ae2ddf8c..2ce86abe43c 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 60112e10d96..a42c367ec2c 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 4da9bc3ca86..f393caa1a7d 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 f61cea10484..4039fd36fa4 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 4c3139ce9c7..f4f29cc453a 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 fcba5842fed..5eafd1266c9 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 be248d3a3dc..5d0894c0d79 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 ca5ca1d7b0d..51633f57b7b 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 fa3b1e670e0..195699f9c9e 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 9d7643cb330..a5677d5274e 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 cf27c74cbec..4aa750c39db 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/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 7ca09910bf8..36853a6d268 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -2,7 +2,6 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ModelsDev } from "@opencode-ai/core/models-dev" import { HttpRecorder } from "@opencode-ai/http-recorder" -import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import { describe, expect, test } from "bun:test" import { tool, type ModelMessage, type JSONValue } from "ai" import { Effect, Layer, Option, Schema, Stream } from "effect" @@ -224,7 +223,7 @@ function isSelected(scenario: RecordedScenario) { const canRun = (scenario: RecordedScenario) => shouldRecord ? scenario.canRecord() - : HttpRecorderInternal.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) + : HttpRecorder.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) const recordError = (scenario: RecordedScenario) => scenario.id === "openai-oauth" @@ -272,14 +271,11 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { url: (url: string) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"), body: redactRecordedBody, } - const recordedHttp = shouldRecord - ? HttpRecorderInternal.cassetteLayer(scenario.cassette, { - directory: FIXTURES_DIR, - mode: "record", - metadata, - redactor: HttpRecorderInternal.Redactor.make(redact), - }) - : HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) + if (shouldRecord) { + if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes") + HttpRecorder.removeCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) + } + const recordedHttp = HttpRecorder.layerFetch(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [ [LayerNodePlatform.requestExecutor, RequestExecutor.layer.pipe(Layer.provide(recordedHttp))], [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: true })], diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1de84c9dd95..9bb688aeddb 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 c8c5fac5955..456d6659506 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 f62c8b4d317..32171789c82 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 8f8286d5628..d29a7398f46 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 d93db3e5b03..e5905570447 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 a9d8d7dcd3e..8e2fee5908b 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 71fa07bd7b0..284d1ffe096 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 3da1d7b566d..f2526ec860c 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 aadacf99d59..d5125cca2a1 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 2934edc27d0..9539e7b9876 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 4cc5e55227f..b96ceec4a83 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 2faaa91fa9b..6e4764a4855 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 219dc165810..00000000000 --- 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 49ad375d676..283d4109f05 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 1332ce27521..e00e302b7ad 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,17 +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, IntegrationHooks, IntegrationMethodRegistration } 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 1433786634e..7af3542b6c5 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -11,7 +11,7 @@ import type { } from "@opencode-ai/sdk/v2/types" import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type IntegrationOAuthAuthorization = { readonly url: string @@ -56,8 +56,8 @@ export interface IntegrationDraft { } } -export interface IntegrationHooks extends IntegrationApi { - readonly transform: TransformHook +export interface IntegrationDomain extends IntegrationApi { + 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 66f57caf607..3b0b6eb9186 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 1216d3c0b84..085ae0de5e8 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 1ddb9e3489c..916b034a73b 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 03583302104..00000000000 --- 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 00000000000..00ee8b91255 --- /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 b01b81bc3bb..32daf0fb0a5 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 bb5931af161..db22b18b4f6 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 e91b93fbdfc..3e96cefc278 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 d9313838cb1..bfffdebb4e0 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 2934edc27d0..9539e7b9876 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 f6b0f649f88..e0a51f5e52b 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 d042259c477..2c675e28561 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 5e67e449613..00000000000 --- 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 5330f70c7c0..344f5d6f30a 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 4f5d5754eae..ae8e6d18a19 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -1,16 +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, IntegrationHooks, IntegrationMethodRegistration } 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 bd133e889ea..76e5a99eb5e 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,12 +1,12 @@ import type { IntegrationApi } from "@opencode-ai/client/promise/api" import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" import type { CredentialValue } from "@opencode-ai/sdk/v2/types" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { IntegrationDraft, IntegrationMethodRegistration } -export interface IntegrationHooks extends IntegrationApi { - readonly transform: TransformHook +export interface IntegrationDomain extends IntegrationApi { + 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 eb91b9df537..37bce3cc031 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 66bd5b4874b..05542b2baf7 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 76be0982b89..0537c1b98ee 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 b89b6e8abf8..00000000000 --- 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 00000000000..41bf598f46f --- /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 a1e62fc544d..cbc6459a339 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 00000000000..da4ee1b2008 --- /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/protocol/src/client.ts b/packages/protocol/src/client.ts index 1abf6667966..03f9a340157 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -60,25 +60,5 @@ export const groupNames = { "server.vcs": "vcs", } as const -export const endpointNames = { - "session.messages": "list", - "integration.connect.key": "connectKey", - "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 a21d3d86d25..d6d1482f301 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 b43e5bc6391..fd7cd9def51 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 d29749ab153..5f9c03935db 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 b8c387576f0..5b47146316d 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 309868189fa..a8fd1a3bc96 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 090df62eb69..e595716873a 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 33ece777c4d..56213ef4ccf 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 eb84cf500be..81bdfe8c981 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 81e37157c6a..ef32acb8211 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 23ed3fd044b..035f72568e4 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 34e44958073..e4f2f9b8139 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 9847a10574b..ff467c58e46 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 3f2efee68d1..56bf6fa3c50 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -9,21 +9,24 @@ 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" export { ProjectCopy } from "./project-copy.js" export { Provider } from "./provider.js" export { Reference } from "./reference.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 ae1e82656d0..d41f5f0c4a8 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 4a7ce15618f..a86ffe6d67a 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 fc6b64712f3..3539c87f770 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 00000000000..f21a390be82 --- /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 ab211a60f32..00000000000 --- 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 8e96321ce2a..9c2417e0537 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 a59c00b40e7..d1837ba08f9 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 62e1a1038d3..3d45e283e60 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 00000000000..bbbc3b0f9bb --- /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 1d5a5ffef04..fffb4b7b3d2 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 1826752d379..59ac25decf4 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 184266f2ada..69819eee26d 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 00000000000..375981b4159 --- /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 00000000000..6f08add0cc6 --- /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 1c2827f0d82..3932cae88a2 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 236e15b91fe..f5f310d8d7e 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 1a5cbe7fa73..971ac68f247 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 00000000000..75daa7fe1c0 --- /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 bcb06f53197..8ebe2307913 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 b0efa629097..9dfe7e2a146 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 4101e0751ff..20a4fcd803b 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 c1956cffe03..bd8f984d7fb 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 458f9c2d2ea..03d76cc5de5 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, @@ -398,8 +404,8 @@ import type { V2SessionLogResponses, V2SessionMessageErrors, V2SessionMessageResponses, - V2SessionMessagesErrors, - V2SessionMessagesResponses, + V2SessionMoveErrors, + V2SessionMoveResponses, V2SessionPermissionCreateErrors, V2SessionPermissionCreateResponses, V2SessionPermissionGetErrors, @@ -6095,6 +6101,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 * @@ -6239,6 +6284,7 @@ export class Session3 extends HeyApiClient { metadata?: { [key: string]: unknown } + resume?: boolean | null }, options?: Options, ) { @@ -6251,6 +6297,7 @@ export class Session3 extends HeyApiClient { { in: "body", key: "text" }, { in: "body", key: "description" }, { in: "body", key: "metadata" }, + { in: "body", key: "resume" }, ], }, ], @@ -6479,40 +6526,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 })) @@ -6539,6 +6552,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 @@ -6969,6 +7018,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 @@ -6991,6 +7068,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 { @@ -7418,6 +7500,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 @@ -7600,39 +7717,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 * @@ -7668,6 +7752,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 { @@ -8117,20 +8206,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 V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -8157,6 +8279,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 87aa5ddbe1c..0edaac8a7c1 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 = { @@ -3575,7 +3626,7 @@ export type SyncEventSessionCreated = { aggregateID: string data: { sessionID: string - info: Session + info: SessionV1Info } } } @@ -3590,7 +3641,7 @@ export type SyncEventSessionUpdated = { aggregateID: string data: { sessionID: string - info: Session + info: SessionV1Info } } } @@ -3882,6 +3933,7 @@ export type SyncEventSessionSkillActivated = { aggregateID: string data: { sessionID: string + id: string name: string text: string } @@ -3954,16 +4006,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 } @@ -3982,6 +4026,8 @@ export type SyncEventSessionStepFailed = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } } @@ -4145,7 +4191,6 @@ export type SyncEventSessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -4217,6 +4262,8 @@ export type SyncEventSessionCompactionStarted = { data: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } } @@ -4248,6 +4295,9 @@ export type SyncEventSessionCompactionFailed = { aggregateID: string data: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } } @@ -4262,7 +4312,7 @@ export type SyncEventSessionRevertStaged = { aggregateID: string data: { sessionID: string - revert: RevertState + revert: SessionRevert } } } @@ -4361,8 +4411,9 @@ export type PermissionV2Rule = { export type PermissionV2Ruleset = Array -export type AgentV2Info = { +export type AgentInfo = { id: string + name: string model?: ModelRef request: ProviderRequest system?: string @@ -4378,7 +4429,7 @@ export type PluginInfo = { id: string } -export type SessionV2Info = { +export type SessionInfo = { id: string parentID?: string fork?: { @@ -4388,16 +4439,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 @@ -4406,7 +4449,7 @@ export type SessionV2Info = { title: string location: LocationRef subpath?: string - revert?: RevertState + revert?: SessionRevert } export type PromptInputFileAttachment = { @@ -4482,7 +4525,6 @@ export type SessionMessageSynthetic = { time: { created: number } - sessionID: string text: string description?: string type: "synthetic" @@ -4509,6 +4551,7 @@ export type SessionMessageSkill = { created: number } type: "skill" + skill: string name: string text: string } @@ -4523,7 +4566,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 @@ -4547,8 +4593,8 @@ export type SessionMessageAssistantReasoning = { } } -export type SessionMessageToolStatePending = { - status: "pending" +export type SessionMessageToolStateStreaming = { + status: "streaming" input: string } @@ -4568,9 +4614,7 @@ export type SessionMessageToolStateCompleted = { input: { [key: string]: unknown } - attachments?: Array content: Array - outputPaths?: Array structured: { [key: string]: unknown } @@ -4598,7 +4642,7 @@ export type SessionMessageAssistantTool = { providerState?: SessionMessageProviderState providerResultState?: SessionMessageProviderState state: - | SessionMessageToolStatePending + | SessionMessageToolStateStreaming | SessionMessageToolStateRunning | SessionMessageToolStateCompleted | SessionMessageToolStateError @@ -4606,7 +4650,6 @@ export type SessionMessageAssistantTool = { created: number ran?: number completed?: number - pruned?: number } } @@ -4635,26 +4678,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 @@ -4662,9 +4693,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 @@ -4692,7 +4761,7 @@ export type SessionAgentSelected = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4711,7 +4780,7 @@ export type SessionModelSelected = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4730,7 +4799,7 @@ export type SessionMoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4750,7 +4819,7 @@ export type SessionRenamed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4769,7 +4838,7 @@ export type SessionDeleted = { durable: { aggregateID: string seq: number - version: number + version: 2 } location?: LocationRef data: { @@ -4787,7 +4856,7 @@ export type SessionForked = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4807,7 +4876,7 @@ export type SessionPromptPromoted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4826,7 +4895,7 @@ export type SessionPromptAdmitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4847,7 +4916,7 @@ export type SessionExecutionStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4865,7 +4934,7 @@ export type SessionExecutionSucceeded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4883,7 +4952,7 @@ export type SessionExecutionFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4902,7 +4971,7 @@ export type SessionExecutionInterrupted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4921,7 +4990,7 @@ export type SessionInstructionsUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4940,7 +5009,7 @@ export type SessionSynthetic = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4963,11 +5032,12 @@ export type SessionSkillActivated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string + id: string name: string text: string } @@ -4983,7 +5053,7 @@ export type SessionShellStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5002,7 +5072,7 @@ export type SessionShellEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5027,7 +5097,7 @@ export type SessionStepStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5049,23 +5119,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 } @@ -5081,13 +5143,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 } } @@ -5101,7 +5165,7 @@ export type SessionTextStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5121,7 +5185,7 @@ export type SessionTextEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5142,7 +5206,7 @@ export type SessionReasoningStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5163,7 +5227,7 @@ export type SessionReasoningEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5185,7 +5249,7 @@ export type SessionToolInputStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5206,7 +5270,7 @@ export type SessionToolInputEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5227,7 +5291,7 @@ export type SessionToolCalled = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5252,7 +5316,7 @@ export type SessionToolProgress = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5276,7 +5340,7 @@ export type SessionToolSuccess = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5287,7 +5351,6 @@ export type SessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -5304,7 +5367,7 @@ export type SessionToolFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5328,7 +5391,7 @@ export type SessionRetryScheduled = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5350,7 +5413,7 @@ export type SessionCompactionAdmitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5369,12 +5432,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 } } @@ -5388,7 +5453,7 @@ export type SessionCompactionEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5409,11 +5474,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 } } @@ -5427,12 +5495,12 @@ export type SessionRevertStaged = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - revert: RevertState + revert: SessionRevert } } @@ -5446,7 +5514,7 @@ export type SessionRevertCleared = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5464,7 +5532,7 @@ export type SessionRevertCommitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5473,6 +5541,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 @@ -5498,20 +5606,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 @@ -5702,6 +5812,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 @@ -5756,7 +5879,7 @@ export type FileSystemEntry = { type: "file" | "directory" } -export type CommandV2Info = { +export type CommandInfo = { name: string template: string description?: string @@ -5765,7 +5888,8 @@ export type CommandV2Info = { subtask?: boolean } -export type SkillV2Info = { +export type SkillInfo = { + id: string name: string description?: string slash?: boolean @@ -5849,12 +5973,12 @@ export type SessionCreated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - info: Session + info: SessionV1Info } } @@ -5868,12 +5992,12 @@ export type SessionUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - info: Session + info: SessionV1Info } } @@ -5887,7 +6011,7 @@ export type MessageUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5906,7 +6030,7 @@ export type MessageRemoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5925,7 +6049,7 @@ export type MessagePartUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5945,7 +6069,7 @@ export type MessagePartRemoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5955,6 +6079,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 @@ -6044,7 +6183,7 @@ export type SessionDiff = { location?: LocationRef data: { sessionID: string - diff: Array + diff: Array } } @@ -6608,6 +6747,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 @@ -6914,7 +7066,7 @@ export type EventSessionCreated = { type: "session.created" properties: { sessionID: string - info: Session + info: SessionV1Info } } @@ -6923,7 +7075,7 @@ export type EventSessionUpdated = { type: "session.updated" properties: { sessionID: string - info: Session + info: SessionV1Info } } @@ -7010,6 +7162,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" @@ -7101,6 +7263,7 @@ export type EventSessionSkillActivated = { type: "session.skill.activated" properties: { sessionID: string + id: string name: string text: string } @@ -7149,16 +7312,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 } @@ -7171,6 +7326,8 @@ export type EventSessionStepFailed = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: MoneyUsd + tokens?: TokenUsageInfo } } @@ -7313,7 +7470,6 @@ export type EventSessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -7361,6 +7517,8 @@ export type EventSessionCompactionStarted = { properties: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } @@ -7389,6 +7547,9 @@ export type EventSessionCompactionFailed = { type: "session.compaction.failed" properties: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } @@ -7397,7 +7558,7 @@ export type EventSessionRevertStaged = { type: "session.revert.staged" properties: { sessionID: string - revert: RevertState + revert: SessionRevert } } @@ -7435,7 +7596,7 @@ export type EventSessionDiff = { type: "session.diff" properties: { sessionID: string - diff: Array + diff: Array } } @@ -7760,6 +7921,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" @@ -7945,19 +8114,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 = { @@ -7976,7 +8145,7 @@ export type InvalidRequestErrorV2 = { } export type SessionsResponseV2 = { - data: Array + data: Array cursor: { previous?: string | null next?: string | null @@ -8016,77 +8185,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" @@ -8108,7 +8224,7 @@ export type UserMessageV2 = { summary?: { title?: string | null body?: string | null - diffs: Array + diffs: Array } | null agent: string model: { @@ -8525,6 +8641,7 @@ export type V2EventV2 = | SessionModelSelectedV2 | SessionMovedV2 | SessionRenamedV2 + | SessionUsageUpdatedV2 | SessionDeletedV2 | SessionForkedV2 | SessionPromptPromotedV2 @@ -8597,6 +8714,7 @@ export type V2EventV2 = | InstallationUpdateAvailableV2 | VcsBranchUpdatedV2 | McpStatusChangedV2 + | McpResourcesChangedV2 | PermissionAskedV2 | PermissionRepliedV2 | QuestionAskedV2 @@ -8624,8 +8742,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 @@ -8642,23 +8761,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?: { @@ -8668,16 +8786,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 @@ -8686,7 +8796,7 @@ export type SessionV2InfoV2 = { title: string location: LocationRefV2 subpath?: string - revert?: RevertStateV2 + revert?: SessionRevertV2 } export type PromptBase64V2 = string @@ -8757,7 +8867,6 @@ export type SessionMessageSyntheticV2 = { time: { created: number } - sessionID: string text: string description?: string type: "synthetic" @@ -8784,6 +8893,7 @@ export type SessionMessageSkillV2 = { created: number } type: "skill" + skill: string name: string text: string } @@ -8798,7 +8908,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 @@ -8832,26 +8945,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 @@ -8859,6 +8960,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 } /** @@ -8876,7 +9010,7 @@ export type SessionAgentSelectedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -8895,7 +9029,7 @@ export type SessionModelSelectedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -8914,7 +9048,7 @@ export type SessionMovedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -8934,7 +9068,7 @@ export type SessionRenamedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -8953,7 +9087,7 @@ export type SessionDeletedV2 = { durable: { aggregateID: string seq: number - version: number + version: 2 } location?: LocationRefV2 data: { @@ -8971,7 +9105,7 @@ export type SessionForkedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -8991,7 +9125,7 @@ export type SessionPromptPromotedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9010,7 +9144,7 @@ export type SessionPromptAdmittedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9031,7 +9165,7 @@ export type SessionExecutionStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9049,7 +9183,7 @@ export type SessionExecutionSucceededV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9067,7 +9201,7 @@ export type SessionExecutionFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9086,7 +9220,7 @@ export type SessionExecutionInterruptedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9105,7 +9239,7 @@ export type SessionInstructionsUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9124,7 +9258,7 @@ export type SessionSyntheticV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9147,11 +9281,12 @@ export type SessionSkillActivatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string + id: string name: string text: string } @@ -9167,7 +9302,7 @@ export type SessionShellStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9186,7 +9321,7 @@ export type SessionShellEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9211,7 +9346,7 @@ export type SessionStepStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9233,23 +9368,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 } @@ -9265,13 +9392,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 } } @@ -9285,7 +9414,7 @@ export type SessionTextStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9305,7 +9434,7 @@ export type SessionTextEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9330,7 +9459,7 @@ export type SessionReasoningStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9355,7 +9484,7 @@ export type SessionReasoningEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9377,7 +9506,7 @@ export type SessionToolInputStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9398,7 +9527,7 @@ export type SessionToolInputEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9423,7 +9552,7 @@ export type SessionToolCalledV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9448,7 +9577,7 @@ export type SessionToolProgressV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9476,7 +9605,7 @@ export type SessionToolSuccessV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9487,7 +9616,6 @@ export type SessionToolSuccessV2 = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState6 @@ -9508,7 +9636,7 @@ export type SessionToolFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9532,7 +9660,7 @@ export type SessionRetryScheduledV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9554,7 +9682,7 @@ export type SessionCompactionAdmittedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9573,12 +9701,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 } } @@ -9592,7 +9722,7 @@ export type SessionCompactionEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9613,11 +9743,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 } } @@ -9631,12 +9764,12 @@ export type SessionRevertStagedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - revert: RevertStateV2 + revert: SessionRevertV2 } } @@ -9650,7 +9783,7 @@ export type SessionRevertClearedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9668,7 +9801,7 @@ export type SessionRevertCommittedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9686,6 +9819,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 @@ -9861,6 +10007,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 @@ -9871,12 +10070,12 @@ export type SessionCreatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - info: SessionV2 + info: SessionV1InfoV2 } } @@ -9890,12 +10089,12 @@ export type SessionUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - info: SessionV2 + info: SessionV1InfoV2 } } @@ -9909,12 +10108,12 @@ export type SessionDeleted1 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - info: SessionV2 + info: SessionV1InfoV2 } } @@ -9928,7 +10127,7 @@ export type MessageUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9947,7 +10146,7 @@ export type MessageRemovedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9966,7 +10165,7 @@ export type MessagePartUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9986,7 +10185,7 @@ export type MessagePartRemovedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9996,6 +10195,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 @@ -10615,6 +10829,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 @@ -13414,7 +13641,7 @@ export type SessionDiffResponses = { /** * Successfully retrieved diff */ - 200: Array + 200: Array } export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] @@ -14992,7 +15219,7 @@ export type V2AgentListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -15110,7 +15337,7 @@ export type V2SessionCreateResponses = { * Success */ 200: { - data: SessionV2InfoV2 + data: SessionInfoV2 } } @@ -15215,7 +15442,7 @@ export type V2SessionGetResponses = { * Success */ 200: { - data: SessionV2InfoV2 + data: SessionInfoV2 } } @@ -15254,7 +15481,7 @@ export type V2SessionForkResponses = { * Success */ 200: { - data: SessionV2InfoV2 + data: SessionInfoV2 } } @@ -15371,6 +15598,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 @@ -15518,6 +15785,7 @@ export type V2SessionSyntheticData = { metadata?: { [key: string]: unknown } + resume?: boolean | null } path: { sessionID: string @@ -15714,7 +15982,7 @@ export type V2SessionRevertStageResponses = { * Success */ 200: { - data: RevertStateV2 + data: SessionRevertV2 } } @@ -15837,7 +16105,7 @@ export type V2SessionContextResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -16104,13 +16372,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 @@ -16129,7 +16397,7 @@ export type V2SessionMessagesData = { url: "/api/session/{sessionID}/message" } -export type V2SessionMessagesErrors = { +export type V2MessageListErrors = { /** * InvalidCursorError | InvalidRequestError */ @@ -16148,16 +16416,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 @@ -16194,7 +16462,7 @@ export type V2ModelListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -16235,7 +16503,7 @@ export type V2ModelDefaultResponses = { */ 200: { location: LocationInfoV2 - data: ModelV2Info | null + data: ModelInfo | null } } @@ -16685,6 +16953,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: { @@ -17539,7 +17844,7 @@ export type V2CommandListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -17576,7 +17881,7 @@ export type V2SkillListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -18543,20 +18848,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 */ @@ -18567,16 +18877,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 PtyConnectData = { body?: never diff --git a/packages/server/src/handlers/debug.ts b/packages/server/src/handlers/debug.ts index f5ffd536e4d..ec797c00321 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 4d702f24c9f..8f423534d46 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 93734c628d8..40e2ba1077f 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 ce3bc15f364..e3160cc2310 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 de6f4b328f5..971d77734b6 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 0ddeef56bf7..7698f487a9d 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" @@ -38,9 +39,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, @@ -56,26 +59,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, [ @@ -86,17 +85,25 @@ 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), - Layer.merge(ServerObservability.httpTracingDisabled), + 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), + Layer.merge(ServerObservability.httpTracingDisabled), + HttpRouter.provideRequest(requestServices), + Layer.provideMerge(services), + Layer.provideMerge(HttpRouter.layer), + ) + }), ) } @@ -110,5 +117,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 0e5dec34b24..f147cd73882 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 48e8eee3108..6fccfc305de 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 8db21f025b7..ec94af2f2d0 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 4ebf6459b15..cda95c0e8e7 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 dd600758122..90348b2a95a 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 35720a27531..aaedded025f 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 999ff510d5f..d505249931f 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 3e7bd830519..c05fe3a7454 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 29613ff28ac..624cf32ef7e 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -10,12 +10,15 @@ "./backend/*": "./src/backend/*.ts", "./frontend": "./src/frontend/simulation.ts", "./frontend/*": "./src/frontend/*.ts", - "./protocol": "./src/protocol/index.ts" + "./protocol": "./src/protocol/index.ts", + "./recording": "./src/recording.ts" }, "scripts": { "typecheck": "tsgo --noEmit" }, "dependencies": { + "@fontsource/adwaita-mono": "5.2.1", + "@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 a27a0cd2afe..eaacc162c1d 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,7 +18,6 @@ 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 }> @@ -58,8 +56,6 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.Backend } case "llm.pending": return { exchanges: SimulationLLMExchange.pending() } - case "network.log": - return { entries: SimulationNetwork.log() } } } diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts index 97b7b71b1ff..638f80c1d01 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 c13e7772ef7..fb58a44724a 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 9e5b7bdead5..5819d940357 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,8 +1,11 @@ +import { mkdir } from "node:fs/promises" +import { tmpdir } from "node:os" +import { extname, join, resolve } from "node:path" import type { 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 @@ -91,57 +94,57 @@ 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, name?: string) { + await harness.renderOnce() + const image = SimulationPng.screenshot(harness.renderer) + const filename = name ?? `screenshot-${crypto.randomUUID()}` + if ( + !filename || + filename.includes("/") || + filename.includes("\\") || + extname(filename) + ) + throw new Error("screenshot name must not contain a path or extension") + const directory = resolve( + process.env.OPENCODE_DRIVE_MEDIA_DIR ?? + join(tmpdir(), "opencode-drive", "output"), + ) + await mkdir(directory, { recursive: true }) + const path = join(directory, `${filename}.png`) + await Bun.write(path, image.data) + 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": - all(harness.renderer.root).find((item) => item.num === action.target)?.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 00000000000..4f7b11c1a04 --- /dev/null +++ b/packages/simulation/src/frontend/png.ts @@ -0,0 +1,91 @@ +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 Mono" + +for (const file of [ + "adwaita-mono-latin-400-normal.woff2", + "adwaita-mono-latin-700-normal.woff2", + "adwaita-mono-latin-400-italic.woff2", + "adwaita-mono-latin-700-italic.woff2", +]) { + GlobalFonts.registerFromPath( + fileURLToPath(import.meta.resolve(`@fontsource/adwaita-mono/files/${file}`)), + 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 e973d57a98d..43947d9ea6b 100644 --- a/packages/simulation/src/frontend/renderer.ts +++ b/packages/simulation/src/frontend/renderer.ts @@ -1,21 +1,43 @@ import type { CliRenderer, CliRendererConfig } from "@opentui/core" import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" +import { Timeline } from "../recording" const setups = new WeakMap() +const recordings = new WeakMap() /** - * 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. + * Creates a headless renderer with optional recording: a real CliRenderer + * backed by an in-memory screen buffer. The TestRendererSetup is kept + * module-side so the harness can use supported testing APIs without app + * code carrying it around. */ -export async function create(options: CliRendererConfig): Promise { +export async function create(options: CliRendererConfig, path?: string): Promise { + if (!path) { + const setup = await createTestRenderer({ + ...options, + width: 100, + height: 40, + }) + setups.set(setup.renderer, setup) + return setup.renderer + } + const recording = await Timeline.create(path, 100, 40) const setup = await createTestRenderer({ ...options, width: 100, height: 40, + stdout: recording as unknown as NodeJS.WriteStream, + bufferedOutput: "stdout", + onDestroy: () => { + void recording.finish().catch((error) => process.stderr.write(`Failed to finish UI recording: ${error}\n`)) + options.onDestroy?.() + }, + }).catch(async (error) => { + await recording.finish().catch(() => undefined) + throw error }) setups.set(setup.renderer, setup) + recordings.set(setup.renderer, recording) return setup.renderer } @@ -23,4 +45,10 @@ export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined { return setups.get(renderer) } +export function finish(renderer: CliRenderer) { + const recording = recordings.get(renderer) + if (!recording) throw new Error("UI recording is not available") + return recording.finish() +} + export * as SimulationRenderer from "./renderer" diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index c717dbd4c6b..b878bdd761f 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,6 +1,5 @@ import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" -import { SimulationTrace } from "./trace" export interface Server { readonly url: string @@ -11,27 +10,45 @@ function parseRequest(input: string | Buffer) { return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(harness: Harness, request: SimulationProtocol.Frontend.Request, headless: boolean) { +async function handle( + harness: Harness, + request: SimulationProtocol.Frontend.Request, + finishRecording?: () => Promise, +) { switch (request.method) { + case "ui.screenshot": + return SimulationActions.screenshot(harness, request.params?.name) case "ui.state": { - if (headless) await harness.renderOnce() - 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, request.params.action) - case "trace.list": - return { records: SimulationTrace.list() } - case "trace.clear": - SimulationTrace.clear() - return { cleared: true } - case "trace.export": - return SimulationTrace.exportTrace() + case "ui.recording.finish": + if (!finishRecording) throw new Error("UI recording is not available") + return finishRecording() + 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, + }) } } -export function start(harness: Harness, endpoint: string, headless: boolean): Server { +export function start(harness: Harness, endpoint: string, finishRecording?: () => Promise): Server { const url = new URL(endpoint) const server = Bun.serve<{ readonly drive: true }>({ hostname: url.hostname, @@ -41,17 +58,11 @@ export function start(harness: Harness, endpoint: string, headless: boolean): Se 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.Frontend.Request | undefined try { request = parseRequest(message) - const result = await handle(harness, request, headless) + const result = await handle(harness, request, finishRecording) const next = SimulationProtocol.JsonRpc.success(request.id, result) if (next) socket.send(JSON.stringify(next)) } catch (error) { @@ -60,11 +71,9 @@ export function start(harness: Harness, endpoint: string, headless: boolean): Se }, }, }) - SimulationTrace.add("control.start", { url: endpoint }) return { url: endpoint, stop: () => { - SimulationTrace.add("control.stop", { url: endpoint }) server.stop(true) }, } diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index c2a56ad8c1c..2a08d2dbef7 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -14,11 +14,14 @@ import { SimulationServer } from "./server" */ export async function create(options: CliRendererConfig): Promise { const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless" - const renderer = headless ? await SimulationRenderer.create(options) : await createCliRenderer(options) + const manifest = DriveManifest.resolve() + const renderer = headless + ? await SimulationRenderer.create(options, manifest.recording?.timeline) + : await createCliRenderer(options) const server = SimulationServer.start( SimulationActions.createHarness(renderer), - DriveManifest.resolve().endpoints.ui, - headless, + manifest.endpoints.ui, + headless && manifest.recording ? () => SimulationRenderer.finish(renderer) : undefined, ) 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 b0914faace5..00000000000 --- 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/manifest.ts b/packages/simulation/src/manifest.ts index 0ef4530e486..5544d23b70e 100644 --- a/packages/simulation/src/manifest.ts +++ b/packages/simulation/src/manifest.ts @@ -1,12 +1,15 @@ import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" -import { join } from "node:path" +import { isAbsolute, join } from "node:path" export interface Manifest { readonly endpoints: { readonly ui: string readonly backend: string } + readonly recording?: { + readonly timeline: string + } } export const defaults: Manifest = { @@ -32,18 +35,16 @@ export function resolve() { if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`) validateEndpoint(manifest.endpoints.ui, "ui") validateEndpoint(manifest.endpoints.backend, "backend") + if (manifest.recording && !isAbsolute(manifest.recording.timeline)) { + throw new Error(`Invalid drive recording timeline path: ${manifest.recording.timeline}`) + } return manifest } function isManifest(value: unknown): value is Manifest { - if (typeof value !== "object" || value === null) return false - if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null) return false - return ( - "ui" in value.endpoints && - typeof value.endpoints.ui === "string" && - "backend" in value.endpoints && - typeof value.endpoints.backend === "string" - ) + if (typeof value !== "object" || value === null || !("endpoints" in value)) return false + if (typeof value.endpoints !== "object" || value.endpoints === null) return false + return "ui" in value.endpoints && "backend" in value.endpoints } function validateEndpoint(value: string, name: string) { diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index 2534db8367c..a2b9fa3c29b 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -59,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 @@ -83,46 +83,69 @@ 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 Screenshot = Schema.String + export type Screenshot = Schema.Schema.Type + + export const RecordingFinish = Schema.String + export type RecordingFinish = Schema.Schema.Type + + export const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) }) + export interface ScreenshotParams extends 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.action"), params: ActionParams }), + 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.state", "trace.list", "trace.clear", "trace.export"]), + method: Schema.Literal("ui.screenshot"), + params: Schema.optional(ScreenshotParams), + }), + Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literals(["ui.enter", "ui.state", "ui.recording.finish"]), }), ]) export type Request = Schema.Schema.Type export const decodeRequest = Schema.decodeUnknownSync(Request) - - 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 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 @@ -148,7 +171,7 @@ export namespace Backend { Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }), Schema.Struct({ ...JsonRpc.RequestFields, - method: Schema.Literals(["llm.attach", "llm.pending", "network.log"]), + method: Schema.Literals(["llm.attach", "llm.pending"]), }), ]) export type Request = Schema.Schema.Type @@ -164,7 +187,6 @@ export namespace Backend { matched: Schema.Boolean, }) export interface NetworkLogEntry extends Schema.Schema.Type {} - } export * as SimulationProtocol from "./index" diff --git a/packages/simulation/src/recording.ts b/packages/simulation/src/recording.ts new file mode 100644 index 00000000000..6d0b3a6bd59 --- /dev/null +++ b/packages/simulation/src/recording.ts @@ -0,0 +1,115 @@ +import { createWriteStream, type WriteStream } from "node:fs" +import { mkdir } from "node:fs/promises" +import { dirname } from "node:path" +import { Writable } from "node:stream" +import { finished } from "node:stream/promises" +import { Schema } from "effect" + +export const Header = Schema.Struct({ + type: Schema.Literal("header"), + version: Schema.Literal(1), + cols: Schema.Number, + rows: Schema.Number, + encoding: Schema.Literal("base64"), +}) +export interface Header extends Schema.Schema.Type {} + +export const Output = Schema.Struct({ + type: Schema.Literal("output"), + at_ms: Schema.Number, + data: Schema.String, +}) +export interface Output extends Schema.Schema.Type {} + +export const Event = Schema.Union([Header, Output]) +export type Event = Schema.Schema.Type + +export class Timeline extends Writable { + readonly isTTY = true + readonly path: string + readonly columns: number + readonly rows: number + private readonly output: WriteStream + private readonly started = performance.now() + private readonly timestamps: number[] = [] + private done?: Promise + + private constructor(path: string, cols: number, rows: number, output: WriteStream) { + super() + this.path = path + this.columns = cols + this.rows = rows + this.output = output + // finish() reports stream failures; keep Writable from also throwing them process-wide. + this.on("error", () => {}) + output.on("error", (error) => this.destroy(error)) + } + + static async create(path: string, cols: number, rows: number) { + await mkdir(dirname(path), { recursive: true }) + const output = createWriteStream(path) + const timeline = new Timeline(path, cols, rows, output) + await new Promise((resolve, reject) => { + output.write( + `${JSON.stringify({ type: "header", version: 1, cols, rows, encoding: "base64" } satisfies Header)}\n`, + (error) => (error ? reject(error) : resolve()), + ) + }) + return timeline + } + + getColorDepth() { + return 24 + } + + override write(chunk: unknown, callback?: (error?: Error | null) => void): boolean + override write(chunk: unknown, encoding: BufferEncoding, callback?: (error?: Error | null) => void): boolean + override write( + chunk: unknown, + encoding?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ) { + if (!this.writableEnded) { + this.timestamps.push(this.elapsed()) + if (typeof encoding === "function") return super.write(chunk, encoding) + if (encoding === undefined) return super.write(chunk, callback) + return super.write(chunk, encoding, callback) + } + const done = typeof encoding === "function" ? encoding : callback + queueMicrotask(() => done?.(null)) + return true + } + + override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) { + this.writeOutput(chunk, this.timestamps.shift() ?? this.elapsed(), callback) + } + + override _final(callback: (error?: Error | null) => void) { + this.writeOutput(Buffer.alloc(0), this.elapsed(), (error) => { + if (error) return callback(error) + this.output.end(callback) + }) + } + + finish() { + if (this.done) return this.done + this.end() + this.done = finished(this).then(() => this.path) + return this.done + } + + private elapsed() { + return Math.max(0, Math.round(performance.now() - this.started)) + } + + private writeOutput(data: Buffer, at_ms: number, callback: (error?: Error | null) => void) { + const event = { + type: "output", + at_ms, + data: data.toString("base64"), + } satisfies Output + this.output.write(`${JSON.stringify(event)}\n`, callback) + } +} + +export * as SimulationRecording from "./recording" diff --git a/packages/simulation/test/recording.test.ts b/packages/simulation/test/recording.test.ts new file mode 100644 index 00000000000..d87453bb79a --- /dev/null +++ b/packages/simulation/test/recording.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { SimulationRenderer } from "../src/frontend/renderer" +import { Timeline, type Event } from "../src/recording" + +test("streams ANSI chunks into a versioned JSONL timeline", async () => { + const directory = await mkdtemp(join(tmpdir(), "simulation-recording-")) + const path = join(directory, "nested", "timeline.jsonl") + + try { + const timeline = await Timeline.create(path, 80, 24) + await new Promise((resolve, reject) => { + timeline.write(Buffer.from("\u001b[2Jhello"), (error) => (error ? reject(error) : resolve())) + }) + expect(await timeline.finish()).toBe(path) + await new Promise((resolve) => timeline.write(Buffer.from("ignored"), () => resolve())) + + const events = (await Bun.file(path).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Event) + expect(events[0]).toEqual({ type: "header", version: 1, cols: 80, rows: 24, encoding: "base64" }) + expect(events[1]?.type).toBe("output") + if (events[1]?.type !== "output") throw new Error("Missing output event") + expect(Buffer.from(events[1].data, "base64").toString()).toBe("\u001b[2Jhello") + expect(events[1].at_ms).toBeGreaterThanOrEqual(0) + expect(events.at(-1)).toMatchObject({ type: "output", data: "" }) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test("captures native renderer output and finishes on destroy", async () => { + const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-")) + const path = join(directory, "timeline.jsonl") + const renderer = await SimulationRenderer.create({}, path) + + try { + await SimulationRenderer.setupFor(renderer)?.renderOnce() + renderer.destroy() + expect(await SimulationRenderer.finish(renderer)).toBe(path) + + const events = (await Bun.file(path).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Event) + expect(events.some((event) => event.type === "output")).toBe(true) + } finally { + if (!renderer.isDestroyed) renderer.destroy() + await SimulationRenderer.finish(renderer) + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 8d924c9e9b5..cbb398a55d3 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1146,11 +1146,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi return render({ params: route.data.data }) }) - // Suppress the full-screen reconnecting overlay for transient disconnects (initial startup, host - // reload, sub-second event-stream blips). After the first successful connect, show it only once the - // connection has been lost for a full second; before the first connect give a longer grace period so - // startup never flashes it, but a server that dies before ever connecting still surfaces instead of - // leaving a silent empty app. Hide it immediately the moment status leaves "connecting". + // Suppress the full-screen overlay for transient startup and event-stream retry states. + // Initial connection gets a longer grace period; retries surface more quickly. const [showReconnecting, setShowReconnecting] = createSignal(false) let reconnectTimer: ReturnType | undefined createEffect(() => { @@ -1158,7 +1155,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi clearTimeout(reconnectTimer) reconnectTimer = undefined } - if (sdk.connection.status() !== "connecting") { + const status = sdk.connection.status() + if (status === "connected") { setShowReconnecting(false) return } @@ -1167,7 +1165,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi reconnectTimer = undefined setShowReconnecting(true) }, - sdk.connection.connectedOnce() ? 1000 : 5000, + status === "reconnecting" ? 1000 : 5000, ).unref() }) onCleanup(() => { diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 52aba6d0e3c..1b962142fc9 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -180,7 +180,7 @@ function KeyMethod(props: { onConfirm={(key) => { if (!key) return void sdk.api.integration - .connectKey({ + .connect.key({ integrationID: props.integration.id, location: location(data), key, @@ -219,7 +219,7 @@ function OAuthStarting(props: { onMount(() => { void sdk.api.integration - .connectOauth({ + .connect.oauth({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -288,7 +288,7 @@ function OAuthAuto(props: { const poll = () => { void sdk.api.integration - .attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) }) + .attempt.status({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -314,7 +314,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 ( @@ -344,7 +344,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 ( @@ -354,7 +354,7 @@ function OAuthCode(props: { onConfirm={(code) => { if (!code) return void sdk.api.integration - .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) + .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 405f2e06937..e0beff09edc 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 00000000000..7ac357e1c89 --- /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 72de26d2780..025c0968b8b 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 61c173a3842..bb54dd87133 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 65bff40838d..1b86dbe5822 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 7aac079b03b..0fcbfa96d58 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 9fdd5c9d6a1..11393850345 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 7acb093a7b5..0f97f8277d5 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 723f80633dd..8ac669492a0 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,24 +1,29 @@ +// 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, 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" @@ -33,30 +38,28 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") export type FormInfo = FormFormInfo | FormUrlInfo type LocationData = { - agent?: AgentV2Info[] - command?: CommandV2Info[] + agent?: AgentInfo[] + command?: CommandInfo[] integration?: IntegrationInfo[] mcp?: McpServer[] - model?: ModelV2Info[] + model?: ModelInfo[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] // 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. family: Record status: Record - compaction: Partial> - compactionReason: Partial> - message: Record + message: Record input: Record permission: Record // Pending forms keyed by session ID. @@ -92,8 +95,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ info: {}, family: {}, status: {}, - compaction: {}, - compactionReason: {}, message: {}, input: {}, permission: {}, @@ -110,17 +111,14 @@ 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", @@ -129,28 +127,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ) }, - 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) { @@ -228,7 +224,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ produce((draft) => { delete draft.info[sessionID] delete draft.status[sessionID] - delete draft.compaction[sessionID] delete draft.message[sessionID] delete draft.input[sessionID] delete draft.permission[sessionID] @@ -250,6 +245,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), @@ -304,6 +306,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) @@ -349,6 +357,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: messageIDFromEvent(event.id), type: "system", text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }) }) @@ -358,9 +367,9 @@ 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, + metadata: event.data.metadata, time: { created: event.created }, }) }) @@ -370,7 +379,11 @@ 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, + metadata: event.metadata, time: { created: event.created }, }) }) @@ -379,7 +392,8 @@ 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.status = event.data.shell.status + match.exit = event.data.shell.exit match.output = event.data.output match.time.completed = event.created }) @@ -408,13 +422,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ type: "assistant", agent: event.data.agent, model: event.data.model, + metadata: event.metadata, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, time: { created: event.created }, }) }) 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 @@ -426,6 +441,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) @@ -434,6 +450,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": @@ -463,7 +483,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 @@ -473,7 +493,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": @@ -482,7 +502,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": @@ -534,7 +554,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, @@ -589,36 +609,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setSessionStatus(event.data.sessionID, "running") break case "session.compaction.admitted": + break + case "session.compaction.started": message.update(event.data.sessionID, (draft, index) => { - if (message.compaction(draft)) return message.append(draft, index, { - id: event.data.inputID, + id: event.data.inputID ?? messageIDFromEvent(event.id), type: "compaction", - status: "queued", - reason: "manual", + status: "running", + reason: event.data.reason, summary: "", - recent: "", + recent: event.data.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" - }) - break case "session.execution.succeeded": case "session.execution.failed": case "session.execution.interrupted": setSessionStatus(event.data.sessionID, "idle") - if (store.session.compaction[event.data.sessionID] !== undefined) - setStore("session", "compaction", event.data.sessionID, undefined) - if (store.session.compactionReason[event.data.sessionID] !== undefined) - setStore("session", "compactionReason", event.data.sessionID, undefined) message.update(event.data.sessionID, (draft) => { const currentAssistant = message.activeAssistant(draft) if (currentAssistant) currentAssistant.retry = undefined @@ -633,8 +641,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", @@ -648,23 +657,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?.status === "running") 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 - if (current) { - current.status = "completed" - current.reason = event.data.reason - current.summary = event.data.text - current.recent = event.data.recent + const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") + const current = draft[position] + if (current?.type === "compaction") { + draft[position] = { + id: current.id, + type: "compaction", + status: "completed", + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + metadata: current.metadata, + time: current.time, + } return } message.append(draft, index, { @@ -679,11 +691,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.failed": - setStore("session", "compaction", event.data.sessionID, undefined) - setStore("session", "compactionReason", event.data.sessionID, undefined) - message.update(event.data.sessionID, (draft) => { - const current = message.compaction(draft) - if (current) current.status = "failed" + message.update(event.data.sessionID, (draft, index) => { + const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") + const current = draft[position] + const failed: Extract = { + id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id), + type: "compaction", + status: "failed", + reason: event.data.reason ?? "manual", + error: event.data.error ?? { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + metadata: current?.type === "compaction" ? current.metadata : event.metadata, + time: current?.type === "compaction" ? current.time : { created: event.created }, + } + if (current?.type === "compaction") { + draft[position] = failed + return + } + message.append(draft, index, failed) }) break case "permission.v2.asked": @@ -763,20 +790,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const result = { on: sdk.event.on, listen: sdk.event.listen, - connection: { - status() { - return sdk.connection.status() - }, - attempt() { - return sdk.connection.attempt() - }, - error() { - return sdk.connection.error() - }, - connectedOnce() { - return sdk.connection.connectedOnce() - }, - }, session: { list() { return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated) @@ -801,9 +814,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.input[sessionID]?.includes(inputID) ?? false }, }, - compaction(sessionID: string) { - return store.session.compaction[sessionID] - }, async refresh(sessionID: string) { setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) registerSession(sessionID) @@ -821,31 +831,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return position === undefined ? undefined : messages?.[position] }, async refresh(sessionID: string) { - const live = [...(store.session.message[sessionID] ?? [])] setStore("session", "message", sessionID, []) messageIndex.set(sessionID, new Map()) - const loaded = mutable( + const messages = 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") - setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined) - setStore( - "session", - "compactionReason", - sessionID, - running?.type === "compaction" ? running.reason : undefined, - ) }, }, permission: { @@ -871,7 +863,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 }))) }, }, }, @@ -1006,6 +998,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(), @@ -1028,23 +1040,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/sdk.tsx b/packages/tui/src/context/sdk.tsx index 0ad516ceacc..728ced3ed42 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -5,7 +5,7 @@ import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -export type SDKConnectionStatus = "connected" | "connecting" +export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting" type SDKEventMap = { [Type in V2Event["type"]]: Extract } const connectTimeout = 2_000 @@ -27,11 +27,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ status: SDKConnectionStatus attempt: number error?: string - connectedOnce: boolean }>({ status: "connecting", attempt: 0, - connectedOnce: false, }) let stream: AbortController | undefined @@ -70,7 +68,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ clearTimeout(timeout) attempt = 0 events.emit(first.value.type, first.value) - setConnection({ status: "connected", attempt: 0, error: undefined, connectedOnce: true }) + setConnection({ status: "connected", attempt: 0, error: undefined }) connected() while (!abort.signal.aborted && !controller.signal.aborted) { const event = await iterator.next() @@ -98,7 +96,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ } } setConnection({ - status: "connecting", + status: "reconnecting", attempt, error: error instanceof Error ? error.message : String(error), }) @@ -136,9 +134,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ error() { return connection.error }, - connectedOnce() { - return connection.connectedOnce - }, }, reload: props.reload, } diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 7e691ad29c7..40eac011ae5 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 f1c99d9679c..ae41cec303a 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 c59046a0172..6fb51ffafc9 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 ed88a1107f9..1bee45dedc4 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 352010840ba..35611b00b7c 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 f31b14868a1..db64c7b5da2 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 d1ea5e1272d..1dc2baa2ba1 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) @@ -437,7 +427,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 +444,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, ) @@ -936,9 +926,6 @@ export function Session() { /> )} - - {(compaction) => } - SessionMessage | undefined }) { +function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) { return ( @@ -1062,7 +1049,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 +1077,14 @@ function BackgroundToolHint(props: { messages: SessionMessage[] }) { ) } -function SessionMessageView(props: { message: SessionMessage }) { +function SessionMessageView(props: { message: SessionMessageInfo }) { return ( - } /> + } /> @@ -1106,17 +1093,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 +1137,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 +1247,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 +1263,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 +1277,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) { ) } -function SessionSkillMessage(props: { message: Extract }) { +function SessionSkillMessage(props: { message: Extract }) { const { theme } = useTheme() return ( @@ -1300,7 +1287,7 @@ function SessionSkillMessage(props: { message: Extract + message?: Extract status?: "running" text?: string }) { @@ -1308,9 +1295,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 +1316,8 @@ function CompactionMessage(props: { - - - - {status() === "queued" ? "Compaction queued" : "Compaction"} + Compaction @@ -1363,7 +1348,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 +1368,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 +1397,7 @@ function RevertMessage(props: { {statusLabel(file.status)} - {Locale.truncateLeft(file.path, 60)} + {Locale.truncateLeft(file.file, 60)} 0}> +{file.additions} @@ -1433,7 +1418,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 +1433,7 @@ function ShellMessage(props: { message: Extract - $ {props.message.shell.command} + $ {props.message.command} {output()} @@ -1560,7 +1545,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 +1817,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 +1841,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 +1893,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { - + @@ -2187,7 +2172,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 +2194,7 @@ function Shell(props: ToolProps) { Writing command... ) : ( Writing command... @@ -2418,7 +2403,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) @@ -2514,7 +2499,7 @@ function Edit(props: ToolProps) { : "# Preparing edit..." } part={props.part} - spinner={props.part.state.status === "pending"} + spinner={props.part.state.status === "streaming"} /> @@ -2606,7 +2591,7 @@ function ApplyPatch(props: ToolProps) { : "# Preparing patch..." } part={props.part} - spinner={props.part.state.status === "pending"} + spinner={props.part.state.status === "streaming"} /> @@ -2676,9 +2661,10 @@ function Question(props: ToolProps) { } function Skill(props: ToolProps) { + const name = createMemo(() => stringValue(props.metadata.name) ?? stringValue(props.input.id)) return ( - - Skill "{stringValue(props.input.name)}" + + Skill "{name()}" ) } @@ -2737,7 +2723,7 @@ const toolDisplays = new Set([ "edit", "subagent", "execute", - "apply_patch", + "patch", "todowrite", "question", "skill", @@ -2746,7 +2732,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" } @@ -2756,15 +2742,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] @@ -2774,7 +2760,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 a137d6272ab..a0ca4775c3b 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 5d2a3614520..c8764f3030a 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,4 +1,4 @@ -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 { useData } from "../../context/data" @@ -88,7 +88,7 @@ export function createSessionRows(sessionID: Accessor) { { id: message.id, created: message.time.created, - input: message.status === "queued" || message.status === "running", + input: message.status === "running", }, ] : [], @@ -157,7 +157,7 @@ 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") + return message?.type === "compaction" && message.status === "running" } const queuedStart = (rows: SessionRow[]) => { @@ -173,7 +173,7 @@ export function createSessionRows(sessionID: Accessor) { } const subscriptions = [ data.on("session.prompt.admitted", input), - data.on("session.compaction.admitted", input), + data.on("session.compaction.started", message), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { if (event.data.sessionID === sessionID() && event.data.description?.trim()) @@ -224,11 +224,9 @@ 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"), - ) +export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { + const isInput = (message: SessionMessageInfo) => inputs.has(message.id) + const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) return [ ...messages.filter((message) => !pending.has(message.id)), diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 5639f6451f5..bd415768a25 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 71b3a161e5f..f02ef3f20e1 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 94ccad22d09..41f583b1c67 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 18e256bd34f..c9d92434e20 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 c08b557eee3..ac3abc95815 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 b7b349a196d..e0612bb059c 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -6,7 +6,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { EventV2 } from "@opencode-ai/core/event" import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/context/project" -import { SDKProvider } from "../../../src/context/sdk" +import { SDKProvider, useSDK } from "../../../src/context/sdk" import { DataProvider, useData } from "../../../src/context/data" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" @@ -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,286 @@ test("refreshes resources into reactive getters", async () => { } }) +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 +434,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 +449,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() @@ -204,9 +494,11 @@ test("reconnects the event stream and bootstraps fresh data", async () => { }) }, events) let data!: ReturnType + let sdk!: ReturnType function Probe() { data = useData() + sdk = useSDK() return } @@ -225,32 +517,24 @@ test("reconnects the event stream and bootstraps fresh data", async () => { try { await wait(() => data.location.model.list()?.[0]?.id === "model-1") await wait(() => data.session.status("session-stale") === "running") - expect(data.connection.status()).toBe("connected") - expect(data.connection.attempt()).toBe(0) + expect(sdk.connection.status()).toBe("connected") + expect(sdk.connection.attempt()).toBe(0) events.disconnect() - await wait(() => data.connection.status() === "connecting") - expect(data.connection.attempt()).toBe(1) - expect(data.connection.error()).toBe("Event stream disconnected") + await wait(() => sdk.connection.status() === "reconnecting") + expect(sdk.connection.attempt()).toBe(1) + expect(sdk.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: {} })) + await wait(() => requests.active === 2 && sdk.connection.status() === "connected", 4000) + 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") expect(data.session.status("session-new")).toBe("running") expect(requests.event).toBe(2) - expect(data.connection.status()).toBe("connected") - expect(data.connection.attempt()).toBe(0) - expect(data.connection.error()).toBeUndefined() + expect(sdk.connection.status()).toBe("connected") + expect(sdk.connection.attempt()).toBe(0) + expect(sdk.connection.error()).toBeUndefined() } finally { app.renderer.destroy() } @@ -391,7 +675,7 @@ test("removes committed revert messages from local state", async () => { } }) -test("connectedOnce is false until first connect and persists across disconnect", async () => { +test("distinguishes initial connection from reconnection", async () => { const encoder = new TextEncoder() let stream: ReadableStreamDefaultController | undefined const eventResponse = () => @@ -417,10 +701,10 @@ test("connectedOnce is false until first connect and persists across disconnect" const calls = createFetch((url) => { if (url.pathname === "/api/event") return eventResponse() }) - let data!: ReturnType + let sdk!: ReturnType function Probe() { - data = useData() + sdk = useSDK() return } @@ -438,16 +722,13 @@ test("connectedOnce is false until first connect and persists across disconnect" try { await wait(() => stream !== undefined) - expect(data.connection.status()).toBe("connecting") - expect(data.connection.connectedOnce()).toBe(false) + expect(sdk.connection.status()).toBe("connecting") connect() - await wait(() => data.connection.status() === "connected") - expect(data.connection.connectedOnce()).toBe(true) + await wait(() => sdk.connection.status() === "connected") disconnect() - await wait(() => data.connection.status() === "connecting") - expect(data.connection.connectedOnce()).toBe(true) + await wait(() => sdk.connection.status() === "reconnecting") } finally { app.renderer.destroy() } @@ -455,8 +736,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 +792,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 +820,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 +885,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 +912,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 +944,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 +956,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 +974,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 +992,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 +1009,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 +1030,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 +1052,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 +1066,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 { @@ -1027,9 +1368,11 @@ test("adds and dismisses permission requests from live events", async () => { const events = createEventStream() const calls = createFetch(undefined, events) let data!: ReturnType + let sdk!: ReturnType function Probe() { data = useData() + sdk = useSDK() return } @@ -1046,7 +1389,7 @@ test("adds and dismisses permission requests from live events", async () => { )) try { - await wait(() => data.connection.status() === "connected") + await wait(() => sdk.connection.status() === "connected") emitEvent(events, { id: "evt_permission_asked_1", created: 0, @@ -1092,6 +1435,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) => { @@ -1099,9 +1488,11 @@ test("adds, dismisses, and refreshes form requests", async () => { return json({ data: [{ id: "frm_remote", sessionID: "ses_1", mode: "form", fields: [] }] }) }, events) let data!: ReturnType + let sdk!: ReturnType function Probe() { data = useData() + sdk = useSDK() return } @@ -1118,7 +1509,7 @@ test("adds, dismisses, and refreshes form requests", async () => { )) try { - await wait(() => data.connection.status() === "connected") + await wait(() => sdk.connection.status() === "connected") emitEvent(events, { id: "evt_form_created_1", created: 0, @@ -1162,6 +1553,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 402db8b5fe1..c95e3834d2a 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 362ab40369a..c43a6faf9df 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,19 +207,19 @@ 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 => ({ +test("places a running compaction barrier before every queued user message", () => { + const queued = (id: string, text: string, created: number): SessionMessageInfo => ({ type: "user", id, text, time: { created }, }) - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ queued("user-before", "Before", 1), { type: "compaction", id: "compaction", - status: "queued", + status: "running", reason: "manual", summary: "", recent: "", @@ -249,5 +247,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 8c6e476db0c..2c10ed43a23 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 f77f54cbab3..ca08e7d974e 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 5aa9b781f91..e592b55ef5d 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 8883e6889a4..a6e8366ca6e 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 94d9ba3c75d..74845213649 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 814d2b819ed..3d998904cbb 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 2c100e7c797..fe04a17fd30 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 b925385cff4..f9b597e05e8 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 bc8b550cc0f..3c75d9929be 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 67cd703a9f8..46fe7138275 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 755b856fc72..5da4b9beddd 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 e6ee7e1f02d..1cf8900f91b 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 36e9485e974..77308b45bcc 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 eee6d3e162c..8769a14d76f 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 69b69e1f283..4a073c4b4b4 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 ac48039aa85..ed754539887 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 49df515c81e..39a67bdb369 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 4f28122bd95..8974771e065 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 46f090bb72a..b3d9702e3dc 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 25e7bce88e4..619c7030d3b 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 搜尋執行 |