Compare commits

..

2 Commits

Author SHA1 Message Date
Brendan Allan 55874ccaeb simplify repalceServerConnection 2026-08-07 19:34:47 +08:00
Brendan Allan b8ed70d6d3 refactor(app): split server management controllers 2026-08-07 17:37:35 +08:00
547 changed files with 26486 additions and 32168 deletions
-1
View File
@@ -4,7 +4,6 @@ on:
push: push:
branches: branches:
- dev - dev
- v2
jobs: jobs:
generate: generate:
+1
View File
@@ -251,3 +251,4 @@ opencode-drive stop --name demo
```bash ```bash
opencode-drive dir --name demo opencode-drive dir --name demo
``` ```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

-57
View File
@@ -395,7 +395,6 @@
"ignore": "7.0.5", "ignore": "7.0.5",
"immer": "11.1.4", "immer": "11.1.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10", "tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
@@ -571,20 +570,6 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
}, },
}, },
"packages/merman": {
"name": "@opencode-ai/merman",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.18.8", "version": "1.18.8",
@@ -693,7 +678,6 @@
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"modal": "0.9.0",
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
@@ -896,7 +880,6 @@
"dependencies": { "dependencies": {
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",
@@ -1528,18 +1511,6 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="], "@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="], "@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1748,10 +1719,6 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="], "@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -1846,8 +1813,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="], "@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
@@ -2098,8 +2063,6 @@
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"], "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -3334,8 +3297,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3576,10 +3537,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -4612,8 +4569,6 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4840,8 +4795,6 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="], "morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="], "motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
@@ -4880,10 +4833,6 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="], "nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="], "nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5682,8 +5631,6 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6642,8 +6589,6 @@
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"cliui/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=="], "cliui/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=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6788,8 +6733,6 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], "motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="], "nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
+3 -4
View File
@@ -368,12 +368,11 @@ Other provider exports listed above remain direct facades until they explicitly
## Provider options & HTTP overlays ## Provider options & HTTP overlays
Request options in order of stability: Three escape hatches in order of stability:
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop). 1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
2. **`promptCacheKey`** — stable cache affinity lowered by every protocol that supports it. 2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
3. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `store`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing). 3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
4. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
Route/provider defaults are overridden by request-level values for each axis. Route/provider defaults are overridden by request-level values for each axis.
+5 -4
View File
@@ -33,10 +33,9 @@ const model = OpenAI.configure({
// //
// - `generation`: common controls such as max tokens, temperature, topP/topK, // - `generation`: common controls such as max tokens, temperature, topP/topK,
// penalties, seed, and stop sequences. // penalties, seed, and stop sequences.
// - `promptCacheKey`: stable cache affinity for protocols that support it.
// - `providerOptions`: namespaced provider-native behavior. For example, // - `providerOptions`: namespaced provider-native behavior. For example,
// OpenAI store behavior, Anthropic thinking, Gemini thinking config, or // OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking
// OpenRouter routing/reasoning. // config, or OpenRouter routing/reasoning.
// - `http`: last-resort serializable overlays for final request body, headers, // - `http`: last-resort serializable overlays for final request body, headers,
// and query params. Prefer typed `providerOptions` when a field is stable. // and query params. Prefer typed `providerOptions` when a field is stable.
// //
@@ -46,7 +45,9 @@ const request = LLM.request({
system: "You are concise and practical.", system: "You are concise and practical.",
prompt: "Tell me a joke", prompt: "Tell me a joke",
generation: { maxTokens: 80, temperature: 0.7 }, generation: { maxTokens: 80, temperature: 0.7 },
promptCacheKey: "tutorial-joke", providerOptions: {
openai: { promptCacheKey: "tutorial-joke" },
},
}) })
// 3. `generate` sends the request and collects the event stream into one // 3. `generate` sends the request and collects the event stream into one
+1 -2
View File
@@ -133,8 +133,7 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => { export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
return request
const policy = resolve(request.cache) const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request if (!policy.tools && !policy.system && !policy.messages) return request
+5 -37
View File
@@ -25,20 +25,8 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
const ADAPTER = "gemini" const ADAPTER = "gemini"
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES) const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call
// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior.
const requiresThoughtSignatureFallback = (modelID: string) => {
if (!/(^|\/)gemini-/i.test(modelID)) return false
if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false
if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
}
export interface OptionsInput { export interface OptionsInput {
readonly [key: string]: unknown readonly [key: string]: unknown
readonly cachedContent?: string readonly cachedContent?: string
@@ -157,9 +145,6 @@ const GeminiGenerationConfig = Schema.Struct({
temperature: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Number),
topP: Schema.optional(Schema.Number), topP: Schema.optional(Schema.Number),
topK: Schema.optional(Schema.Number), topK: Schema.optional(Schema.Number),
frequencyPenalty: Schema.optional(Schema.Number),
presencePenalty: Schema.optional(Schema.Number),
seed: Schema.optional(Schema.Number),
stopSequences: optionalArray(Schema.String), stopSequences: optionalArray(Schema.String),
thinkingConfig: Schema.optional(GeminiThinkingConfig), thinkingConfig: Schema.optional(GeminiThinkingConfig),
}) })
@@ -217,13 +202,11 @@ interface ParserState {
// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules. // keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
// //
// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect: // 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
// drop empty root parameter schemas while preserving nested empty objects, // drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
// expand type arrays into `anyOf`, derive `nullable: true` from null members, // coerce `const` to `[const]` enum, recurse properties/items, propagate
// coerce `const` to `[const]` enum, recurse properties/items, and propagate
// only an allowlisted set of keys (description, required, format, type, // only an allowlisted set of keys (description, required, format, type,
// nullable, enum, properties, items, allOf, anyOf, oneOf, minLength). // properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
// Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is // allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
// silently dropped.
// //
// Sanitize runs first, then project. The implementation lives in // Sanitize runs first, then project. The implementation lives in
// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other // `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
@@ -299,8 +282,6 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (message.role === "assistant") { if (message.role === "assistant") {
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = [] const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
// Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid.
let hasSignedToolCall = false
for (const part of message.content) { for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"]) return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
@@ -313,17 +294,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
continue continue
} }
if (part.type === "tool-call") { if (part.type === "tool-call") {
const lowered = lowerToolCall(part) parts.push(lowerToolCall(part))
const signature = lowered.thoughtSignature
parts.push({
...lowered,
thoughtSignature:
signature ??
(requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
? SKIP_THOUGHT_SIGNATURE_VALIDATOR
: undefined),
})
if (signature !== undefined) hasSignedToolCall = true
continue continue
} }
} }
@@ -417,9 +388,6 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
temperature: generation?.temperature, temperature: generation?.temperature,
topP: generation?.topP, topP: generation?.topP,
topK: generation?.topK, topK: generation?.topK,
frequencyPenalty: generation?.frequencyPenalty,
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop, stopSequences: generation?.stop,
thinkingConfig: options.thinkingConfig, thinkingConfig: options.thinkingConfig,
} }
+1 -1
View File
@@ -539,7 +539,7 @@ const lowerOptions = (request: LLMRequest) => {
return { return {
...(options.instructions ? { instructions: options.instructions } : {}), ...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}), ...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}), ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}), ...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary ...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } } ? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
+17 -20
View File
@@ -132,7 +132,6 @@ export const bodyFields = {
stream: Schema.Literal(true), stream: Schema.Literal(true),
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })), stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number), max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number), max_tokens: Schema.optional(Schema.Number),
@@ -371,13 +370,12 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
return text return text
})() })()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined) const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
const result = { const result = {
role: "assistant" as const, role: "assistant" as const,
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "", content: content.length === 0 ? null : ProviderShared.joinText(content),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
...(details !== undefined ? { reasoning_details: details } : {}), reasoning_details: details,
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}), cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
} }
if (field === undefined || reasoningText === undefined) return result if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText } return { ...result, [field]: reasoningText }
@@ -511,7 +509,6 @@ const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request) const options = OpenAIOptions.resolve(request)
return { return {
...(options.store !== undefined ? { store: options.store } : {}), ...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}), ...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
} }
} }
@@ -710,20 +707,23 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
Boolean(delta?.content) || Boolean(delta?.content) ||
reasoning !== undefined || reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) || (Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments)) toolDeltas.some(
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
)
if (state.finishReason !== undefined) { if (state.finishReason !== undefined) {
if (hasLateContent) if (hasLateContent)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason") return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
return [{ ...state, usage }, events] as const return [{ ...state, usage }, events] as const
} }
const reasoningField = state.reasoningField ?? reasoning?.field const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta) if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField) const deltaMetadata = reasoningMetadata(reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata) if (!state.lifecycle.text.has("text-0") && text !== undefined)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if ( else if (
reasoningDetailsObserved && reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") && !lifecycle.reasoning.has("reasoning-0") &&
@@ -749,7 +749,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position) const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
const fallbackTool = tools[fallback] ?? pendingTools[fallback] const fallbackTool = tools[fallback] ?? pendingTools[fallback]
const index = const index =
tool.index ?? matched ?? (tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback) tool.index ?? matched ??
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
const current = tools[index] const current = tools[index]
const pending = pendingTools[index] const pending = pendingTools[index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined) const id = current?.id ?? pending?.id ?? (tool.id || undefined)
@@ -812,18 +813,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => { const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const toolCallEvents = const hasToolCalls = state.toolCallEvents.length > 0
state.finishReason === undefined && Object.keys(state.tools).length > 0
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
: state.toolCallEvents
const hasToolCalls = toolCallEvents.length > 0
const reason = state.finishReason const reason = state.finishReason
? { ? {
...state.finishReason, ...state.finishReason,
normalized: normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized, state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
} }
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) } : undefined
const metadata = reasoningMetadata( const metadata = reasoningMetadata(
state.reasoningField, state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined, state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
@@ -833,9 +830,9 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField)) ? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
: state.lifecycle : state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata) const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...toolCallEvents) events.push(...state.toolCallEvents)
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage }) if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events return events
} }
@@ -61,57 +61,37 @@ const emptyObjectSchema = (schema: Record<string, unknown>) =>
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) && (!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
!schema.additionalProperties !schema.additionalProperties
const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => { const projectNode = (schema: unknown): Record<string, unknown> | undefined => {
if (!isRecord(schema)) return undefined if (!isRecord(schema)) return undefined
if (!nested && emptyObjectSchema(schema)) return undefined if (emptyObjectSchema(schema)) return undefined
const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined return Object.fromEntries(
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined
const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false
const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf
const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined
const result = Object.fromEntries(
[ [
["description", schema.description], ["description", schema.description],
["required", schema.required], ["required", schema.required],
["format", schema.format], ["format", schema.format],
["type", types ? (types.length === 0 ? "null" : undefined) : schema.type], ["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type],
[ ["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined],
"nullable",
(Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf
? true
: undefined,
],
["enum", schema.const !== undefined ? [schema.const] : schema.enum], ["enum", schema.const !== undefined ? [schema.const] : schema.enum],
[ [
"properties", "properties",
isRecord(schema.properties) isRecord(schema.properties)
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)])) ? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)]))
: undefined, : undefined,
], ],
[ [
"items", "items",
Array.isArray(schema.items) Array.isArray(schema.items)
? schema.items.map((item) => projectNode(item, true)) ? schema.items.map(projectNode)
: schema.items === undefined : schema.items === undefined
? undefined ? undefined
: projectNode(schema.items, true), : projectNode(schema.items),
], ],
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined], ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined],
[ ["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined],
"anyOf", ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined],
anyOfTypes
? hasNullAnyOf && anyOfTypes.length === 1
? undefined
: anyOfTypes.map((item) => projectNode(item, true))
: types && types.length > 0
? types.map((type) => ({ type }))
: undefined,
],
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined],
["minLength", schema.minLength], ["minLength", schema.minLength],
].filter((entry) => entry[1] !== undefined), ].filter((entry) => entry[1] !== undefined),
) )
return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result
} }
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema)) export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
@@ -33,6 +33,7 @@ export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export interface Resolved { export interface Resolved {
readonly instructions?: string readonly instructions?: string
readonly store?: boolean readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: string readonly reasoningEffort?: string
readonly reasoningSummary?: "auto" | "concise" | "detailed" readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable> readonly include?: ReadonlyArray<ResponseIncludable>
@@ -49,6 +50,7 @@ export const resolve = (request: LLMRequest): Resolved => {
return { return {
instructions: typeof input?.instructions === "string" ? input.instructions : undefined, instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
store: typeof input?.store === "boolean" ? input.store : undefined, store: typeof input?.store === "boolean" ? input.store : undefined,
promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined,
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined, reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
reasoningSummary: reasoningSummary:
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed" reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
@@ -5,6 +5,7 @@ export interface OpenResponsesOptionsInput {
readonly [key: string]: unknown readonly [key: string]: unknown
readonly instructions?: string readonly instructions?: string
readonly store?: boolean readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: ReasoningEffort readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed" readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable> readonly include?: ReadonlyArray<ResponseIncludable>
@@ -17,6 +17,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
const openai = Object.fromEntries( const openai = Object.fromEntries(
definedEntries({ definedEntries({
store: options?.store, store: options?.store,
promptCacheKey: options?.promptCacheKey,
reasoningEffort: options?.reasoningEffort, reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary, reasoningSummary: options?.reasoningSummary,
include: options?.include, include: options?.include,
+2 -1
View File
@@ -55,6 +55,7 @@ export interface OpenRouterOptions {
readonly debug?: Readonly<{ echo_upstream_body?: boolean }> readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
readonly models?: ReadonlyArray<string> readonly models?: ReadonlyArray<string>
readonly plugins?: ReadonlyArray<OpenRouterPlugin> readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{ readonly reasoning?: Readonly<{
enabled?: boolean enabled?: boolean
@@ -121,7 +122,6 @@ export const protocol = Protocol.make({
...body, ...body,
messages, messages,
...bodyOptions(request.providerOptions?.openrouter), ...bodyOptions(request.providerOptions?.openrouter),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
} as OpenRouterBody } as OpenRouterBody
}), }),
), ),
@@ -161,6 +161,7 @@ const bodyOptions = (input: unknown) => {
...(isRecord(debug) ? { debug } : {}), ...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}), ...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}), ...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
} }
} }
-2
View File
@@ -47,8 +47,6 @@ const chatRoute = Route.make({
protocol: OpenAIChat.protocol, protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }), endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport, transport: OpenAICompatibleChat.route.transport,
headers: ({ request }): Record<string, string> =>
request.promptCacheKey ? { "x-grok-conv-id": request.promptCacheKey } : {},
}) })
export const routes = [responsesRoute, chatRoute] export const routes = [responsesRoute, chatRoute]
+7 -1
View File
@@ -255,7 +255,13 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
return Effect.succeed(event) return Effect.succeed(event)
}), }),
Stream.onEnd(Effect.suspend(() => (terminal ? Effect.void : Effect.fail(incompleteStreamError(route))))), Stream.onEnd(
Effect.suspend(() =>
terminal
? Effect.void
: Effect.fail(incompleteStreamError(route)),
),
),
) )
}) })
+2 -14
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema } from "effect" import { Cause, Context, Effect, Layer } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
Headers, Headers,
@@ -198,20 +198,8 @@ const responseBody = (body: string | void, request: HttpClientRequest.HttpClient
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true } return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
} }
const decodeProviderBody = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
message: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
}),
),
)
const providerMessage = (status: number, body: { readonly body?: string }) => { const providerMessage = (status: number, body: { readonly body?: string }) => {
if (body.body && body.body.length <= 500) { if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
}
return `Provider request failed with HTTP ${status}` return `Provider request failed with HTTP ${status}`
} }
+28 -30
View File
@@ -45,34 +45,35 @@ const isToolResultValue = (value: unknown): value is ToolResultValue =>
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") && (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value "value" in value
const toolResultValueSchema = Schema.Union([ export const ToolResultValue = Object.assign(
Schema.Struct({ Schema.Union([
type: Schema.Literal("json"), Schema.Struct({
value: Schema.Unknown, type: Schema.Literal("json"),
}), value: Schema.Unknown,
Schema.Struct({ }),
type: Schema.Literal("text"), Schema.Struct({
value: Schema.Unknown, type: Schema.Literal("text"),
}), value: Schema.Unknown,
Schema.Struct({ }),
type: Schema.Literal("error"), Schema.Struct({
value: Schema.Unknown, type: Schema.Literal("error"),
}), value: Schema.Unknown,
Schema.Struct({ }),
type: Schema.Literal("content"), Schema.Struct({
value: Schema.Array(Tool.Content), type: Schema.Literal("content"),
}), value: Schema.Array(Tool.Content),
]).annotate({ identifier: "LLM.ToolResult" }) }),
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema> ]).annotate({ identifier: "LLM.ToolResult" }),
{
export const ToolResultValue = Object.assign(toolResultValueSchema, { is: isToolResultValue,
is: isToolResultValue, make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => { if (isToolResultValue(value)) return value
if (isToolResultValue(value)) return value if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
if (type === "content") return { type, value: Array.isArray(value) ? value : [] } return { type, value }
return { type, value } },
}, },
}) )
export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
export interface ToolOutput { export interface ToolOutput {
readonly structured: unknown readonly structured: unknown
@@ -271,8 +272,6 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
providerOptions: Schema.optional(ProviderOptions), providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions), http: Schema.optional(HttpOptions),
cache: Schema.optional(CachePolicy), cache: Schema.optional(CachePolicy),
// Stable cache affinity for protocols that support provider-managed prompt caching.
promptCacheKey: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {} }) {}
@@ -290,7 +289,6 @@ export namespace LLMRequest {
providerOptions: request.providerOptions, providerOptions: request.providerOptions,
http: request.http, http: request.http,
cache: request.cache, cache: request.cache,
promptCacheKey: request.promptCacheKey,
metadata: request.metadata, metadata: request.metadata,
}) })
+7 -2
View File
@@ -1,6 +1,10 @@
import { Effect, JsonSchema, Schema } from "effect" import { Effect, JsonSchema, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import type { ToolCallPart, ToolDefinition as ToolDefinitionClass, ToolOutput as ToolOutputType } from "./schema" import type {
ToolCallPart,
ToolDefinition as ToolDefinitionClass,
ToolOutput as ToolOutputType,
} from "./schema"
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema" import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
/** /**
@@ -240,7 +244,8 @@ const project = (
): ToolOutputType => ): ToolOutputType =>
ToolOutput.make( ToolOutput.make(
toStructuredOutput?.(output) ?? output, toStructuredOutput?.(output) ?? output,
toModelOutput?.({ id, parameters, output }) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []), toModelOutput?.({ id, parameters, output }) ??
(typeof output === "string" ? [{ type: "text", text: output }] : []),
) )
export { ToolFailure } export { ToolFailure }
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:anthropic-messages-cache", "provider:anthropic", "protocol:anthropic-messages", "cache", "tool"], "tags": [
"prefix:anthropic-messages-cache",
"provider:anthropic",
"protocol:anthropic-messages",
"cache",
"tool"
],
"name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback", "name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback",
"recordedAt": "2026-07-24T16:22:29.494Z" "recordedAt": "2026-07-24T16:22:29.494Z"
}, },
@@ -1,7 +1,12 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:bedrock-converse-cache", "provider:amazon-bedrock", "protocol:bedrock-converse", "cache"], "tags": [
"prefix:bedrock-converse-cache",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"cache"
],
"name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call", "name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call",
"recordedAt": "2026-07-23T02:29:10.955Z" "recordedAt": "2026-07-23T02:29:10.955Z"
}, },
@@ -1,7 +1,11 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:google-images", "provider:google", "protocol:google-images"], "tags": [
"prefix:google-images",
"provider:google",
"protocol:google-images"
],
"name": "google-images/generates-an-image", "name": "google-images/generates-an-image",
"recordedAt": "2026-07-19T16:05:51.868Z" "recordedAt": "2026-07-19T16:05:51.868Z"
}, },
@@ -1,7 +1,11 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:openai-images", "provider:openai", "protocol:openai-images"], "tags": [
"prefix:openai-images",
"provider:openai",
"protocol:openai-images"
],
"name": "openai-images/generates-an-image", "name": "openai-images/generates-an-image",
"recordedAt": "2026-07-19T14:41:43.188Z" "recordedAt": "2026-07-19T14:41:43.188Z"
}, },
@@ -1,7 +1,11 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:openai-responses-images", "provider:openai", "protocol:openai-responses"], "tags": [
"prefix:openai-responses-images",
"provider:openai",
"protocol:openai-responses"
],
"name": "openai-responses-images/generates-and-edits-an-image-with-the-hosted-tool", "name": "openai-responses-images/generates-and-edits-an-image-with-the-hosted-tool",
"recordedAt": "2026-07-19T14:57:16.284Z" "recordedAt": "2026-07-19T14:57:16.284Z"
}, },
@@ -2,7 +2,12 @@
"version": 1, "version": 1,
"metadata": { "metadata": {
"model": "anthropic/claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6",
"tags": ["prefix:openai-compatible-chat", "provider:openrouter", "protocol:openai-chat", "reasoning"], "tags": [
"prefix:openai-compatible-chat",
"provider:openrouter",
"protocol:openai-chat",
"reasoning"
],
"name": "openrouter-reasoning", "name": "openrouter-reasoning",
"recordedAt": "2026-07-18T11:28:39.267Z" "recordedAt": "2026-07-18T11:28:39.267Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"tool",
"tool-result"
],
"name": "pdf/anthropic-tool-result", "name": "pdf/anthropic-tool-result",
"recordedAt": "2026-07-22T18:15:39.002Z" "recordedAt": "2026-07-22T18:15:39.002Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"user-input"
],
"name": "pdf/anthropic-user-input", "name": "pdf/anthropic-user-input",
"recordedAt": "2026-07-22T18:15:37.979Z" "recordedAt": "2026-07-22T18:15:37.979Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"tool",
"tool-result"
],
"name": "pdf/bedrock-tool-result", "name": "pdf/bedrock-tool-result",
"recordedAt": "2026-07-22T18:15:52.400Z" "recordedAt": "2026-07-22T18:15:52.400Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"user-input"
],
"name": "pdf/bedrock-user-input", "name": "pdf/bedrock-user-input",
"recordedAt": "2026-07-22T18:15:48.408Z" "recordedAt": "2026-07-22T18:15:48.408Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"tool",
"tool-result"
],
"name": "pdf/gemini-tool-result", "name": "pdf/gemini-tool-result",
"recordedAt": "2026-07-22T18:21:59.606Z" "recordedAt": "2026-07-22T18:21:59.606Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"user-input"
],
"name": "pdf/gemini-user-input", "name": "pdf/gemini-user-input",
"recordedAt": "2026-07-22T18:20:55.140Z" "recordedAt": "2026-07-22T18:20:55.140Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"tool",
"tool-result"
],
"name": "pdf/openai-tool-result", "name": "pdf/openai-tool-result",
"recordedAt": "2026-07-22T18:15:36.438Z" "recordedAt": "2026-07-22T18:15:36.438Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"user-input"
],
"name": "pdf/openai-user-input", "name": "pdf/openai-user-input",
"recordedAt": "2026-07-22T18:15:34.867Z" "recordedAt": "2026-07-22T18:15:34.867Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:openai-responses",
"tool",
"tool-result"
],
"name": "pdf/xai-tool-result", "name": "pdf/xai-tool-result",
"recordedAt": "2026-07-22T18:15:43.608Z" "recordedAt": "2026-07-22T18:15:43.608Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:openai-responses",
"user-input"
],
"name": "pdf/xai-user-input", "name": "pdf/xai-user-input",
"recordedAt": "2026-07-22T18:15:42.429Z" "recordedAt": "2026-07-22T18:15:42.429Z"
}, },
@@ -2,7 +2,12 @@
"version": 1, "version": 1,
"metadata": { "metadata": {
"model": "anthropic/claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6",
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"], "tags": [
"prefix:openai-compatible-chat",
"provider:vercel-ai-gateway",
"protocol:openai-chat",
"reasoning"
],
"name": "vercel-ai-gateway-reasoning", "name": "vercel-ai-gateway-reasoning",
"recordedAt": "2026-07-18T11:28:42.077Z" "recordedAt": "2026-07-18T11:28:42.077Z"
}, },
@@ -1,7 +1,11 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:xai-images", "provider:xai", "protocol:xai-images"], "tags": [
"prefix:xai-images",
"provider:xai",
"protocol:xai-images"
],
"name": "xai-images/generates-an-image", "name": "xai-images/generates-an-image",
"recordedAt": "2026-07-19T15:56:20.098Z" "recordedAt": "2026-07-19T15:56:20.098Z"
}, },
@@ -3,11 +3,11 @@ import { CloudflareWorkersAI } from "../../src/providers"
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model") const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
LLM.request({ model, prompt: "Hello", promptCacheKey: "cache" }) LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Prompt cache keys must be strings. // @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
promptCacheKey: 1, providerOptions: { openai: { promptCacheKey: 1 } },
}) })
@@ -519,8 +519,14 @@ describe("Bedrock Converse route", () => {
fixedBytes( fixedBytes(
eventStreamBody( eventStreamBody(
["messageStart", { role: "assistant" }], ["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }], [
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }], "contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["messageStop", { stopReason: "end_turn" }], ["messageStop", { stopReason: "end_turn" }],
), ),
), ),
@@ -555,7 +561,10 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const body = eventStreamBody( const body = eventStreamBody(
["messageStart", { role: "assistant" }], ["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }], [
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockStop", { contentBlockIndex: 0 }], ["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }], ["messageStop", { stopReason: "end_turn" }],
) )
-172
View File
@@ -16,13 +16,6 @@ const model = Gemini.route
}) })
.model({ id: "gemini-2.5-flash" }) .model({ id: "gemini-2.5-flash" })
const gemini3 = Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-3-flash-preview" })
const request = LLM.request({ const request = LLM.request({
id: "req_1", id: "req_1",
model, model,
@@ -93,39 +86,6 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("forwards standard Gemini generation options", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
generation: {
maxTokens: 40,
temperature: 0.2,
topP: 0.8,
topK: 12,
frequencyPenalty: 0.3,
presencePenalty: 0.4,
seed: 42,
stop: ["done"],
},
}),
)
expect(prepared.body.generationConfig).toEqual({
maxOutputTokens: 40,
temperature: 0.2,
topP: 0.8,
topK: 12,
frequencyPenalty: 0.3,
presencePenalty: 0.4,
seed: 42,
stopSequences: ["done"],
thinkingConfig: undefined,
})
}),
)
it.effect("lowers chronological system updates to wrapped user text in order", () => it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -390,100 +350,6 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("preserves nested empty object tool schemas", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
tools: [
{
name: "configure",
description: "Configure the operation",
inputSchema: {
type: "object",
required: ["options"],
properties: {
options: { type: "object", description: "Optional provider settings", properties: {} },
},
},
},
],
}),
)
expect(prepared.body.tools).toEqual([
{
functionDeclarations: [
{
name: "configure",
description: "Configure the operation",
parameters: {
type: "object",
required: ["options"],
properties: {
options: { type: "object", description: "Optional provider settings", properties: {} },
},
},
},
],
},
])
}),
)
it.effect("projects Gemini type arrays without narrowing their allowed values", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
tools: [
{
name: "filter",
description: "Filter values",
inputSchema: {
type: "object",
properties: {
status: { type: ["number", "string"], description: "Status filter" },
maybe: { type: ["string", "null"] },
nothing: { type: ["null"] },
explicit: { anyOf: [{ type: "string" }, { type: "null" }] },
choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] },
},
},
},
],
}),
)
expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({
type: "object",
properties: {
status: {
description: "Status filter",
anyOf: [{ type: "number" }, { type: "string" }],
},
maybe: {
nullable: true,
anyOf: [{ type: "string" }],
},
nothing: {
type: "null",
},
explicit: {
type: "string",
nullable: true,
},
choice: {
anyOf: [{ type: "string" }, { type: "number" }],
nullable: true,
},
},
})
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () => it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( const body = sseEvents(
@@ -670,44 +536,6 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "tool_0", name: "lookup", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
])
}),
)
it.effect("emits streamed tool calls and maps finish reason", () => it.effect("emits streamed tool calls and maps finish reason", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents({ const body = sseEvents({
+26 -91
View File
@@ -15,8 +15,6 @@ import {
} from "../../src" } from "../../src"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as XAI from "../../src/providers/xai"
import * as OpenAIChat from "../../src/protocols/openai-chat" import * as OpenAIChat from "../../src/protocols/openai-chat"
import { ProviderShared } from "../../src/protocols/shared" import { ProviderShared } from "../../src/protocols/shared"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
@@ -102,24 +100,6 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("concatenates assistant text parts without adding separators", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Hello" },
{ type: "text", text: " world" },
]),
],
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello world" }])
}),
)
it.effect("writes reasoning to a configured custom field on every assistant message", () => it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -174,47 +154,6 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("maps the request prompt cache key", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAICompatible.configure({
baseURL: "https://api.compatible.test/v1",
apiKey: "test",
}).model("compatible-model"),
prompt: "Hello",
promptCacheKey: "session_123",
}),
)
expect(prepared.body.prompt_cache_key).toBe("session_123")
}),
)
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
LLMClient.generate(
LLM.request({
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
prompt: "Hello",
promptCacheKey: "session_123",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
const body = decodeJson(yield* Effect.promise(() => web.text()))
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () => it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -596,7 +535,7 @@ describe("OpenAI Chat route", () => {
}), }),
) )
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "", reasoning_content: "hidden" }]) expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }])
}), }),
) )
@@ -845,7 +784,7 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("preserves scalar reasoning after content starts", () => it.effect("ignores scalar reasoning after content starts", () =>
Effect.gen(function* () { Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }] const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe( const response = yield* LLMClient.generate(request).pipe(
@@ -861,11 +800,11 @@ describe("OpenAI Chat route", () => {
), ),
) )
expect(response.reasoning).toBe("detailscalar") expect(response.reasoning).toBe("detail")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2) expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2) expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({ expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details }, openai: { reasoningDetails: details },
}) })
}), }),
) )
@@ -965,7 +904,7 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1) expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }]) expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}), }),
) )
@@ -1015,7 +954,7 @@ describe("OpenAI Chat route", () => {
) )
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ role: "assistant", content: "", reasoning: "firstsecond", reasoning_details: [first, second] }, { role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
]) ])
}), }),
) )
@@ -1040,7 +979,7 @@ describe("OpenAI Chat route", () => {
) )
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ role: "assistant", content: "", reasoning_content: "AB", reasoning_details: [detail] }, { role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
]) ])
}), }),
) )
@@ -1062,7 +1001,7 @@ describe("OpenAI Chat route", () => {
) )
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ role: "assistant", content: "", reasoning_content: "thinking", reasoning_details: details }, { role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
]) ])
}), }),
) )
@@ -1170,7 +1109,7 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("finalizes a streamed tool call when the provider ends without a finish reason", () => it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( const body = sseEvents(
deltaChunk({ deltaChunk({
@@ -1182,31 +1121,27 @@ describe("OpenAI Chat route", () => {
const input = LLMRequest.update(request, { const input = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}) })
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body))) const events: LLMEvent[] = []
const streamError = yield* LLMClient.stream(input).pipe(
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
Effect.flip,
Effect.provide(fixedResponse(body)),
)
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(response.events).toEqual([ expect(events).toEqual([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "tool-calls" }, usage: undefined },
]) ])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
})
expect(streamError.message).toContain("The provider response ended unexpectedly.")
expect(error.message).toContain("The provider response ended unexpectedly.")
}), }),
) )
@@ -300,9 +300,7 @@ describe("OpenAI-compatible Chat route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLMRequest.update(request, {
tools: [ tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } }),
],
}), }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
@@ -20,7 +20,7 @@ const cacheRequest = LLM.request({
system: LARGE_CACHEABLE_SYSTEM, system: LARGE_CACHEABLE_SYSTEM,
prompt: "Say hi.", prompt: "Say hi.",
generation: { maxTokens: 16, temperature: 0 }, generation: { maxTokens: 16, temperature: 0 },
promptCacheKey: "recorded-cache-test", providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } },
}) })
const recorded = recordedTests({ const recorded = recordedTests({
@@ -682,9 +682,9 @@ describe("OpenAI Responses route", () => {
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think", prompt: "think",
promptCacheKey: "session_123",
providerOptions: { providerOptions: {
openai: { openai: {
promptCacheKey: "session_123",
reasoningEffort: "high", reasoningEffort: "high",
reasoningSummary: "auto", reasoningSummary: "auto",
include: ["reasoning.encrypted_content"], include: ["reasoning.encrypted_content"],
@@ -803,16 +803,17 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("maps the request prompt cache key", () => it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: OpenAI.configure({ model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/", baseURL: "https://api.openai.test/v1/",
apiKey: "test", apiKey: "test",
providerOptions: { openai: { promptCacheKey: "model_cache" } },
}).model("gpt-4.1-mini"), }).model("gpt-4.1-mini"),
prompt: "no cache", prompt: "no cache",
promptCacheKey: "request_cache", providerOptions: { openai: { promptCacheKey: "request_cache" } },
}), }),
) )
+1 -1
View File
@@ -162,6 +162,7 @@ describe("OpenRouter", () => {
openrouter: { openrouter: {
usage: true, usage: true,
reasoning: { effort: "high" }, reasoning: { effort: "high" },
promptCacheKey: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"], models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true }, provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }], plugins: [{ id: "response-healing" }],
@@ -173,7 +174,6 @@ describe("OpenRouter", () => {
}, },
}).model("anthropic/claude-3.7-sonnet:thinking"), }).model("anthropic/claude-3.7-sonnet:thinking"),
prompt: "Think briefly.", prompt: "Think briefly.",
promptCacheKey: "session_123",
}), }),
) )
@@ -177,8 +177,8 @@ test("shows all and expands historical diff summary without overlap", async ({ p
const firstUser = userMessage(undefined, { const firstUser = userMessage(undefined, {
summary: { summary: {
diffs: Array.from({ length: 12 }, (_, index) => ({ diffs: Array.from({ length: 12 }, (_, index) => ({
file: `src/diff-${index}.ts`, file: `src/diff-${index}.ts`,
status: "modified", status: "modified",
additions: 1, additions: 1,
deletions: 1, deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
@@ -85,7 +85,8 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -181,17 +181,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/provider") if (url.pathname === "/api/provider")
return json(route, { return json(route, {
location: { directory }, location: { directory },
data: [ data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
{
id: remote ? "server-b" : "server-a",
name: remote ? "Server B Provider" : "Server A Provider",
package: "test",
},
],
}) })
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] }) if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) }) if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
@@ -58,7 +58,8 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event") if (url.pathname === "/api/event")
return sse(route, url.pathname === "/api/event")
if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active") if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
@@ -171,8 +171,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
expect(bottomGap).toBeLessThanOrEqual(16) expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => { const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url()) const url = new URL(request.url())
return ( return (
url.pathname === "/api/vcs/diff" && url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
@@ -86,10 +86,15 @@ test.describe("session timeline projection", () => {
], ],
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
) )
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], { const aborted = assistantMessage(
id: "msg_1001_assistant_aborted", [
error: { name: "MessageAbortedError", data: { message: "Stopped" } }, { id: "prt_before_abort", type: "text", text: "Before interruption" },
}) ],
{
id: "msg_1001_assistant_aborted",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
},
)
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], { const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
id: "msg_1002_assistant_failed", id: "msg_1002_assistant_failed",
error: { error: {
@@ -89,7 +89,8 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback() if (url.origin !== server) return route.fallback()
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {}) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
@@ -300,8 +300,9 @@ export const fixture = {
.filter((message) => message.info.role === "user") .filter((message) => message.info.role === "user")
.map((message) => message.info.id), .map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap(currentPartIDs), targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")! expandedShellPartID: targetMessages
.callID, .flatMap((message) => message.parts)
.find((part) => part.tool === "bash")!.callID,
}, },
} }
+23 -25
View File
@@ -144,8 +144,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config), location: location(config),
data: currentProviders(providerConfig(config)), data: currentProviders(providerConfig(config)),
}) })
if (path === "/api/model") if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default") if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) }) return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] }) if (path === "/api/integration") return json(route, { location: location(config), data: [] })
@@ -207,8 +206,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1] const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) { if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead)) const value = await config.fileContent(decodeURIComponent(fileRead))
const content = const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } }) return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
} }
if (path === "/api/fs/find" && config.findFiles) { if (path === "/api/fs/find" && config.findFiles) {
@@ -283,8 +281,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
} }
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
return json(route, true)
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
return json(route, true) return json(route, true)
if ( if (
@@ -389,13 +386,11 @@ function providerConfig(config: MockServerConfig) {
function currentProviders(value: unknown) { function currentProviders(value: unknown) {
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : [] if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
return value.all return value.all.filter(record).flatMap((provider) =>
.filter(record) typeof provider.id === "string" && typeof provider.name === "string"
.flatMap((provider) => ? [{ id: provider.id, name: provider.name, package: provider.id }]
typeof provider.id === "string" && typeof provider.name === "string" : [],
? [{ id: provider.id, name: provider.name, package: provider.id }] )
: [],
)
} }
function currentModels(value: unknown) { function currentModels(value: unknown) {
@@ -445,7 +440,9 @@ function currentDefaultModel(value: unknown) {
if (!record(value) || !record(value.default)) return null if (!record(value) || !record(value.default)) return null
const selected = value.default const selected = value.default
const models = currentModels(value) const models = currentModels(value)
return models.find((model) => model.providerID === selected.providerID && model.id === selected.modelID) ?? null return models.find(
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
) ?? null
} }
function currentPermission(value: unknown) { function currentPermission(value: unknown) {
@@ -565,16 +562,22 @@ function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
} }
function mentionFrom(value: Record<string, unknown> | undefined) { function mentionFrom(value: Record<string, unknown> | undefined) {
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number") if (
!value ||
typeof value.value !== "string" ||
typeof value.start !== "number" ||
typeof value.end !== "number"
)
return return
return { text: value.value, start: value.start, end: value.end } return { text: value.value, start: value.start, end: value.end }
} }
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] { function legacyAssistantContent(
part: Record<string, unknown>,
created: number,
): SessionMessageAssistant["content"] {
if (part.type === "text" && typeof part.text === "string") if (part.type === "text" && typeof part.text === "string")
return [ return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
]
if (part.type === "reasoning" && typeof part.text === "string") { if (part.type === "reasoning" && typeof part.text === "string") {
const time = record(part.time) ? part.time : undefined const time = record(part.time) ? part.time : undefined
return [ return [
@@ -615,12 +618,7 @@ function legacyAssistantContent(part: Record<string, unknown>, created: number):
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}), ...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
} }
if (state.status === "pending") if (state.status === "pending")
return [ return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
{
...base,
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
},
]
if (state.status === "completed") if (state.status === "completed")
return [ return [
{ {
+2 -1
View File
@@ -174,7 +174,8 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request) if (url.origin !== server || url.pathname !== "/api/event")
return originalFetch(request)
const id = ++nextConnectionID const id = ++nextConnectionID
const record = { const record = {
+1 -4
View File
@@ -20,10 +20,7 @@
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head> </head>
<body <body data-new-layout class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep">
data-new-layout
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div> <div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script src="/src/entry.tsx" type="module"></script> <script src="/src/entry.tsx" type="module"></script>
+8 -1
View File
@@ -9,7 +9,14 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import {
type BaseRouterProps,
Navigate,
Route,
Router,
useParams,
useSearchParams,
} from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
@@ -6,21 +6,17 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query" import { Show } from "solid-js"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import {
type ServerDomainController,
const DEFAULT_USERNAME = "opencode" type ServerFormController,
useServerDomainController,
useServerFormController,
} from "@/components/server/server-management-controller"
interface ServerFormProps { interface ServerFormProps {
value: string value: string
@@ -39,76 +35,6 @@ interface ServerFormProps {
onBack: () => void onBack: () => void
} }
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) { function ServerForm(props: ServerFormProps) {
const language = useLanguage() const language = useLanguage()
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
@@ -176,385 +102,40 @@ function ServerForm(props: ServerFormProps) {
export function DialogSelectServer() { export function DialogSelectServer() {
const dialog = useDialog() const dialog = useDialog()
const controller = useServerManagementController({ onSelect: dialog.close }) const language = useLanguage()
const domain = useServerDomainController({ onSelect: () => dialog.close() })
const form = useServerFormController({ onSelect: () => dialog.close() })
const title = () => {
if (!form.state.open()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
<span>
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
</span>
</div>
)
}
return ( return (
<Dialog title={controller.formTitle()}> <Dialog title={title()}>
<div class="flex flex-1 min-h-0 flex-col px-5"> <div class="flex flex-1 min-h-0 flex-col px-5">
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}> <Show
<ServerConnectionForm controller={controller} /> when={form.state.open()}
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
>
<ServerConnectionForm form={form} />
</Show> </Show>
</div> </div>
</Dialog> </Dialog>
) )
} }
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) { export function ServerConnectionList(props: {
const navigate = useNavigate() domain: ServerDomainController
const server = useServer() onAdd: () => void
const tabs = useTabs() onEdit: (server: ServerConnection.Http) => void
const global = useGlobal() }) {
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
addServer: {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
const resetAdd = () => {
setStore("addServer", {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(originalKey)
}
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const settings = useSettings()
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
options.onSelect?.()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
return
}
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
})
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
}
const startAdd = () => {
resetEdit()
setStore("addServer", {
showForm: true,
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
})
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return
}
const original = editing()
if (!original) return
if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
}
}
return {
defaultKey,
canDefault,
current,
sortedItems,
status: () => global.servers.health,
isFormMode,
isAddMode,
formTitle,
formBusy,
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
select,
setDefault,
startAdd,
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
}
}
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -568,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
}} }}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={props.controller.sortedItems} items={props.domain.collection.items}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}} }}
divider={true} divider={true}
> >
@@ -580,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5"> <div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={props.domain.collection.health()[key]} />
</div> </div>
<ServerRow <ServerRow
conn={i} conn={i}
dimmed={props.controller.status()[key]?.healthy === false} dimmed={props.domain.collection.health()[key]?.healthy === false}
status={props.controller.status()[key]} status={props.domain.collection.health()[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}> <Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -597,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}> <Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" /> <Icon name="check" class="h-6" />
</Show> </Show>
@@ -616,27 +202,27 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
if (i.type !== "http") return if (i.type !== "http") return
props.controller.startEdit(i) props.onEdit(i)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canRemove(key)}> <Show when={props.domain.connection.canRemove(key)}>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))} onSelect={() => props.domain.connection.remove(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -657,7 +243,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
variant="secondary" variant="secondary"
icon="plus-small" icon="plus-small"
size="large" size="large"
onClick={props.controller.startAdd} onClick={props.onAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
> >
{language.t("dialog.server.add.button")} {language.t("dialog.server.add.button")}
@@ -667,38 +253,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
) )
} }
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) { export function ServerConnectionForm(props: { form: ServerFormController }) {
const language = useLanguage() const language = useLanguage()
return ( return (
<div class="flex flex-1 min-h-0 flex-col gap-4"> <div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm <ServerForm
value={props.controller.formValue()} value={props.form.state.value()}
name={props.controller.formName()} name={props.form.state.name()}
username={props.controller.formUsername()} username={props.form.state.username()}
password={props.controller.formPassword()} password={props.form.state.password()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
busy={props.controller.formBusy()} busy={props.form.state.busy()}
error={props.controller.formError()} error={props.form.state.error()}
status={props.controller.formStatus()} status={props.form.state.status()}
onChange={props.controller.handleFormChange()} onChange={props.form.change.value}
onNameChange={props.controller.handleFormNameChange()} onNameChange={props.form.change.name}
onUsernameChange={props.controller.handleFormUsernameChange()} onUsernameChange={props.form.change.username}
onPasswordChange={props.controller.handleFormPasswordChange()} onPasswordChange={props.form.change.password}
onSubmit={props.controller.submitForm} onSubmit={props.form.submit}
onBack={props.controller.resetForm} onBack={props.form.reset}
/> />
<div class="shrink-0 pb-5"> <div class="shrink-0 pb-5">
<Button <Button
variant="primary" variant="primary"
size="large" size="large"
onClick={props.controller.submitForm} onClick={props.form.submit}
disabled={props.controller.formBusy()} disabled={props.form.state.busy()}
class="px-3 py-1.5" class="px-3 py-1.5"
> >
{props.controller.formBusy() {props.form.state.busy()
? language.t("dialog.server.add.checking") ? language.t("dialog.server.add.checking")
: props.controller.isAddMode() : props.form.state.adding()
? language.t("dialog.server.add.button") ? language.t("dialog.server.add.button")
: language.t("common.save")} : language.t("common.save")}
</Button> </Button>
@@ -0,0 +1,323 @@
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { showToast } from "@/utils/toast"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
const DEFAULT_USERNAME = "opencode"
type FormMode = "list" | "add" | "edit"
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultKeyActions] = createResource(
async () => {
try {
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const set = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultKeyActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return {
key: () => defaultKey.latest,
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
set,
}
}
function useServerMutations() {
const server = useServer()
const tabs = useTabs()
return {
add: (connection: ServerConnection.Http) => server.add(connection),
replace: (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key),
add: (connection) => server.add(connection),
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key),
}),
}
}
export function useServerActionsController() {
const server = useServer()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
const defaults = useDefaultServer()
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
} catch (err) {
showRequestError(language, err)
}
}
return { defaults, connection: { canRemove: server.canRemove, remove } }
}
export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() {
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
return {
collection: {
items: sorted,
current,
health: () => global.servers.health,
},
...actions,
}
}
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const language = useLanguage()
const mutations = useServerMutations()
const checkServerHealth = useCheckServerHealth()
const healthPreview = createServerHealthPreview(checkServerHealth)
const [store, setStore] = createStore({
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined as boolean | undefined,
})
onCleanup(healthPreview.cancel)
const reset = () => {
healthPreview.cancel()
setStore({
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined,
})
}
const allServers = () => {
if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
}
const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
)
const request = useMutation(() => ({
mutationFn: async () => {
const normalized = normalizeServerUrl(store.values.url)
if (!normalized) {
reset()
return
}
const original = store.mode === "edit" ? editing() : undefined
if (store.mode === "edit" && !original) return
const name = store.values.name.trim() || undefined
const username = store.values.username || undefined
const password = store.values.password || undefined
if (
original?.type === "http" &&
normalized === original.http.url &&
name === original.displayName &&
username === original.http.username &&
password === original.http.password
) {
reset()
return
}
const connection: ServerConnection.Http = {
type: "http",
displayName: name,
http: {
url: normalized,
username: store.mode === "add" && !password ? undefined : username,
password,
},
}
const result = await checkServerHealth(connection.http)
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
return
}
if (original?.type === "http") {
if (normalized === original.http.url) mutations.add(connection)
if (normalized !== original.http.url) mutations.replace(ServerConnection.key(original), connection)
reset()
return
}
reset()
if (options.navigateOnAdd === false) {
mutations.add(connection)
options.onSelect?.()
return
}
mutations.add(connection)
options.onSelect?.()
navigate("/")
},
}))
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
const change = (field: keyof ServerFormValues, value: string) => {
if (request.isPending) return
setStore("values", field, value)
setStore("error", "")
if (field !== "name") preview()
}
const startAdd = () => {
reset()
setStore("mode", "add")
}
const startEdit = (connection: ServerConnection.Http) => {
reset()
setStore({
mode: "edit",
originalUrl: connection.http.url,
values: {
url: connection.http.url,
name: connection.displayName ?? "",
username: connection.http.username ?? "",
password: connection.http.password ?? "",
},
error: "",
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
})
}
const submit = () => {
if (store.mode === "list" || request.isPending) return
setStore("error", "")
request.mutate()
}
createEffect(() => {
if (store.mode !== "edit") return
if (editing()) return
reset()
})
return {
state: {
mode: () => store.mode,
open: () => store.mode !== "list",
adding: () => store.mode === "add",
busy: () => request.isPending,
value: () => store.values.url,
name: () => store.values.name,
username: () => store.values.username,
password: () => store.values.password,
error: () => store.error,
status: () => store.status,
},
change: {
value: (value: string) => change("url", value),
name: (value: string) => change("name", value),
username: (value: string) => change("username", value),
password: (value: string) => change("password", value),
},
start: { add: startAdd, edit: startEdit },
reset,
submit,
}
}
export type ServerFormController = ReturnType<typeof useServerFormController>
@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
describe("createServerHealthPreview", () => {
test("ignores an older response that resolves after the latest response", async () => {
const first = deferred<{ healthy: boolean }>()
const second = deferred<{ healthy: boolean }>()
const requests = [first, second]
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => requests.shift()!.promise)
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
second.resolve({ healthy: true })
await latest
first.resolve({ healthy: false })
await older
expect(status).toEqual([undefined, undefined, true])
})
test("an incomplete value invalidates an in-flight response", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
await preview.preview(values("server"), (value) => status.push(value))
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined, undefined])
})
test("cancellation prevents an in-flight response from updating status", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
preview.cancel()
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined])
})
})
describe("replaceServerConnection", () => {
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
test("moves active selection after adding the replacement and removes the original", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com",
])
})
test("keeps the original when the replacement cannot be added", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key),
})
expect(removed).toEqual([])
})
})
@@ -0,0 +1,59 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
url: string
name: string
username: string
password: string
}
export function createServerHealthPreview(
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
) {
let generation = 0
const cancel = () => {
generation += 1
}
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
const current = ++generation
setStatus(undefined)
const normalized = normalizeServerUrl(values.url)
if (!normalized) return
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
return
const http: ServerConnection.HttpBase = { url: normalized }
if (values.username) http.username = values.username
if (values.password) http.password = values.password
const result = await check(http)
if (current !== generation) return
setStatus(result.healthy)
}
return { cancel, preview }
}
export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import { useServerManagementController } from "@/components/dialog-select-server" import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{ export const ServerRowMenu: Component<{
server: ServerConnection.Any server: ServerConnection.Any
controller: ReturnType<typeof useServerManagementController> domain: ServerActionsController
onEdit: (server: ServerConnection.Http) => void onEdit: (server: ServerConnection.Http) => void
open?: boolean open?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
<ServerRowMenuView <ServerRowMenuView
server={props.server} server={props.server}
labels={serverMenuLabels(language)} labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()} canDefault={props.domain.defaults.available()}
isDefault={props.controller.defaultKey() === key} isDefault={props.domain.defaults.key() === key}
canRemove={props.controller.canRemove(key)} canRemove={props.domain.connection.canRemove(key)}
onEdit={props.onEdit} onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)} onSetDefault={() => props.domain.defaults.set(key)}
onRemoveDefault={() => props.controller.setDefault(null)} onRemoveDefault={() => props.domain.defaults.set(null)}
onRemove={() => props.controller.handleRemove(key)} onRemove={() => props.domain.connection.remove(key)}
open={props.open} open={props.open}
onOpenChange={props.onOpenChange} onOpenChange={props.onOpenChange}
/> />
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type ServerConnection } from "@/context/server" import { type ServerConnection } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerFormController } from "../server/server-management-controller"
import "./settings-v2.css" import "./settings-v2.css"
export const DialogServerV2: Component<{ export const DialogServerV2: Component<{
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
}> = (props) => { }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController({ const form = useServerFormController({
onSelect: () => dialog.close(), onSelect: () => dialog.close(),
navigateOnAdd: false, navigateOnAdd: false,
}) })
const [opened, setOpened] = createSignal(false) const [opened, setOpened] = createSignal(false)
onMount(() => { onMount(() => {
if (props.mode === "add") controller.startAdd() if (props.mode === "add") form.start.add()
if (props.mode === "edit" && props.server) controller.startEdit(props.server) if (props.mode === "edit" && props.server) form.start.edit(props.server)
setOpened(true) setOpened(true)
}) })
onCleanup(() => { onCleanup(() => {
controller.resetForm() form.reset()
}) })
createEffect(() => { createEffect(() => {
if (!opened()) return if (!opened()) return
if (controller.isFormMode()) return if (form.state.open()) return
dialog.close() dialog.close()
}) })
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return if (event.key !== "Enter" || event.isComposing) return
event.preventDefault() event.preventDefault()
controller.submitForm() form.submit()
} }
const title = () => const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title") props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => { const submitLabel = () => {
if (controller.formBusy()) return language.t("dialog.server.add.checking") if (form.state.busy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button") if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save") return language.t("common.save")
} }
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formValue()} value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!controller.formError()} invalid={!!form.state.error()}
disabled={controller.formBusy()} disabled={form.state.busy()}
autofocus autofocus
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)} onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
<Show when={controller.formError()}> <Show when={form.state.error()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span> <span class="settings-v2-server-dialog-error">{form.state.error()}</span>
</Show> </Show>
</div> </div>
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex w-full min-w-0 flex-col gap-2">
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formName()} value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")} placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)} onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formUsername()} value={form.state.username()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")} placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)} onInput={(event) => form.change.username(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
type="password" type="password"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formPassword()} value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")} placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)} onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
</div> </div>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}> <ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</ButtonV2> </ButtonV2>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}> <ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()} {submitLabel()}
</ButtonV2> </ButtonV2>
</DialogFooter> </DialogFooter>
@@ -123,7 +123,9 @@ export const SettingsProvidersV2: Component<{
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? [] const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`) if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all( await Promise.all(
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })), credentials.map((credential) =>
serverSdk().api.credential.remove({ credentialID: credential.id, location }),
),
) )
showToast({ showToast({
variant: "success", variant: "success",
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2" import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
@@ -19,16 +19,16 @@ import "./settings-v2.css"
export const SettingsServersV2: Component = () => { export const SettingsServersV2: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController() const domain = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" }) const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter) const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo( const showSearch = createMemo(
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1, () => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
) )
const filtered = createMemo(() => { const filtered = createMemo(() => {
const items = controller.sortedItems().filter((item) => !isWslServer(item)) const items = domain.collection.items().filter((item) => !isWslServer(item))
const query = store.filter.trim() const query = store.filter.trim()
if (!query) return items if (!query) return items
return fuzzysort return fuzzysort
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
}) })
const openAdd = () => { const openAdd = () => {
dialog.push(() => <DialogServerV2 mode="add" />) void dialog.push(() => <DialogServerV2 mode="add" />)
} }
const openEdit = (server: ServerConnection.Http) => { const openEdit = (server: ServerConnection.Http) => {
dialog.push(() => <DialogServerV2 mode="edit" server={server} />) void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
} }
return ( return (
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
} }
> >
<SettingsListV2> <SettingsListV2>
<WslServerSettings controller={controller} servers={wslServers} /> <WslServerSettings domain={domain} servers={wslServers} />
<For each={filtered()}> <For each={filtered()}>
{(item) => { {(item) => {
const key = ServerConnection.key(item) const key = ServerConnection.key(item)
const health = () => controller.status()[key] const health = () => domain.collection.health()[key]
const isDefault = () => controller.defaultKey() === key const isDefault = () => domain.defaults.key() === key
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={controller.canDefault() && isDefault()}> <Show when={domain.defaults.available() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} /> <ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
</div> </div>
</div> </div>
) )
@@ -5,16 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
type Accessor,
createEffect,
createMemo,
createResource,
For,
type JSXElement,
onCleanup,
Show,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -300,10 +291,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource( const [pluginList] = createResource(
() => (props.shown() ? sdk().directory : undefined), () => (props.shown() ? sdk().directory : undefined),
(directory) => (directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
sdk()
.api.plugin.list({ location: { directory } })
.then((result) => result.data),
) )
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id)) const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const pluginCount = createMemo(() => plugins().length) const pluginCount = createMemo(() => plugins().length)
+4 -1
View File
@@ -190,7 +190,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
.find((item) => ServerConnection.key(item) === (route.server ?? server.key)) .find((item) => ServerConnection.key(item) === (route.server ?? server.key))
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
}, },
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}), ({ route, sdk }) =>
sdk.api.session
.get({ sessionID: route.sessionId })
.catch(() => {}),
) )
const matchRoute = (route: LayoutRoute) => { const matchRoute = (route: LayoutRoute) => {
+3 -1
View File
@@ -124,7 +124,9 @@ export const createDirSyncContext = (
const [store, setStore] = current() const [store, setStore] = current()
setStore("limit", (value) => value + count) setStore("limit", (value) => value + count)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data.sort((a, b) => cmp(a.id, b.id)).slice(0, store.limit) const sessions = response.data
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
sessions.forEach(serverSync.session.remember) sessions.forEach(serverSync.session.remember)
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
}, },
+124 -76
View File
@@ -1,4 +1,9 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import type {
Config,
Path,
Project,
ProviderAuthResponse,
} from "@/types"
import type { import type {
AgentListInput, AgentListInput,
AgentListOutput, AgentListOutput,
@@ -26,7 +31,12 @@ import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session" import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils" import {
cmp,
normalizeAgentList,
normalizeProjectInfo,
normalizeProviderList,
} from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
@@ -138,7 +148,10 @@ export async function bootstrapGlobal(input: {
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)), () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)), () =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI),
),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() => () =>
input.queryClient input.queryClient
@@ -198,7 +211,11 @@ function warmSessions(input: {
).then(() => undefined) ).then(() => undefined)
} }
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) => export const loadProvidersQuery = (
scope: ServerScope,
directory: string | null,
sdk: CatalogApi,
) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "providers"], queryKey: [scope, directory, "providers"],
queryFn: () => queryFn: () =>
@@ -225,16 +242,28 @@ type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput> readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
} }
export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) => export const loadAgentsQuery = (
scope: ServerScope,
directory: string,
sdk: AgentListApi,
) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "agents"], queryKey: [scope, directory, "agents"],
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))), queryFn: () =>
retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
}) })
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> => export const loadCommands = (
directory: string,
api: CommandListApi,
): Promise<CommandInfo[]> =>
retry(() => api.list({ location: { directory } }).then((result) => result.data)) retry(() => api.list({ location: { directory } }).then((result) => result.data))
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) => export const loadPathQuery = (
scope: ServerScope,
directory: string | null,
api: LocationApi,
) =>
queryOptions<Path>({ queryOptions<Path>({
queryKey: [scope, directory, "path"], queryKey: [scope, directory, "path"],
queryFn: () => queryFn: () =>
@@ -247,10 +276,15 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, api:
})), })),
}) })
export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) => export const loadReferencesQuery = (
scope: ServerScope,
directory: string,
api: ReferenceListApi,
) =>
queryOptions<ReferenceInfo[]>({ queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const, queryKey: [scope, directory, "references"] as const,
queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []), queryFn: () =>
retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
placeholderData: [], placeholderData: [],
}) })
@@ -319,41 +353,46 @@ export async function bootstrapDirectory(input: {
})), })),
input.mcp && input.mcp &&
(() => (() =>
loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))), loadCommands(input.directory, input.api.command).then((commands) =>
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)), input.setStore("command", commands),
)),
() =>
input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, input.directory, input.api.reference),
),
() => () =>
retry(() => retry(() =>
input.api.permission.request input.api.permission.request
.list({ location: { directory: input.directory } }) .list({ location: { directory: input.directory } })
.then((result) => result.data) .then((result) => result.data)
.then((permissions) => { .then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID) const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID), permissions.filter((permission) => !!permission.id && !!permission.sessionID),
) )
const warm = input.session const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() => return warm.then(() =>
batch(() => { batch(() => {
const current = input.session?.data.permission ?? input.store.permission const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) { for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, []) if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, []) if (!input.session) input.setStore("permission", sessionID, [])
} }
for (const [sessionID, permissions] of Object.entries(grouped)) { for (const [sessionID, permissions] of Object.entries(grouped)) {
const value = reconcile( const value = reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" }, { key: "id" },
) )
if (input.session) input.session.set("permission", sessionID, value) if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value) if (!input.session) input.setStore("permission", sessionID, value)
} }
}), }),
) )
}), }),
), ),
() => () =>
retry(() => retry(() =>
@@ -361,47 +400,56 @@ export async function bootstrapDirectory(input: {
.list({ location: { directory: input.directory } }) .list({ location: { directory: input.directory } })
.then((result) => result.data) .then((result) => result.data)
.then((questions) => { .then((questions) => {
const ids = questions.map((question) => question.sessionID) const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
) )
const warm = input.session const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() => return warm.then(() =>
batch(() => { batch(() => {
const current = input.session?.data.question ?? input.store.question const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) { for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, []) if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, []) if (!input.session) input.setStore("question", sessionID, [])
} }
for (const [sessionID, questions] of Object.entries(grouped)) { for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile( const value = reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" }, { key: "id" },
) )
if (input.session) input.session.set("question", sessionID, value) if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value) if (!input.session) input.setStore("question", sessionID, value)
} }
}), }),
) )
}), }),
), ),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
input.mcp && input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))), (() =>
input.queryClient.fetchQuery(
loadMcpQuery(input.scope, input.directory, input.api.mcp),
)),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp),
)),
() => () =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => { input.queryClient
const project = getFilename(input.directory) .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api))
showToast({ .catch((err) => {
variant: "error", const project = getFilename(input.directory)
title: input.translate("toast.project.reloadFailed.title", { project }), showToast({
description: formatServerError(err, input.translate), variant: "error",
}) title: input.translate("toast.project.reloadFailed.title", { project }),
}), description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
@@ -1,13 +1,12 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types"
import type { import type {
FileDiffInfo, Message,
PermissionRequest, Part,
QuestionRequest, Project,
SessionInfo, Todo,
SessionStatus, } from "@/types"
} from "@opencode-ai/client/promise" import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
@@ -81,9 +81,9 @@ describe("Home V2 session index", () => {
]) ])
expect(result).toEqual([ expect(result).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "root", id: "root",
location: { directory: "/project" }, location: { directory: "/project" },
projectID: "project", projectID: "project",
title: "root", title: "root",
time: { created: 1, updated: 30 }, time: { created: 1, updated: 30 },
+10 -1
View File
@@ -1,4 +1,13 @@
import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } from "@/types" import type {
Agent,
Config,
LspStatus,
Message,
Part,
Path,
Todo,
VcsInfo,
} from "@/types"
import type { import type {
FileDiffInfo, FileDiffInfo,
PermissionRequest, PermissionRequest,
@@ -153,88 +153,4 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
}) })
test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_cancelled",
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ missing: "msg_user" })
})
test("keeps steered input available to the promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_steered",
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_queued",
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
})
}) })
@@ -29,9 +29,6 @@ export function createV2SessionReducer() {
case "session.input.admitted": case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input) pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source]) return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": { case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID)) const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID)) pending.delete(key(sessionID, event.data.inputID))
+280 -279
View File
@@ -67,74 +67,64 @@ function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
}, },
] ]
} }
return [ return [{
{ id: item.info.id,
id: item.info.id, type: "assistant",
type: "assistant", agent: item.info.agent,
agent: item.info.agent, model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, content: item.parts.flatMap((part): SessionMessageAssistant["content"] => {
content: item.parts.flatMap((part): SessionMessageAssistant["content"] => { if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "text") return [{ type: "text", text: part.text }] if (part.type === "reasoning")
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
},
]
if (part.type !== "tool") return []
const state: SessionMessageAssistantTool["state"] = (() => {
if (part.state.status === "pending")
return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
if (part.state.status === "running")
return {
status: "running" as const,
input: part.state.input as CurrentToolObject,
metadata: (part.state.metadata ?? {}) as CurrentToolObject,
}
if (part.state.status === "error")
return {
status: "error" as const,
input: part.state.input as CurrentToolObject,
error: { type: "tool_error", message: part.state.error },
metadata: part.state.metadata as CurrentToolObject | undefined,
}
return {
status: "completed" as const,
input: part.state.input as CurrentToolObject,
content: [{ type: "text" as const, text: part.state.output }],
metadata: part.state.metadata as CurrentToolObject,
}
})()
return [ return [
{ {
id: part.id, type: "reasoning",
type: "tool" as const, text: part.text,
name: part.tool, time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
state,
time: {
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
ran: part.state.status === "pending" ? undefined : part.state.time.start,
completed:
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
},
}, },
] ]
}), if (part.type !== "tool") return []
time: item.info.time, const state: SessionMessageAssistantTool["state"] = (() => {
cost: item.info.cost, if (part.state.status === "pending") return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
tokens: item.info.tokens, if (part.state.status === "running")
finish: item.info.finish as return {
| "stop" status: "running" as const,
| "length" input: part.state.input as CurrentToolObject,
| "tool-calls" metadata: (part.state.metadata ?? {}) as CurrentToolObject,
| "content-filter" }
| "error" if (part.state.status === "error")
| "unknown" return {
| undefined, status: "error" as const,
}, input: part.state.input as CurrentToolObject,
] error: { type: "tool_error", message: part.state.error },
metadata: part.state.metadata as CurrentToolObject | undefined,
}
return {
status: "completed" as const,
input: part.state.input as CurrentToolObject,
content: [{ type: "text" as const, text: part.state.output }],
metadata: part.state.metadata as CurrentToolObject,
}
})()
return [
{
id: part.id,
type: "tool" as const,
name: part.tool,
state,
time: {
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
ran: part.state.status === "pending" ? undefined : part.state.time.start,
completed:
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
},
},
]
}),
time: item.info.time,
cost: item.info.cost,
tokens: item.info.tokens,
finish: item.info.finish as "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | undefined,
}]
}) })
} }
@@ -438,259 +428,270 @@ describe("server session", () => {
// V2 messages are ordered projections and do not expose V1 assistant parent IDs. // V2 messages are ordered projections and do not expose V1 assistant parent IDs.
describe.skip("V1 assistant parent projections", () => { describe.skip("V1 assistant parent projections", () => {
test("backfills an assistant-only initial page through its user root", async () => { test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))], [
[singleResponse(user)], response(assistants.map((info) => ({ info, parts: [] }))),
) ],
const store = createServerSession(client) [singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child") await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }]) expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(false) expect(store.history.more("child")).toBe(false)
}) })
test("keeps assistant history when its deleted parent cannot be backfilled", async () => { test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
const missing = Promise.withResolvers<SingleMessageResponse>() const missing = Promise.withResolvers<SingleMessageResponse>()
const assistant = assistantMessage("message-2", "message-missing") const assistant = assistantMessage("message-2", "message-missing")
const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise]) const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise])
const store = createServerSession(client) const store = createServerSession(client)
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } })) missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
await loading await loading
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
expect(store.data.message.child).toEqual([assistant]) expect(store.data.message.child).toEqual([assistant])
expect(store.history.more("child")).toBe(false) expect(store.history.more("child")).toBe(false)
}) })
test("drops a cached parent when a forced refresh confirms it was deleted", async () => { test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
const missing = Promise.withResolvers<SingleMessageResponse>() const missing = Promise.withResolvers<SingleMessageResponse>()
const parent = userMessage("message-1") const parent = userMessage("message-1")
const part = textPart(parent.id) const part = textPart(parent.id)
const assistant = assistantMessage("message-2", parent.id) const assistant = assistantMessage("message-2", parent.id)
const client = rootMessageClient( const client = rootMessageClient(
[ [
response([ response([
{ info: parent, parts: [part] }, { info: parent, parts: [part] },
{ info: assistant, parts: [] }, { info: assistant, parts: [] },
]), ]),
response([{ info: assistant, parts: [] }]), response([{ info: assistant, parts: [] }]),
], ],
[missing.promise], [missing.promise],
) )
const store = createServerSession(client) const store = createServerSession(client)
await store.sync("child") await store.sync("child")
const loading = store.sync("child", { force: true }) const loading = store.sync("child", { force: true })
await client.rootRequested(1) await client.rootRequested(1)
missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } })) missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
await loading await loading
expect(store.data.message.child).toEqual([assistant]) expect(store.data.message.child).toEqual([assistant])
expect(store.data.part[parent.id]).toBeUndefined() expect(store.data.part[parent.id]).toBeUndefined()
}) })
test("does not let an optimistic user suppress initial root backfill", async () => { test("does not let an optimistic user suppress initial root backfill", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const part = textPart(user.id) const part = textPart(user.id)
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))], [
[singleResponse(user)], response(assistants.map((info) => ({ info, parts: [] }))),
) ],
const store = createServerSession(client) [singleResponse(user)],
store.optimistic.add({ sessionID: "child", message: user, parts: [part] }) )
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
await store.sync("child") await store.sync("child")
store.optimistic.remove({ sessionID: "child", messageID: user.id }) store.optimistic.remove({ sessionID: "child", messageID: user.id })
expect(client.requests).toHaveLength(1) expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(1) expect(client.rootRequests).toHaveLength(1)
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
}) })
test("backfills the parent of fetched assistants when another user is cached", async () => { test("backfills the parent of fetched assistants when another user is cached", async () => {
const unrelated = userMessage("message-0", { time: { created: 0 } }) const unrelated = userMessage("message-0", { time: { created: 0 } })
const user = userMessage("message-1") const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: unrelated, parts: [] }]), response(assistants.map((info) => ({ info, parts: [] })))], [
[singleResponse(user)], response([{ info: unrelated, parts: [] }]),
) response(assistants.map((info) => ({ info, parts: [] }))),
const store = createServerSession(client) ],
await store.sync("child") [singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child")
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(client.requests).toHaveLength(2) expect(client.requests).toHaveLength(2)
expect(client.rootRequests).toHaveLength(1) expect(client.rootRequests).toHaveLength(1)
expect(store.data.message.child).toEqual([unrelated, user, ...assistants]) expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
}) })
test("preserves cached history between an injected parent and the page boundary", async () => { test("preserves cached history between an injected parent and the page boundary", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const cached = userMessage("message-3", { time: { created: 3 } }) const cached = userMessage("message-3", { time: { created: 3 } })
const assistant = assistantMessage("message-4", user.id) const assistant = assistantMessage("message-4", user.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])], [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(user)], [singleResponse(user)],
) )
const store = createServerSession(client) const store = createServerSession(client)
await store.sync("child") await store.sync("child")
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([user, cached, assistant]) expect(store.data.message.child).toEqual([user, cached, assistant])
}) })
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => { test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const stalePart = textPart(stale.id, { text: "stale" }) const stalePart = textPart(stale.id, { text: "stale" })
const freshPart = { ...stalePart, text: "fresh" } const freshPart = { ...stalePart, text: "fresh" }
const assistant = assistantMessage("message-2", stale.id) const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])], [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(fresh, [freshPart])], [singleResponse(fresh, [freshPart])],
) )
const store = createServerSession(client) const store = createServerSession(client)
await store.sync("child") await store.sync("child")
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant]) expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([freshPart]) expect(store.data.part[stale.id]).toEqual([freshPart])
}) })
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => { test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" }) const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
const refreshed = { ...confirmed, text: "fresh" } const refreshed = { ...confirmed, text: "fresh" }
const pending = textPart(stale.id, { id: "pending", text: "pending" }) const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id) const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])], [response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(fresh, [refreshed])], [singleResponse(fresh, [refreshed])],
) )
const store = createServerSession(client) const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] }) store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
await store.sync("child") await store.sync("child")
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant]) expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([refreshed, pending]) expect(store.data.part[stale.id]).toEqual([refreshed, pending])
}) })
test("uses a parent received by SSE during the replacement load", async () => { test("uses a parent received by SSE during the replacement load", async () => {
const pending = deferredResponse() const pending = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const client = rootMessageClient([pending.promise], []) const client = rootMessageClient([pending.promise], [])
const store = createServerSession(client) const store = createServerSession(client)
const loading = store.sync("child") const loading = store.sync("child")
store.apply({ type: "message.updated", properties: { info: user } }) store.apply({ type: "message.updated", properties: { info: user } })
pending.resolve(response([{ info: assistant, parts: [] }])) pending.resolve(response([{ info: assistant, parts: [] }]))
await loading await loading
expect(client.rootRequests).toEqual([]) expect(client.rootRequests).toEqual([])
expect(store.data.message.child).toEqual([user, assistant]) expect(store.data.message.child).toEqual([user, assistant])
}) })
test("uses a successful retry over events received by a failed backfill attempt", async () => { test("uses a successful retry over events received by a failed backfill attempt", async () => {
const failed = deferredResponse() const failed = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const live = { ...user, agent: "stale" } const live = { ...user, agent: "stale" }
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))], [
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], response(
) assistants.map((info) => ({ info, parts: [] })),
const store = createServerSession(client, { retry: retryImmediately }) ),
const loading = store.sync("child") ],
await client.rootRequested(1) [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } }) store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry")) failed.reject(new Error("retry"))
await loading await loading
expect(client.requests).toHaveLength(1) expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(2) expect(client.rootRequests).toHaveLength(2)
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
}) })
test("preserves newer-page events across a failed parent retry", async () => { test("preserves newer-page events across a failed parent retry", async () => {
const failed = deferredResponse() const failed = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const live = { ...assistant, cost: 1 } const live = { ...assistant, cost: 1 }
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [] }])], [response([{ info: assistant, parts: [] }])],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } }) store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry")) failed.reject(new Error("retry"))
await loading await loading
expect(store.data.message.child).toEqual([user, live]) expect(store.data.message.child).toEqual([user, live])
}) })
test("preserves unrelated message events across a failed parent retry", async () => { test("preserves unrelated message events across a failed parent retry", async () => {
const failed = deferredResponse() const failed = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const live = userMessage("message-4", { time: { created: 4 } }) const live = userMessage("message-4", { time: { created: 4 } })
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [] }])], [response([{ info: assistant, parts: [] }])],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } }) store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry")) failed.reject(new Error("retry"))
await loading await loading
expect(store.data.message.child).toEqual([user, assistant, live]) expect(store.data.message.child).toEqual([user, assistant, live])
}) })
test("preserves newer-page part events across a failed parent retry", async () => { test("preserves newer-page part events across a failed parent retry", async () => {
const failed = deferredResponse() const failed = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const stale = textPart(assistant.id, { text: "stale" }) const stale = textPart(assistant.id, { text: "stale" })
const live = { ...stale, text: "live" } const live = { ...stale, text: "live" }
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [stale] }])], [response([{ info: assistant, parts: [stale] }])],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
failed.reject(new Error("retry")) failed.reject(new Error("retry"))
await loading await loading
expect(store.data.part[assistant.id]).toEqual([live]) expect(store.data.part[assistant.id]).toEqual([live])
}) })
}) })
test("merges live events into the initial page", async () => { test("merges live events into the initial page", async () => {
+18 -12
View File
@@ -1,7 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types" import type {
Message,
Part,
Todo,
} from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
@@ -34,16 +38,14 @@ function projectMessageSource(message: Message): SessionMessageInfo[] {
{ id: message.id, type: "user", text: "", time: message.time }, { id: message.id, type: "user", text: "", time: message.time },
] ]
} }
return [ return [{
{ id: message.id,
id: message.id, type: "assistant",
type: "assistant", agent: message.agent ?? message.mode,
agent: message.agent ?? message.mode, model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
model: { id: message.modelID, providerID: message.providerID, variant: message.variant }, content: [],
content: [], time: message.time,
time: message.time, }]
},
]
} }
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
@@ -241,7 +243,11 @@ export function createServerSession(
const indexProjectedMessage = (message: Message) => { const indexProjectedMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? [] const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return if (current.some((item) => item.id === message.id)) return
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)])) setData(
"session_message",
message.sessionID,
reconcile([...current, ...projectMessageSource(message)]),
)
} }
const remember = (session: SessionInfo) => { const remember = (session: SessionInfo) => {
+12 -3
View File
@@ -1,4 +1,9 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import type {
Config,
Path,
Project,
ProviderAuthResponse,
} from "@/types"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -159,11 +164,15 @@ export function seedActiveSessionStatuses(
} }
} }
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) { function makeQueryOptionsApi(
scope: ServerScope,
serverAPI: ServerApi,
) {
return { return {
globalConfig: () => loadGlobalConfigQuery(scope), globalConfig: () => loadGlobalConfigQuery(scope),
projects: () => loadProjectsQuery(scope, serverAPI.project), projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI), providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location), path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
+4 -7
View File
@@ -268,13 +268,10 @@ function createWorkspaceTerminalSession(
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index] const pty = store.all[index]
if (!pty) return if (!pty) return
const data = await sdk.api.pty const data = await sdk.api.pty.create({ location, title: pty.title }).then((result) => result.data).catch((error: unknown) => {
.create({ location, title: pty.title }) console.error("Failed to clone terminal", error)
.then((result) => result.data) return undefined
.catch((error: unknown) => { })
console.error("Failed to clone terminal", error)
return undefined
})
if (!data?.id) return if (!data?.id) return
const active = store.active === pty.id const active = store.active === pty.id
@@ -1,5 +1,5 @@
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerManagementController } from "@/components/dialog-select-server" import { useServerActionsController } from "@/components/server/server-management-controller"
import { useSettingsCommand } from "@/components/settings-dialog" import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
const language = useLanguage() const language = useLanguage()
const notification = useNotification() const notification = useNotification()
const openSettings = useSettingsCommand() const openSettings = useSettingsCommand()
const serverManagement = useServerManagementController({ navigateOnAdd: false }) const serverManagement = useServerActionsController()
const [_state, setState, _, ready] = persisted( const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]), Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }), createStore({ collapsed: {} as Record<string, boolean> }),
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
const key = ServerConnection.key(conn) const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key]) setState("collapsed", key, !state().collapsed[key])
}, },
canDefault: serverManagement.canDefault, canDefault: serverManagement.defaults.available,
defaultKey: serverManagement.defaultKey, defaultKey: serverManagement.defaults.key,
setDefault: (conn: ServerConnection.Any | undefined) => setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null), serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)), canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)), remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />), edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer, focus: home.selection.focusServer,
}, },
+13 -12
View File
@@ -688,8 +688,6 @@ export default function Page() {
return { return {
queryKey: [...vcsKey(), mode] as const, queryKey: [...vcsKey(), mode] as const,
enabled, enabled,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: mode queryFn: mode
? () => ? () =>
sdk() sdk()
@@ -703,16 +701,6 @@ export default function Page() {
} }
}) })
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
createEffect(
on(
() => desktopReviewOpen() || mobileChanges(),
(open, previous) => {
if (!open || previous || !desktopFileTreeOpen() || vcsQuery.isFetching) return
refreshVcs()
},
{ defer: true },
),
)
const reviewDiffs = () => { const reviewDiffs = () => {
if (reviewMode() === "git" || reviewMode() === "branch") if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense // avoids suspense
@@ -959,6 +947,19 @@ export default function Page() {
), ),
) )
const stopVcs = sdk().event.listen((evt) => {
const details = evt.details as { type: string; properties?: unknown }
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
const props =
typeof details.properties === "object" && details.properties
? (details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect( createEffect(
on( on(
() => sdk().directory, () => sdk().directory,
@@ -45,7 +45,13 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { AssistantMessage, Message as MessageType, Part as PartType, ToolPart, UserMessage } from "@/types" import type {
AssistantMessage,
Message as MessageType,
Part as PartType,
ToolPart,
UserMessage,
} from "@/types"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover" import { Popover as KobaltePopover } from "@kobalte/core/popover"
@@ -54,9 +54,9 @@ describe("reviewDiffNeedsLoad", () => {
patch: "@@ -0,0 +1 @@\n+value", patch: "@@ -0,0 +1 @@\n+value",
}), }),
).toBe(false) ).toBe(false)
expect(reviewDiffNeedsLoad({ file: "empty.txt", patch: "", additions: 0, deletions: 0, status: "modified" })).toBe( expect(
false, reviewDiffNeedsLoad({ file: "empty.txt", patch: "", additions: 0, deletions: 0, status: "modified" }),
) ).toBe(false)
}) })
}) })
+5 -1
View File
@@ -1,4 +1,8 @@
import type { EventSubscribeOutput, FileDiffInfo, ProjectListOutput } from "@opencode-ai/client/promise" import type {
EventSubscribeOutput,
FileDiffInfo,
ProjectListOutput,
} from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string } export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string }
+10 -12
View File
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { type Accessor, For, Show, createMemo } from "solid-js" import { type Accessor, For, Show, createMemo } from "solid-js"
import type { useServerManagementController } from "@/components/dialog-select-server" import type { ServerCollectionController } from "@/components/server/server-management-controller"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -17,8 +17,6 @@ import { DialogAddWslServer } from "./dialog-add-server"
import { useWslServers } from "./context" import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model" import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
type Controller = ReturnType<typeof useServerManagementController>
export function isWslServer(server: ServerConnection.Any) { export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl" return server.type === "sidecar" && server.variant === "wsl"
} }
@@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const openAddWsl = () => { const openAddWsl = () => {
dialog.push(() => <DialogAddWslServer />) void dialog.push(() => <DialogAddWslServer />)
} }
return ( return (
<Show <Show
@@ -67,7 +65,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
} }
export function WslServerSettings(props: { export function WslServerSettings(props: {
controller: Controller domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
servers: ReturnType<typeof useFilteredWslServers> servers: ReturnType<typeof useFilteredWslServers>
}) { }) {
const platform = usePlatform() const platform = usePlatform()
@@ -86,7 +84,7 @@ export function WslServerSettings(props: {
})) }))
const remove = (key: ServerConnection.Key) => { const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.controller.handleRemove(key)) request.mutate(() => props.domain.connection.remove(key))
} }
return ( return (
@@ -100,7 +98,7 @@ export function WslServerSettings(props: {
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={props.domain.collection.health()[key]} />
<div class="settings-v2-servers-copy"> <div class="settings-v2-servers-copy">
<span class="flex min-w-0 items-center gap-1"> <span class="flex min-w-0 items-center gap-1">
<span class="settings-v2-servers-name">{item.config.distro}</span> <span class="settings-v2-servers-name">{item.config.distro}</span>
@@ -114,7 +112,7 @@ export function WslServerSettings(props: {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<Show when={opencodeAction()}> <Show when={opencodeAction()}>
@@ -145,13 +143,13 @@ export function WslServerSettings(props: {
{language.t("wsl.server.retryStart")} {language.t("wsl.server.retryStart")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}> <MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.default")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}> <MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
+1 -2
View File
@@ -22,6 +22,5 @@
} }
}, },
"include": ["src", "package.json"], "include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"], "exclude": ["dist", "ts-dist"]
"references": [{ "path": "../core" }]
} }
+2 -12
View File
@@ -119,15 +119,7 @@ function installPackage(name) {
try { try {
const result = childProcess.spawnSync( const result = childProcess.spawnSync(
"npm", "npm",
[ ["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`],
"install",
"--ignore-scripts",
"--no-save",
"--loglevel=error",
"--prefix",
temp,
`${name}@${dependencies[name]}`,
],
{ stdio: "inherit", windowsHide: true }, { stdio: "inherit", windowsHide: true },
) )
if (result.status !== 0) return false if (result.status !== 0) return false
@@ -158,9 +150,7 @@ function main() {
} }
} }
throw new Error( throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`)
`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`,
)
} }
try { try {
+13 -11
View File
@@ -40,13 +40,15 @@ try {
const token = encodeURIComponent(credential) const token = encodeURIComponent(credential)
const health = await waitForReady(info.url, headers) const health = await waitForReady(info.url, headers)
if (health.pid !== info.pid) throw new Error("Health process does not match registration") if (health.pid !== info.pid) throw new Error("Health process does not match registration")
const tokenHealth = await fetch(new URL(`/api/health?auth_token=${token}`, info.url), { const tokenHealth = await fetch(
signal: AbortSignal.timeout(5_000), new URL(`/api/health?auth_token=${token}`, info.url),
}) { signal: AbortSignal.timeout(5_000) },
)
if (tokenHealth.status !== 200) throw new Error("Compiled service rejected query authentication") if (tokenHealth.status !== 200) throw new Error("Compiled service rejected query authentication")
const tokenOpenApi = await fetch(new URL(`/openapi.json?auth_token=${token}`, info.url), { const tokenOpenApi = await fetch(
signal: AbortSignal.timeout(5_000), new URL(`/openapi.json?auth_token=${token}`, info.url),
}) { signal: AbortSignal.timeout(5_000) },
)
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication") if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation") if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts") const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
@@ -61,8 +63,7 @@ try {
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), { const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
signal: AbortSignal.timeout(5_000), signal: AbortSignal.timeout(5_000),
}) })
if (unauthorizedOpenApi.status !== 401) if (unauthorizedOpenApi.status !== 401) throw new Error("Compiled service exposed application routes without authentication")
throw new Error("Compiled service exposed application routes without authentication")
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), { const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -93,8 +94,7 @@ try {
} finally { } finally {
processes.forEach((process) => process.kill()) processes.forEach((process) => process.kill())
await Promise.all(processes.map((process) => process.exited)) await Promise.all(processes.map((process) => process.exited))
if (failure) if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
} }
const output = await Promise.all(errors) const output = await Promise.all(errors)
@@ -154,7 +154,9 @@ async function pluginIDs(url: string, headers: HeadersInit) {
throw new Error("Compiled service returned an invalid plugin list") throw new Error("Compiled service returned an invalid plugin list")
} }
return body.data.flatMap((plugin) => return body.data.flatMap((plugin) =>
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string" ? [plugin.id] : [], typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string"
? [plugin.id]
: [],
) )
} }
+4 -5
View File
@@ -20,8 +20,7 @@ export default Runtime.handler(
return response.text() return response.text()
}) })
: Bun.file(input.file).text(), : Bun.file(input.file).text(),
catch: (cause) => catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
}) })
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text) const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data) const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
@@ -53,9 +52,9 @@ export default Runtime.handler(
return return
} }
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`)) if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
const imported = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ data: Session.Info })))( const imported = yield* Schema.decodeUnknownEffect(
yield* Effect.promise(() => response.text()), Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
) )(yield* Effect.promise(() => response.text()))
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`) process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
}), }),
) )
+3 -1
View File
@@ -36,6 +36,8 @@ export default Runtime.handler(
const hostname = new URL(endpoint.url).hostname const hostname = new URL(endpoint.url).hostname
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`) process.stderr.write(
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,
)
}), }),
) )
+3 -7
View File
@@ -48,16 +48,12 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
const attentionSoundPack = kv.attention_sound_pack const attentionSoundPack = kv.attention_sound_pack
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined) const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
const thinking = const thinking =
kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide") kv.thinking_mode ??
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
return { return {
...(themeName !== undefined || themeMode !== undefined ...(themeName !== undefined || themeMode !== undefined
? { ? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
theme: {
...(themeName === undefined ? {} : { name: themeName }),
...(themeMode === undefined ? {} : { mode: themeMode }),
},
}
: {}), : {}),
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }), ...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(plugins.length ? { plugins } : {}), ...(plugins.length ? { plugins } : {}),
+4 -1
View File
@@ -468,7 +468,10 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.step.failed") { if (event.type === "session.step.failed") {
if (input.compatibility === "v1" && event.data.error.message === "The provider response ended unexpectedly.") { if (
input.compatibility === "v1" &&
event.data.error.message === "The provider response ended unexpectedly."
) {
pendingStep = undefined pendingStep = undefined
v1InvalidOutput = true v1InvalidOutput = true
continue continue
@@ -62,7 +62,10 @@ function managedService(options: EnsureOptions) {
} }
} }
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) { const resolveManaged = Effect.fnUntraced(function* (
options: EnsureOptions,
mismatch: NonNullable<Args["mismatch"]>,
) {
if (mismatch === "replace") return yield* Service.ensure(options) if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined }) if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
@@ -413,7 +413,9 @@ function UpdateFooter(props: {
const completion = smoothstep(headerFade.progress()) const completion = smoothstep(headerFade.progress())
return Array.from({ length: width }, (_, index) => { return Array.from({ length: width }, (_, index) => {
const color = const color =
index >= filled ? colors.muted : shade(railRamp, Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2) index >= filled
? colors.muted
: shade(railRamp, Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2)
return { return {
char: success || index < filled ? "━" : "·", char: success || index < filled ? "━" : "·",
color: success ? blend(color, colors.accent, completion) : color, color: success ? blend(color, colors.accent, completion) : color,
+35 -36
View File
@@ -52,10 +52,9 @@ export const layer = Layer.effect(
const readPolicy = Effect.fnUntraced(function* () { const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) => const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs.readFileString(path.join(global.config, name)).pipe( fs
Effect.map(decodePolicy), .readFileString(path.join(global.config, name))
Effect.catch(() => Effect.succeed(undefined)), .pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
),
) )
return values.findLast((value) => value !== undefined) ?? true return values.findLast((value) => value !== undefined) ?? true
}) })
@@ -95,10 +94,10 @@ export const layer = Layer.effect(
const latest = Effect.fnUntraced(function* () { const latest = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({ const response = yield* Effect.tryPromise({
try: () => try: () =>
fetch(`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, { fetch(
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, `https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`,
signal: AbortSignal.timeout(10_000), { headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) },
}), ),
catch: (cause) => new Error("Failed to check for updates", { cause }), catch: (cause) => new Error("Failed to check for updates", { cause }),
}) })
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`)) if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
@@ -119,7 +118,7 @@ export const layer = Layer.effect(
pnpm: ["pnpm", "install", "--global", target], pnpm: ["pnpm", "install", "--global", target],
yarn: ["yarn", "global", "add", target], yarn: ["yarn", "global", "add", target],
} }
const result = yield* method === "bun" const result = yield* (method === "bun"
? Effect.scoped( ? Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
// Bun does not prune old versions from its shared package cache. // Bun does not prune old versions from its shared package cache.
@@ -128,38 +127,38 @@ export const layer = Layer.effect(
return yield* run(["bun", "install", "--global", "--cache-dir", cache, target], "5 minutes") return yield* run(["bun", "install", "--global", "--cache-dir", cache, target], "5 minutes")
}), }),
) )
: run(commands[method], "5 minutes") : run(commands[method], "5 minutes"))
if (result.code === 0) return if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`)) return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
}) })
const check = Effect.fn("cli.updater.check")( const check = Effect.fn("cli.updater.check")(function* () {
function* () { if (
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) OPENCODE_LOCAL ||
return yield* Effect.logInfo("update check skipped", { ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
reason: OPENCODE_LOCAL ? "local-install" : "disabled", )
version: OPENCODE_VERSION, return yield* Effect.logInfo("update check skipped", {
channel: OPENCODE_CHANNEL, reason: OPENCODE_LOCAL ? "local-install" : "disabled",
}) version: OPENCODE_VERSION,
const policy = yield* readPolicy() channel: OPENCODE_CHANNEL,
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return yield* Effect.gen(function* () {
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
}) })
}, const policy = yield* readPolicy()
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })), if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
)
return yield* Effect.gen(function* () {
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
})
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
return Service.of({ check }) return Service.of({ check })
}), }),
+2 -2
View File
@@ -141,7 +141,7 @@ export async function createTimelineHost(): Promise<TimelineHost> {
if (closeTask) return closeTask if (closeTask) return closeTask
closed = true closed = true
closeTask = (async () => { closeTask = (async () => {
await active?.catch(() => {}) await active?.catch(() => { })
signals.forEach((signal) => process.off(signal, cancel)) signals.forEach((signal) => process.off(signal, cancel))
})() })()
return closeTask return closeTask
@@ -211,7 +211,7 @@ export async function createTimelineHost(): Promise<TimelineHost> {
if (closeTask) return closeTask if (closeTask) return closeTask
closed = true closed = true
closeTask = (async () => { closeTask = (async () => {
await active?.catch(() => {}) await active?.catch(() => { })
try { try {
await shutdown(activeRenderer) await shutdown(activeRenderer)
await bounded(renderTask) await bounded(renderTask)
+4 -2
View File
@@ -102,7 +102,9 @@ test("migrates before the first update and does not remigrate afterward", async
draft.animations = false draft.animations = false
draft.mouse = false draft.mouse = false
}) })
yield* Effect.promise(() => Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" }))) yield* Effect.promise(() =>
Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })),
)
return yield* service.get() return yield* service.get()
}), }),
) )
@@ -120,7 +122,7 @@ test("migrates before the first update and does not remigrate afterward", async
test("updates a config draft while preserving JSONC comments", async () => { test("updates a config draft while preserving JSONC comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n') await Bun.write(path.join(directory, "cli.json"), "{\n // Keep this comment\n \"animations\": true\n}\n")
try { try {
const config = await run( const config = await run(
@@ -17,9 +17,7 @@ export default defineScript({
const session = `mini-stage2-${process.pid}` const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2") const snapshots = path.join(artifacts, "mini-stage2")
const explicitDirectory = path.join(artifacts, "explicit-model") const explicitDirectory = path.join(artifacts, "explicit-model")
yield* Effect.promise(() => yield* Effect.promise(() => Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))))
Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))),
)
/** @param {string} directory @param {string | undefined} model */ /** @param {string} directory @param {string | undefined} model */
const mini = (directory, model) => [ const mini = (directory, model) => [
"env", "env",
@@ -171,7 +169,14 @@ export default defineScript({
yield* Effect.promise(() => tmux(["clear-history", "-t", session])) yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
yield* Effect.promise(() => yield* Effect.promise(() =>
tmux(["respawn-pane", "-k", "-t", session, "--", ...mini(explicitDirectory, "simulation/gpt-sim-model")]), tmux([
"respawn-pane",
"-k",
"-t",
session,
"--",
...mini(explicitDirectory, "simulation/gpt-sim-model"),
]),
) )
const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000)) const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel)) yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
+8 -1
View File
@@ -159,7 +159,14 @@ test("import validates a file and sends it to the resolved location", async () =
}) })
try { try {
const [stdout, , exitCode] = await run(["import", file, "--directory", root, "--server", server.url.toString()]) const [stdout, , exitCode] = await run([
"import",
file,
"--directory",
root,
"--server",
server.url.toString(),
])
expect(exitCode).toBe(0) expect(exitCode).toBe(0)
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`) expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
+7 -1
View File
@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises"
import { createRequire } from "node:module" import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite" import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid" import solid from "vite-plugin-solid"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target" import {
nodeExecArgv,
nodeTarget,
type NodeTarget,
photonWasmAsset,
shellParserWasmAssets,
} from "./src/node/target"
const dir = import.meta.dirname const dir = import.meta.dirname

Some files were not shown because too many files have changed in this diff Show More