From f44661feb7f99a87eae2b9ea790b62388d723d61 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 02:09:08 -0700 Subject: [PATCH 01/35] fix(deps): update dependency mermaid to v11.10.0 [security] (#7231) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 129 ++++++++++++++++++++++++++++--------------------- 1 file changed, 74 insertions(+), 55 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff063ecb0d..6b05ddb264 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -985,7 +985,7 @@ importers: version: 0.518.0(react@18.3.1) mermaid: specifier: ^11.4.1 - version: 11.6.0 + version: 11.10.0 posthog-js: specifier: ^1.227.2 version: 1.242.1 @@ -1446,8 +1446,8 @@ packages: resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.28.2': - resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==} + '@babel/runtime@7.28.3': + resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} engines: {node: '>=6.9.0'} '@babel/template@7.27.2': @@ -2025,8 +2025,8 @@ packages: '@libsql/client@0.15.8': resolution: {integrity: sha512-TskygwF+ToZeWhPPT0WennyGrP3tmkKraaKopT2YwUjqD6DWDRm6SG5iy0VqnaO+HC9FNBCDX0oQPODU3gqqPQ==} - '@libsql/core@0.15.10': - resolution: {integrity: sha512-fAMD+GnGQNdZ9zxeNC8AiExpKnou/97GJWkiDDZbTRHj3c9dvF1y4jsRQ0WE72m/CqTdbMGyU98yL0SJ9hQVeg==} + '@libsql/core@0.15.12': + resolution: {integrity: sha512-S3tF6885ZizVjfym7f8SevL2VId/+DzxiKmP5zFbrhA8oMLh2XH8bYXChmhab7o9qUSHx+XjK4jCFpUwR5g+Ig==} '@libsql/darwin-arm64@0.5.17': resolution: {integrity: sha512-WTYG2skZsUnZmfZ2v7WFj7s3/5s2PfrYBZOWBKOnxHA8g4XCDc/4bFDaqob9Q2e88+GC7cWeJ8VNkVBFpD2Xxg==} @@ -2099,8 +2099,8 @@ packages: resolution: {integrity: sha512-LyQz4XJIdCdY/+temIhD/Ed0x/p4GAOUycpFSEK2Ads1CPKZy6b7V/2ROEtQiLLQ8soIs0xe/QAoR6kwpyW/yw==} engines: {node: '>=12'} - '@mermaid-js/parser@0.4.0': - resolution: {integrity: sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==} + '@mermaid-js/parser@0.6.2': + resolution: {integrity: sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==} '@microsoft/fast-element@1.14.0': resolution: {integrity: sha512-zXvuSOzvsu8zDTy9eby8ix8VqLop2rwKRgp++ZN2kTCsoB3+QJVoaGD2T/Cyso2ViZQFXNpiNCVKfnmxBvmWkQ==} @@ -3743,8 +3743,8 @@ packages: '@types/d3-delaunay@6.0.4': resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} - '@types/d3-dispatch@3.0.6': - resolution: {integrity: sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==} + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} '@types/d3-drag@3.0.7': resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} @@ -3916,6 +3916,9 @@ packages: '@types/node@20.17.57': resolution: {integrity: sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==} + '@types/node@20.19.11': + resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==} + '@types/node@24.2.1': resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==} @@ -4868,8 +4871,8 @@ packages: peerDependencies: cytoscape: ^3.2.0 - cytoscape@3.32.0: - resolution: {integrity: sha512-5JHBC9n75kz5851jeklCPmZWcg3hUe6sjqJvyk3+hVqFaKcHwHgxsjeN1yLmggoUc6STbtm9/NQyabQehfjvWQ==} + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} engines: {node: '>=0.10'} d3-array@2.12.1: @@ -5224,8 +5227,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.2.5: - resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==} + dompurify@3.2.6: + resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -5696,8 +5699,8 @@ packages: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} - exsolve@1.0.5: - resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==} + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} @@ -6655,8 +6658,8 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-base64@3.7.7: - resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} js-cookie@2.2.1: resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} @@ -7146,9 +7149,9 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@15.0.11: - resolution: {integrity: sha512-1BEXAU2euRCG3xwgLVT1y0xbJEld1XOrmRJpUwRCcy7rxhSCwMrmEu9LXoPhHSCJG41V7YcQ2mjKRr5BA3ITIA==} - engines: {node: '>= 18'} + marked@16.2.0: + resolution: {integrity: sha512-LbbTuye+0dWRz2TS9KJ7wsnD4KAtpj0MVkWc90XvBa6AslXsT0hTBVH5k32pcSyHH1fst9XEFJunXHktVy0zlg==} + engines: {node: '>= 20'} hasBin: true math-intrinsics@1.1.0: @@ -7243,8 +7246,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.6.0: - resolution: {integrity: sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==} + mermaid@11.10.0: + resolution: {integrity: sha512-oQsFzPBy9xlpnGxUqLbVY8pvknLlsNIJ0NWwi8SUJjhbP1IT0E0o1lfhU4iYV3ubpy+xkzkaOyDUQMn06vQElQ==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -7924,8 +7927,8 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - pkg-types@2.1.0: - resolution: {integrity: sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==} + pkg-types@2.2.0: + resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==} points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -8151,6 +8154,9 @@ packages: quansync@0.2.10: resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -9316,6 +9322,9 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} @@ -10658,7 +10667,7 @@ snapshots: '@babel/runtime@7.27.6': {} - '@babel/runtime@7.28.2': {} + '@babel/runtime@7.28.3': {} '@babel/template@7.27.2': dependencies: @@ -11270,9 +11279,9 @@ snapshots: '@libsql/client@0.15.8': dependencies: - '@libsql/core': 0.15.10 + '@libsql/core': 0.15.12 '@libsql/hrana-client': 0.7.0 - js-base64: 3.7.7 + js-base64: 3.7.8 libsql: 0.5.17 promise-limit: 2.7.0 transitivePeerDependencies: @@ -11280,9 +11289,9 @@ snapshots: - utf-8-validate optional: true - '@libsql/core@0.15.10': + '@libsql/core@0.15.12': dependencies: - js-base64: 3.7.7 + js-base64: 3.7.8 optional: true '@libsql/darwin-arm64@0.5.17': @@ -11295,7 +11304,7 @@ snapshots: dependencies: '@libsql/isomorphic-fetch': 0.3.1 '@libsql/isomorphic-ws': 0.1.5 - js-base64: 3.7.7 + js-base64: 3.7.8 node-fetch: 3.3.2 transitivePeerDependencies: - bufferutil @@ -11373,7 +11382,7 @@ snapshots: dependencies: unist-util-visit: 1.4.1 - '@mermaid-js/parser@0.4.0': + '@mermaid-js/parser@0.6.2': dependencies: langium: 3.3.1 @@ -13031,7 +13040,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.2 + '@babel/runtime': 7.28.3 '@types/aria-query': 5.0.4 aria-query: 5.3.0 chalk: 4.1.2 @@ -13120,7 +13129,7 @@ snapshots: '@types/d3-delaunay@6.0.4': {} - '@types/d3-dispatch@3.0.6': {} + '@types/d3-dispatch@3.0.7': {} '@types/d3-drag@3.0.7': dependencies: @@ -13192,7 +13201,7 @@ snapshots: '@types/d3-color': 3.1.3 '@types/d3-contour': 3.0.6 '@types/d3-delaunay': 6.0.4 - '@types/d3-dispatch': 3.0.6 + '@types/d3-dispatch': 3.0.7 '@types/d3-drag': 3.0.7 '@types/d3-dsv': 3.0.7 '@types/d3-ease': 3.0.2 @@ -13319,6 +13328,11 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@20.19.11': + dependencies: + undici-types: 6.21.0 + optional: true + '@types/node@24.2.1': dependencies: undici-types: 7.10.0 @@ -13382,7 +13396,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.2.1 + '@types/node': 20.19.11 optional: true '@types/yargs-parser@21.0.3': {} @@ -14420,17 +14434,17 @@ snapshots: csstype@3.1.3: {} - cytoscape-cose-bilkent@4.1.0(cytoscape@3.32.0): + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): dependencies: cose-base: 1.0.3 - cytoscape: 3.32.0 + cytoscape: 3.33.1 - cytoscape-fcose@2.2.0(cytoscape@3.32.0): + cytoscape-fcose@2.2.0(cytoscape@3.33.1): dependencies: cose-base: 2.2.0 - cytoscape: 3.32.0 + cytoscape: 3.33.1 - cytoscape@3.32.0: {} + cytoscape@3.33.1: {} d3-array@2.12.1: dependencies: @@ -14775,7 +14789,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.2.5: + dompurify@3.2.6: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -15387,7 +15401,7 @@ snapshots: transitivePeerDependencies: - supports-color - exsolve@1.0.5: {} + exsolve@1.0.7: {} extend-shallow@2.0.1: dependencies: @@ -16430,7 +16444,7 @@ snapshots: joycon@3.1.1: {} - js-base64@3.7.7: + js-base64@3.7.8: optional: true js-cookie@2.2.1: {} @@ -16774,8 +16788,8 @@ snapshots: local-pkg@1.1.1: dependencies: mlly: 1.7.4 - pkg-types: 2.1.0 - quansync: 0.2.10 + pkg-types: 2.2.0 + quansync: 0.2.11 locate-path@5.0.0: dependencies: @@ -16933,7 +16947,7 @@ snapshots: markdown-table@3.0.4: {} - marked@15.0.11: {} + marked@16.2.0: {} math-intrinsics@1.1.0: {} @@ -17145,24 +17159,24 @@ snapshots: merge2@1.4.1: {} - mermaid@11.6.0: + mermaid@11.10.0: dependencies: '@braintree/sanitize-url': 7.1.1 '@iconify/utils': 2.3.0 - '@mermaid-js/parser': 0.4.0 + '@mermaid-js/parser': 0.6.2 '@types/d3': 7.4.3 - cytoscape: 3.32.0 - cytoscape-cose-bilkent: 4.1.0(cytoscape@3.32.0) - cytoscape-fcose: 2.2.0(cytoscape@3.32.0) + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) d3: 7.9.0 d3-sankey: 0.12.3 dagre-d3-es: 7.0.11 dayjs: 1.11.13 - dompurify: 3.2.5 + dompurify: 3.2.6 katex: 0.16.22 khroma: 2.1.0 lodash-es: 4.17.21 - marked: 15.0.11 + marked: 16.2.0 roughjs: 4.6.6 stylis: 4.3.6 ts-dedent: 2.2.0 @@ -17991,10 +18005,10 @@ snapshots: mlly: 1.7.4 pathe: 2.0.3 - pkg-types@2.1.0: + pkg-types@2.2.0: dependencies: confbox: 0.2.2 - exsolve: 1.0.5 + exsolve: 1.0.7 pathe: 2.0.3 points-on-curve@0.2.0: {} @@ -18243,6 +18257,8 @@ snapshots: quansync@0.2.10: {} + quansync@0.2.11: {} + queue-microtask@1.2.3: {} randombytes@2.1.0: @@ -19637,6 +19653,9 @@ snapshots: undici-types@6.19.8: {} + undici-types@6.21.0: + optional: true + undici-types@7.10.0: {} undici@6.21.3: {} From faece9e26fe8b8d29f24ae04cc145c9b9a192765 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 02:12:56 -0700 Subject: [PATCH 02/35] feat: enable loading Roo modes from multiple files in .roo/roo_modes directory (#7203) * feat: enable loading Roo modes from multiple files in .roo/roo_modes directory - Add support for loading modes from .roo/roo_modes directory (both global and project) - Implement proper precedence: project .roo/roo_modes > .roomodes > global .roo/roo_modes > settings - Update file watchers to monitor .roo/roo_modes directories - Preserve original source file when updating modes - Add comprehensive tests for the new functionality Fixes #7202 * refactor: rename .roo/roo_modes to .roo/modes for cleaner directory structure - Changed ROO_MODES_DIR constant from "roo_modes" to "modes" - Updated all references in CustomModesManager.ts - Updated all test descriptions and mocks in CustomModesManager.spec.ts - All tests passing successfully * fix: normalize sourceFile paths to use forward slashes in exported YAML - Fixes Windows test failure where backslashes in sourceFile paths were causing test assertions to fail - Ensures cross-platform compatibility by normalizing all paths to use forward slashes in YAML exports --------- Co-authored-by: Roo Code Co-authored-by: daniel-lxs --- src/core/config/CustomModesManager.ts | 376 +++++++++++------- .../__tests__/CustomModesManager.spec.ts | 200 +++++++++- 2 files changed, 410 insertions(+), 166 deletions(-) diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index a9a2e6a6b5..160dc8a286 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -10,13 +10,14 @@ import { type ModeConfig, type PromptComponent, customModesSettingsSchema, modeC import { fileExistsAtPath } from "../../utils/fs" import { getWorkspacePath } from "../../utils/path" -import { getGlobalRooDirectory } from "../../services/roo-config" +import { getGlobalRooDirectory, getRooDirectoriesForCwd } from "../../services/roo-config" import { logger } from "../../utils/logging" import { GlobalFileNames } from "../../shared/globalFileNames" import { ensureSettingsDirectoryExists } from "../../utils/globalContext" import { t } from "../../i18n" const ROOMODES_FILENAME = ".roomodes" +const ROO_MODES_DIR = "modes" // Type definitions for import/export functionality interface RuleFile { @@ -179,7 +180,7 @@ export class CustomModesManager { } } - private async loadModesFromFile(filePath: string): Promise { + private async loadModesFromFile(filePath: string, source?: "global" | "project"): Promise { try { const content = await fs.readFile(filePath, "utf-8") const settings = this.parseYamlSafely(content, filePath) @@ -206,12 +207,19 @@ export class CustomModesManager { return [] } - // Determine source based on file path - const isRoomodes = filePath.endsWith(ROOMODES_FILENAME) - const source = isRoomodes ? ("project" as const) : ("global" as const) + // Determine source based on file path if not provided + if (!source) { + const isRoomodes = filePath.endsWith(ROOMODES_FILENAME) + const isInRooModesDir = filePath.includes(ROO_MODES_DIR) + source = isRoomodes || isInRooModesDir ? ("project" as const) : ("global" as const) + } - // Add source to each mode - return result.data.customModes.map((mode) => ({ ...mode, source })) + // Add source and sourceFile to each mode + return result.data.customModes.map((mode) => ({ + ...mode, + source, + sourceFile: filePath, + })) } catch (error) { // Only log if the error wasn't already handled in parseYamlSafely if (!(error as any).alreadyHandled) { @@ -222,6 +230,37 @@ export class CustomModesManager { } } + /** + * Load modes from all YAML files in a directory + */ + private async loadModesFromDirectory(dirPath: string, source: "global" | "project"): Promise { + const modes: ModeConfig[] = [] + + try { + // Check if directory exists + const dirExists = await fileExistsAtPath(dirPath) + if (!dirExists) { + return modes + } + + // Read all files in the directory + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + + // Process each YAML file + for (const entry of entries) { + if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) { + const filePath = path.join(dirPath, entry.name) + const fileModes = await this.loadModesFromFile(filePath, source) + modes.push(...fileModes) + } + } + } catch (error) { + console.error(`[CustomModesManager] Error loading modes from directory ${dirPath}:`, error) + } + + return modes + } + private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise { const slugs = new Set() const merged: ModeConfig[] = [] @@ -265,90 +304,55 @@ export class CustomModesManager { const settingsPath = await this.getCustomModesFilePath() - // Watch settings file - const settingsWatcher = vscode.workspace.createFileSystemWatcher(settingsPath) - - const handleSettingsChange = async () => { + // Common handler for any mode file change + const handleModeFileChange = async () => { try { - // Ensure that the settings file exists (especially important for delete events) - await this.getCustomModesFilePath() - const content = await fs.readFile(settingsPath, "utf-8") - - const errorMessage = t("common:customModes.errors.invalidFormat") - - let config: any - - try { - config = this.parseYamlSafely(content, settingsPath) - } catch (error) { - console.error(error) - vscode.window.showErrorMessage(errorMessage) - return - } - - const result = customModesSettingsSchema.safeParse(config) - - if (!result.success) { - vscode.window.showErrorMessage(errorMessage) - return - } - - // Get modes from .roomodes if it exists (takes precedence) - const roomodesPath = await this.getWorkspaceRoomodes() - const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - - // Merge modes from both sources (.roomodes takes precedence) - const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes) - await this.context.globalState.update("customModes", mergedModes) + // Reload all modes using the same logic as getCustomModes + const modes = await this.getCustomModes() this.clearCache() await this.onUpdate() } catch (error) { - console.error(`[CustomModesManager] Error handling settings file change:`, error) + console.error(`[CustomModesManager] Error handling mode file change:`, error) } } - this.disposables.push(settingsWatcher.onDidChange(handleSettingsChange)) - this.disposables.push(settingsWatcher.onDidCreate(handleSettingsChange)) - this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange)) + // Watch settings file + const settingsWatcher = vscode.workspace.createFileSystemWatcher(settingsPath) + this.disposables.push(settingsWatcher.onDidChange(handleModeFileChange)) + this.disposables.push(settingsWatcher.onDidCreate(handleModeFileChange)) + this.disposables.push(settingsWatcher.onDidDelete(handleModeFileChange)) this.disposables.push(settingsWatcher) - // Watch .roomodes file - watch the path even if it doesn't exist yet + // Watch global .roo/modes directory + const globalRooModesDir = path.join(getGlobalRooDirectory(), ROO_MODES_DIR) + const globalRooModesPattern = path.join(globalRooModesDir, "*.{yaml,yml}") + const globalRooModesWatcher = vscode.workspace.createFileSystemWatcher(globalRooModesPattern) + this.disposables.push(globalRooModesWatcher.onDidChange(handleModeFileChange)) + this.disposables.push(globalRooModesWatcher.onDidCreate(handleModeFileChange)) + this.disposables.push(globalRooModesWatcher.onDidDelete(handleModeFileChange)) + this.disposables.push(globalRooModesWatcher) + + // Watch .roomodes file and project .roo/modes directory if workspace exists const workspaceFolders = vscode.workspace.workspaceFolders if (workspaceFolders && workspaceFolders.length > 0) { const workspaceRoot = getWorkspacePath() + + // Watch .roomodes file const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath) - - const handleRoomodesChange = async () => { - try { - const settingsModes = await this.loadModesFromFile(settingsPath) - const roomodesModes = await this.loadModesFromFile(roomodesPath) - // .roomodes takes precedence - const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) - await this.context.globalState.update("customModes", mergedModes) - this.clearCache() - await this.onUpdate() - } catch (error) { - console.error(`[CustomModesManager] Error handling .roomodes file change:`, error) - } - } - - this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange)) - this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange)) - this.disposables.push( - roomodesWatcher.onDidDelete(async () => { - // When .roomodes is deleted, refresh with only settings modes - try { - const settingsModes = await this.loadModesFromFile(settingsPath) - await this.context.globalState.update("customModes", settingsModes) - this.clearCache() - await this.onUpdate() - } catch (error) { - console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error) - } - }), - ) + this.disposables.push(roomodesWatcher.onDidChange(handleModeFileChange)) + this.disposables.push(roomodesWatcher.onDidCreate(handleModeFileChange)) + this.disposables.push(roomodesWatcher.onDidDelete(handleModeFileChange)) this.disposables.push(roomodesWatcher) + + // Watch project .roo/modes directory + const projectRooModesDir = path.join(workspaceRoot, ".roo", ROO_MODES_DIR) + const projectRooModesPattern = path.join(projectRooModesDir, "*.{yaml,yml}") + const projectRooModesWatcher = vscode.workspace.createFileSystemWatcher(projectRooModesPattern) + this.disposables.push(projectRooModesWatcher.onDidChange(handleModeFileChange)) + this.disposables.push(projectRooModesWatcher.onDidCreate(handleModeFileChange)) + this.disposables.push(projectRooModesWatcher.onDidDelete(handleModeFileChange)) + this.disposables.push(projectRooModesWatcher) } } @@ -362,35 +366,59 @@ export class CustomModesManager { // Get modes from settings file. const settingsPath = await this.getCustomModesFilePath() - const settingsModes = await this.loadModesFromFile(settingsPath) + const settingsModes = await this.loadModesFromFile(settingsPath, "global") + + // Get modes from .roo/modes directories (both global and project) + const allRooModesDirModes: ModeConfig[] = [] + + // Load from global .roo/modes + const globalRooModesDir = path.join(getGlobalRooDirectory(), ROO_MODES_DIR) + const globalRooModesDirModes = await this.loadModesFromDirectory(globalRooModesDir, "global") + allRooModesDirModes.push(...globalRooModesDirModes) + + // Load from project .roo/modes if workspace exists + const workspacePath = getWorkspacePath() + if (workspacePath) { + const projectRooModesDir = path.join(workspacePath, ".roo", ROO_MODES_DIR) + const projectRooModesDirModes = await this.loadModesFromDirectory(projectRooModesDir, "project") + allRooModesDirModes.push(...projectRooModesDirModes) + } // Get modes from .roomodes if it exists. const roomodesPath = await this.getWorkspaceRoomodes() - const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] + const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath, "project") : [] - // Create maps to store modes by source. - const projectModes = new Map() - const globalModes = new Map() + // Create a map to store modes with proper precedence + // Precedence order (highest to lowest): + // 1. .roo/modes (project) + // 2. .roomodes (project) + // 3. .roo/modes (global) + // 4. settings file (global) + const modesMap = new Map() - // Add project modes (they take precedence). - for (const mode of roomodesModes) { - projectModes.set(mode.slug, { ...mode, source: "project" as const }) - } - - // Add global modes. + // Add in reverse precedence order (lowest to highest) so higher precedence overwrites + // 4. Global settings file for (const mode of settingsModes) { - if (!projectModes.has(mode.slug)) { - globalModes.set(mode.slug, { ...mode, source: "global" as const }) - } + modesMap.set(mode.slug, mode) } - // Combine modes in the correct order: project modes first, then global modes. - const mergedModes = [ - ...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })), - ...settingsModes - .filter((mode) => !projectModes.has(mode.slug)) - .map((mode) => ({ ...mode, source: "global" as const })), - ] + // 3. Global .roo/modes + for (const mode of globalRooModesDirModes) { + modesMap.set(mode.slug, mode) + } + + // 2. Project .roomodes + for (const mode of roomodesModes) { + modesMap.set(mode.slug, mode) + } + + // 1. Project .roo/modes (highest precedence) + for (const mode of allRooModesDirModes.filter((m) => m.source === "project")) { + modesMap.set(mode.slug, mode) + } + + // Convert map to array + const mergedModes = Array.from(modesMap.values()) await this.context.globalState.update("customModes", mergedModes) @@ -414,34 +442,50 @@ export class CustomModesManager { return } - const isProjectMode = config.source === "project" + // Check if we're updating an existing mode and preserve its source file + const existingModes = await this.getCustomModes() + const existingMode = existingModes.find((m) => m.slug === slug) + let targetPath: string - if (isProjectMode) { - const workspaceFolders = vscode.workspace.workspaceFolders - - if (!workspaceFolders || workspaceFolders.length === 0) { - logger.error("Failed to update project mode: No workspace folder found", { slug }) - throw new Error(t("common:customModes.errors.noWorkspaceForProject")) - } - - const workspaceRoot = getWorkspacePath() - targetPath = path.join(workspaceRoot, ROOMODES_FILENAME) - const exists = await fileExistsAtPath(targetPath) - - logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, { - slug, - workspace: workspaceRoot, - }) + // If mode exists and has a sourceFile, update it in the same file + if (existingMode && (existingMode as any).sourceFile) { + targetPath = (existingMode as any).sourceFile + logger.info(`Updating mode in original file: ${targetPath}`, { slug }) } else { - targetPath = await this.getCustomModesFilePath() + // For new modes or modes without sourceFile, determine target based on source + const isProjectMode = config.source === "project" + + if (isProjectMode) { + const workspaceFolders = vscode.workspace.workspaceFolders + + if (!workspaceFolders || workspaceFolders.length === 0) { + logger.error("Failed to update project mode: No workspace folder found", { slug }) + throw new Error(t("common:customModes.errors.noWorkspaceForProject")) + } + + const workspaceRoot = getWorkspacePath() + targetPath = path.join(workspaceRoot, ROOMODES_FILENAME) + const exists = await fileExistsAtPath(targetPath) + + logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, { + slug, + workspace: workspaceRoot, + }) + } else { + targetPath = await this.getCustomModesFilePath() + } } await this.queueWrite(async () => { - // Ensure source is set correctly based on target file. + // Determine source based on target path + const isProjectFile = + targetPath.includes(ROOMODES_FILENAME) || + (targetPath.includes(ROO_MODES_DIR) && !targetPath.includes(getGlobalRooDirectory())) + const modeWithSource = { ...config, - source: isProjectMode ? ("project" as const) : ("global" as const), + source: isProjectFile ? ("project" as const) : ("global" as const), } await this.updateModesInFile(targetPath, (modes) => { @@ -492,54 +536,81 @@ export class CustomModesManager { } private async refreshMergedState(): Promise { - const settingsPath = await this.getCustomModesFilePath() - const roomodesPath = await this.getWorkspaceRoomodes() - - const settingsModes = await this.loadModesFromFile(settingsPath) - const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) - - await this.context.globalState.update("customModes", mergedModes) - + // Use the same logic as getCustomModes to ensure consistency + const modes = await this.getCustomModes() this.clearCache() - await this.onUpdate() } public async deleteCustomMode(slug: string, fromMarketplace = false): Promise { try { - const settingsPath = await this.getCustomModesFilePath() - const roomodesPath = await this.getWorkspaceRoomodes() + // Get all modes to find where this mode is stored + const allModes = await this.getCustomModes() + const modeToDelete = allModes.find((m) => m.slug === slug) - const settingsModes = await this.loadModesFromFile(settingsPath) - const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - - // Find the mode in either file - const projectMode = roomodesModes.find((m) => m.slug === slug) - const globalMode = settingsModes.find((m) => m.slug === slug) - - if (!projectMode && !globalMode) { + if (!modeToDelete) { throw new Error(t("common:customModes.errors.modeNotFound")) } - // Determine which mode to use for rules folder path calculation - const modeToDelete = projectMode || globalMode - await this.queueWrite(async () => { - // Delete from project first if it exists there - if (projectMode && roomodesPath) { - await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug)) - } + // Delete from the source file if it has one + if ((modeToDelete as any).sourceFile) { + const sourceFile = (modeToDelete as any).sourceFile + await this.updateModesInFile(sourceFile, (modes) => modes.filter((m) => m.slug !== slug)) + } else { + // Fallback to checking all possible locations + const settingsPath = await this.getCustomModesFilePath() + const roomodesPath = await this.getWorkspaceRoomodes() - // Delete from global settings if it exists there - if (globalMode) { - await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug)) + // Try to delete from settings file + try { + const settingsModes = await this.loadModesFromFile(settingsPath) + if (settingsModes.find((m) => m.slug === slug)) { + await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug)) + } + } catch (error) { + // Ignore if file doesn't exist + } + + // Try to delete from .roomodes + if (roomodesPath) { + try { + const roomodesModes = await this.loadModesFromFile(roomodesPath) + if (roomodesModes.find((m) => m.slug === slug)) { + await this.updateModesInFile(roomodesPath, (modes) => + modes.filter((m) => m.slug !== slug), + ) + } + } catch (error) { + // Ignore if file doesn't exist + } + } + + // Check and delete from .roo/modes directories + const rooDirectories = getRooDirectoriesForCwd(getWorkspacePath() || process.cwd()) + for (const rooDir of rooDirectories) { + const rooModesDir = path.join(rooDir, ROO_MODES_DIR) + try { + const entries = await fs.readdir(rooModesDir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) { + const filePath = path.join(rooModesDir, entry.name) + const fileModes = await this.loadModesFromFile(filePath) + if (fileModes.find((m) => m.slug === slug)) { + await this.updateModesInFile(filePath, (modes) => + modes.filter((m) => m.slug !== slug), + ) + } + } + } + } catch (error) { + // Directory might not exist + } + } } // Delete associated rules folder - if (modeToDelete) { - await this.deleteRulesFolder(slug, modeToDelete, fromMarketplace) - } + await this.deleteRulesFolder(slug, modeToDelete, fromMarketplace) // Clear cache when modes are deleted this.clearCache() @@ -807,6 +878,11 @@ export class CustomModesManager { source: "project" as const, } + // Normalize sourceFile path to use forward slashes if it exists + if ((exportMode as any).sourceFile) { + ;(exportMode as any).sourceFile = (exportMode as any).sourceFile.replace(/\\/g, "/") + } + // Merge custom prompts if provided if (customPrompts) { if (customPrompts.roleDefinition) exportMode.roleDefinition = customPrompts.roleDefinition diff --git a/src/core/config/__tests__/CustomModesManager.spec.ts b/src/core/config/__tests__/CustomModesManager.spec.ts index b48ea7b65b..bc69951d36 100644 --- a/src/core/config/__tests__/CustomModesManager.spec.ts +++ b/src/core/config/__tests__/CustomModesManager.spec.ts @@ -114,6 +114,176 @@ describe("CustomModesManager", () => { expect(modes).toHaveLength(2) }) + it("should load modes from .roo/modes directory", async () => { + const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }] + const rooModesMode1 = { slug: "mode2", name: "Mode 2", roleDefinition: "Role 2", groups: ["read"] } + const rooModesMode2 = { slug: "mode3", name: "Mode 3", roleDefinition: "Role 3", groups: ["edit"] } + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + // Return true for settings path and modes directories + return path === mockSettingsPath || path.includes("modes") || path === mockRoomodes + }) + ;(fs.readdir as Mock).mockImplementation(async (path: string) => { + if (path.includes("modes")) { + return [ + { name: "mode1.yaml", isFile: () => true }, + { name: "mode2.yml", isFile: () => true }, + { name: "readme.txt", isFile: () => true }, // Should be ignored + ] + } + return [] + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: settingsModes }) + } + if (path.includes("mode1.yaml")) { + return yaml.stringify({ customModes: [rooModesMode1] }) + } + if (path.includes("mode2.yml")) { + return yaml.stringify({ customModes: [rooModesMode2] }) + } + throw new Error("File not found") + }) + + const modes = await manager.getCustomModes() + + // Should have all 3 modes + expect(modes).toHaveLength(3) + expect(modes.map((m) => m.slug)).toContain("mode1") + expect(modes.map((m) => m.slug)).toContain("mode2") + expect(modes.map((m) => m.slug)).toContain("mode3") + }) + + it("should respect precedence: project .roo/modes > .roomodes > global .roo/modes > settings", async () => { + const settingsMode = { slug: "test", name: "Settings", roleDefinition: "Settings Role", groups: ["read"] } + const globalRooMode = { + slug: "test", + name: "Global Roo", + roleDefinition: "Global Roo Role", + groups: ["read"], + } + const roomodesMode = { slug: "test", name: "Roomodes", roleDefinition: "Roomodes Role", groups: ["read"] } + const projectRooMode = { + slug: "test", + name: "Project Roo", + roleDefinition: "Project Roo Role", + groups: ["read"], + } + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return true // All paths exist + }) + ;(fs.readdir as Mock).mockImplementation(async (path: string) => { + if (path.includes("modes")) { + return [{ name: "test.yaml", isFile: () => true }] + } + return [] + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [settingsMode] }) + } + if (path === mockRoomodes) { + return yaml.stringify({ customModes: [roomodesMode] }) + } + // Global .roo/modes + if (path.includes("modes") && !path.includes(mockWorkspacePath)) { + return yaml.stringify({ customModes: [globalRooMode] }) + } + // Project .roo/modes + if (path.includes("modes") && path.includes(mockWorkspacePath)) { + return yaml.stringify({ customModes: [projectRooMode] }) + } + throw new Error("File not found") + }) + + const modes = await manager.getCustomModes() + + // Should have only one mode with the slug "test" + expect(modes).toHaveLength(1) + // Project .roo/modes should take precedence + expect(modes[0].name).toBe("Project Roo") + expect(modes[0].roleDefinition).toBe("Project Roo Role") + }) + + it("should handle empty .roo/modes directory", async () => { + const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }] + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockSettingsPath || path.includes("modes") + }) + ;(fs.readdir as Mock).mockImplementation(async () => { + return [] // Empty directory + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: settingsModes }) + } + throw new Error("File not found") + }) + + const modes = await manager.getCustomModes() + + // Should only have the settings mode + expect(modes).toHaveLength(1) + expect(modes[0].slug).toBe("mode1") + }) + + it("should handle non-existent .roo/modes directory", async () => { + const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }] + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + // modes directories don't exist + if (path.includes("modes")) { + return false + } + return path === mockSettingsPath + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: settingsModes }) + } + throw new Error("File not found") + }) + + const modes = await manager.getCustomModes() + + // Should only have the settings mode + expect(modes).toHaveLength(1) + expect(modes[0].slug).toBe("mode1") + }) + + it("should preserve sourceFile property for modes loaded from .roo/modes", async () => { + const rooModesMode = { slug: "test", name: "Test Mode", roleDefinition: "Test Role", groups: ["read"] } + + ;(fileExistsAtPath as Mock).mockImplementation(async (path: string) => { + return path === mockSettingsPath || path.includes("modes") + }) + ;(fs.readdir as Mock).mockImplementation(async (path: string) => { + if (path.includes("modes")) { + return [{ name: "test.yaml", isFile: () => true }] + } + return [] + }) + ;(fs.readFile as Mock).mockImplementation(async (path: string) => { + if (path === mockSettingsPath) { + return yaml.stringify({ customModes: [] }) + } + if (path.includes("test.yaml")) { + return yaml.stringify({ customModes: [rooModesMode] }) + } + throw new Error("File not found") + }) + + const modes = await manager.getCustomModes() + + expect(modes).toHaveLength(1) + expect(modes[0].slug).toBe("test") + // Check that sourceFile is preserved + expect((modes[0] as any).sourceFile).toContain("test.yaml") + }) + it("should merge modes with .roomodes taking precedence", async () => { const settingsModes = [ { slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }, @@ -139,7 +309,10 @@ describe("CustomModesManager", () => { // Should contain 3 modes (mode1 from settings, mode2 and mode3 from roomodes) expect(modes).toHaveLength(3) - expect(modes.map((m) => m.slug)).toEqual(["mode2", "mode3", "mode1"]) + // The order is now: settings modes first, then roomodes overrides + expect(modes.map((m) => m.slug)).toContain("mode1") + expect(modes.map((m) => m.slug)).toContain("mode2") + expect(modes.map((m) => m.slug)).toContain("mode3") // mode2 should come from .roomodes since it takes precedence const mode2 = modes.find((m) => m.slug === "mode2") @@ -455,6 +628,7 @@ describe("CustomModesManager", () => { roleDefinition: "Role 1", groups: ["read"], source: "project", + sourceFile: mockRoomodes, // Add sourceFile to simulate existing mode }, ] @@ -465,6 +639,9 @@ describe("CustomModesManager", () => { let settingsContent = { customModes: existingModes } let roomodesContent = { customModes: roomodesModes } + // Mock getCustomModes to return the roomodes mode with sourceFile + const getCustomModesSpy = vi.spyOn(manager, "getCustomModes") + getCustomModesSpy.mockResolvedValueOnce(roomodesModes as any) ;(fs.readFile as Mock).mockImplementation(async (path: string) => { if (path === mockRoomodes) { return yaml.stringify(roomodesContent) @@ -486,8 +663,8 @@ describe("CustomModesManager", () => { await manager.updateCustomMode("mode1", newMode) - // Should write to settings file - expect(fs.writeFile).toHaveBeenCalledWith(mockSettingsPath, expect.any(String), "utf-8") + // Since mode1 exists in .roomodes with sourceFile, it should update there + expect(fs.writeFile).toHaveBeenCalledWith(mockRoomodes, expect.any(String), "utf-8") // Verify the content of the write const writeCall = (fs.writeFile as Mock).mock.calls[0] @@ -497,24 +674,15 @@ describe("CustomModesManager", () => { slug: "mode1", name: "Updated Mode 1", roleDefinition: "Updated Role 1", - source: "global", + source: "project", // Should be project since it's in .roomodes }), ) - // Should update global state with merged modes where .roomodes takes precedence - expect(mockContext.globalState.update).toHaveBeenCalledWith( - "customModes", - expect.arrayContaining([ - expect.objectContaining({ - slug: "mode1", - name: "Roomodes Mode 1", // .roomodes version should take precedence - source: "project", - }), - ]), - ) - // Should trigger onUpdate expect(mockOnUpdate).toHaveBeenCalled() + + // Restore the spy + getCustomModesSpy.mockRestore() }) it("creates .roomodes file when adding project-specific mode", async () => { From 090737c516d7d56ac271ad2718b3158867d68af7 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 02:14:03 -0700 Subject: [PATCH 03/35] fix: omit temperature parameter when not explicitly set for OpenAI Compatible providers (#7188) * fix: omit temperature parameter when not explicitly set for OpenAI Compatible providers - Modified OpenAiHandler to only include temperature when modelTemperature is defined - Modified BaseOpenAiCompatibleProvider to only include temperature when modelTemperature is defined - Added tests to verify temperature is omitted when undefined - Updated existing tests to explicitly set temperature where needed This allows backend services (LiteLLM, vLLM) to use their configured default temperatures instead of being forced to use temperature=0 when "Use custom temperature" is unchecked. Fixes #7187 * test: update tests to match new temperature handling behavior - Remove temperature parameter expectations from provider tests - Tests now expect temperature to be omitted when not explicitly set - Aligns with PR #7188 changes to fix OpenAI Compatible provider behavior --------- Co-authored-by: Roo Code Co-authored-by: daniel-lxs --- src/api/providers/__tests__/chutes.spec.ts | 1 - src/api/providers/__tests__/fireworks.spec.ts | 1 - src/api/providers/__tests__/groq.spec.ts | 77 ++++++++++++++++++- src/api/providers/__tests__/openai.spec.ts | 67 +++++++++++++++- src/api/providers/__tests__/roo.spec.ts | 6 +- src/api/providers/__tests__/sambanova.spec.ts | 1 - src/api/providers/__tests__/zai.spec.ts | 1 - .../base-openai-compatible-provider.ts | 8 +- src/api/providers/openai.ts | 9 ++- 9 files changed, 158 insertions(+), 13 deletions(-) diff --git a/src/api/providers/__tests__/chutes.spec.ts b/src/api/providers/__tests__/chutes.spec.ts index 0596a911df..0ce1823087 100644 --- a/src/api/providers/__tests__/chutes.spec.ts +++ b/src/api/providers/__tests__/chutes.spec.ts @@ -394,7 +394,6 @@ describe("ChutesHandler", () => { expect.objectContaining({ model: modelId, max_tokens: modelInfo.maxTokens, - temperature: 0.5, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index cfab672c08..985a9edad8 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -352,7 +352,6 @@ describe("FireworksHandler", () => { expect.objectContaining({ model: modelId, max_tokens: modelInfo.maxTokens, - temperature: 0.5, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/__tests__/groq.spec.ts b/src/api/providers/__tests__/groq.spec.ts index 72a834b21d..7de7a04262 100644 --- a/src/api/providers/__tests__/groq.spec.ts +++ b/src/api/providers/__tests__/groq.spec.ts @@ -114,7 +114,11 @@ describe("GroqHandler", () => { it("createMessage should pass correct parameters to Groq client", async () => { const modelId: GroqModelId = "llama-3.1-8b-instant" const modelInfo = groqModels[modelId] - const handlerWithModel = new GroqHandler({ apiModelId: modelId, groqApiKey: "test-groq-api-key" }) + const handlerWithModel = new GroqHandler({ + apiModelId: modelId, + groqApiKey: "test-groq-api-key", + modelTemperature: 0.5, // Explicitly set temperature for this test + }) mockCreate.mockImplementationOnce(() => { return { @@ -143,4 +147,75 @@ describe("GroqHandler", () => { }), ) }) + + it("should omit temperature when modelTemperature is undefined", async () => { + const modelId: GroqModelId = "llama-3.1-8b-instant" + const handlerWithoutTemp = new GroqHandler({ + apiModelId: modelId, + groqApiKey: "test-groq-api-key", + // modelTemperature is not set + }) + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) + + const systemPrompt = "Test system prompt" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message" }] + + const messageGenerator = handlerWithoutTemp.createMessage(systemPrompt, messages) + await messageGenerator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: modelId, + messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), + stream: true, + }), + ) + + // Verify temperature is NOT included + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("temperature") + }) + + it("should include temperature when modelTemperature is explicitly set", async () => { + const modelId: GroqModelId = "llama-3.1-8b-instant" + const handlerWithTemp = new GroqHandler({ + apiModelId: modelId, + groqApiKey: "test-groq-api-key", + modelTemperature: 0.7, + }) + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) + + const systemPrompt = "Test system prompt" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message" }] + + const messageGenerator = handlerWithTemp.createMessage(systemPrompt, messages) + await messageGenerator.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: modelId, + temperature: 0.7, + messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), + stream: true, + }), + ) + }) }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 3e744d6e16..14ed35430a 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -315,6 +315,71 @@ describe("OpenAiHandler", () => { const callArgs = mockCreate.mock.calls[0][0] expect(callArgs.max_completion_tokens).toBe(4096) }) + + it("should omit temperature when modelTemperature is undefined", async () => { + const optionsWithoutTemperature: ApiHandlerOptions = { + ...mockOptions, + // modelTemperature is not set, should not include temperature + } + const handlerWithoutTemperature = new OpenAiHandler(optionsWithoutTemperature) + const stream = handlerWithoutTemperature.createMessage(systemPrompt, messages) + // Consume the stream to trigger the API call + for await (const _chunk of stream) { + } + // Assert the mockCreate was called without temperature + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("temperature") + }) + + it("should include temperature when modelTemperature is explicitly set to 0", async () => { + const optionsWithZeroTemperature: ApiHandlerOptions = { + ...mockOptions, + modelTemperature: 0, + } + const handlerWithZeroTemperature = new OpenAiHandler(optionsWithZeroTemperature) + const stream = handlerWithZeroTemperature.createMessage(systemPrompt, messages) + // Consume the stream to trigger the API call + for await (const _chunk of stream) { + } + // Assert the mockCreate was called with temperature: 0 + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.temperature).toBe(0) + }) + + it("should include temperature when modelTemperature is set to a non-zero value", async () => { + const optionsWithCustomTemperature: ApiHandlerOptions = { + ...mockOptions, + modelTemperature: 0.7, + } + const handlerWithCustomTemperature = new OpenAiHandler(optionsWithCustomTemperature) + const stream = handlerWithCustomTemperature.createMessage(systemPrompt, messages) + // Consume the stream to trigger the API call + for await (const _chunk of stream) { + } + // Assert the mockCreate was called with temperature: 0.7 + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.temperature).toBe(0.7) + }) + + it("should include DEEP_SEEK_DEFAULT_TEMPERATURE for deepseek-reasoner models when temperature is not set", async () => { + const deepseekOptions: ApiHandlerOptions = { + ...mockOptions, + openAiModelId: "deepseek-reasoner", + // modelTemperature is not set + } + const deepseekHandler = new OpenAiHandler(deepseekOptions) + const stream = deepseekHandler.createMessage(systemPrompt, messages) + // Consume the stream to trigger the API call + for await (const _chunk of stream) { + } + // Assert the mockCreate was called with DEEP_SEEK_DEFAULT_TEMPERATURE (0.6) + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.temperature).toBe(0.6) + }) }) describe("error handling", () => { @@ -450,7 +515,7 @@ describe("OpenAiHandler", () => { ], stream: true, stream_options: { include_usage: true }, - temperature: 0, + // temperature should be omitted when not set }, { path: "/models/chat/completions" }, ) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index b16a6e7ac1..093137e1b2 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -350,7 +350,7 @@ describe("RooHandler", () => { }) describe("temperature and model configuration", () => { - it("should use default temperature of 0.7", async () => { + it("should omit temperature when not explicitly set", async () => { handler = new RooHandler(mockOptions) const stream = handler.createMessage(systemPrompt, messages) for await (const _chunk of stream) { @@ -358,8 +358,8 @@ describe("RooHandler", () => { } expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.7, + expect.not.objectContaining({ + temperature: expect.anything(), }), ) }) diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index cd0e4a1989..6757b7562f 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -144,7 +144,6 @@ describe("SambaNovaHandler", () => { expect.objectContaining({ model: modelId, max_tokens: modelInfo.maxTokens, - temperature: 0.7, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 6b93aaa43b..067f4344d5 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -220,7 +220,6 @@ describe("ZAiHandler", () => { expect.objectContaining({ model: modelId, max_tokens: modelInfo.maxTokens, - temperature: ZAI_DEFAULT_TEMPERATURE, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, stream_options: { include_usage: true }, diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 3c824f2651..cb6331bab4 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -72,17 +72,19 @@ export abstract class BaseOpenAiCompatibleProvider info: { maxTokens: max_tokens }, } = this.getModel() - const temperature = this.options.modelTemperature ?? this.defaultTemperature - const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model, max_tokens, - temperature, messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], stream: true, stream_options: { include_usage: true }, } + // Only include temperature if explicitly set + if (this.options.modelTemperature !== undefined) { + params.temperature = this.options.modelTemperature + } + return this.client.chat.completions.create(params) } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 939816480a..36158d770c 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -157,13 +157,20 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelId, - temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), messages: convertedMessages, stream: true as const, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), ...(reasoning && reasoning), } + // Only include temperature if explicitly set + if (this.options.modelTemperature !== undefined) { + requestOptions.temperature = this.options.modelTemperature + } else if (deepseekReasoner) { + // DeepSeek Reasoner has a specific default temperature + requestOptions.temperature = DEEP_SEEK_DEFAULT_TEMPERATURE + } + // Add max_tokens if needed this.addMaxTokensIfNeeded(requestOptions, modelInfo) From ce052dbd7e21b9940ddbba7f34e2feba7e31494a Mon Sep 17 00:00:00 2001 From: Dicha Zelianivan Arkana <51877647+elianiva@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:15:52 +0700 Subject: [PATCH 04/35] refactor(storage): better fs check (#7164) * refactor(storage): better fs check * fix(storage): fs check inconsistencies * test(storage): more thorough testing --- src/utils/__tests__/storage.spec.ts | 155 ++++++++++++++++++++++++++++ src/utils/storage.ts | 8 +- 2 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/utils/__tests__/storage.spec.ts diff --git a/src/utils/__tests__/storage.spec.ts b/src/utils/__tests__/storage.spec.ts new file mode 100644 index 0000000000..e5e1586dc6 --- /dev/null +++ b/src/utils/__tests__/storage.spec.ts @@ -0,0 +1,155 @@ +import * as vscode from "vscode" + +vi.mock("fs/promises", async () => { + const mod = await import("../../__mocks__/fs/promises") + return (mod as any).default ?? mod +}) + +describe("getStorageBasePath - customStoragePath", () => { + const defaultPath = "/test/global-storage" + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns the configured custom path when it is writable", async () => { + const customPath = "/test/storage/path" + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ + get: vi.fn().mockReturnValue(customPath), + } as any) + + const fsPromises = await import("fs/promises") + const { getStorageBasePath } = await import("../storage") + + const result = await getStorageBasePath(defaultPath) + + expect(result).toBe(customPath) + expect((fsPromises as any).mkdir).toHaveBeenCalledWith(customPath, { recursive: true }) + expect((fsPromises as any).access).toHaveBeenCalledWith(customPath, 7) // 7 = R_OK(4) | W_OK(2) | X_OK(1) + }) + + it("falls back to default and shows an error when custom path is not writable", async () => { + const customPath = "/test/storage/unwritable" + + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ + get: vi.fn().mockReturnValue(customPath), + } as any) + + const showErrorSpy = vi.spyOn(vscode.window, "showErrorMessage").mockResolvedValue(undefined as any) + + const fsPromises = await import("fs/promises") + const { getStorageBasePath } = await import("../storage") + + await (fsPromises as any).mkdir(customPath, { recursive: true }) + + const accessMock = (fsPromises as any).access as ReturnType + accessMock.mockImplementationOnce(async (p: string) => { + if (p === customPath) { + const err: any = new Error("EACCES: permission denied") + err.code = "EACCES" + throw err + } + return Promise.resolve() + }) + + const result = await getStorageBasePath(defaultPath) + + expect(result).toBe(defaultPath) + expect(showErrorSpy).toHaveBeenCalledTimes(1) + const firstArg = showErrorSpy.mock.calls[0][0] + expect(typeof firstArg).toBe("string") + }) + it("returns the default path when customStoragePath is an empty string and does not touch fs", async () => { + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ + get: vi.fn().mockReturnValue(""), + } as any) + + const fsPromises = await import("fs/promises") + const { getStorageBasePath } = await import("../storage") + + const result = await getStorageBasePath(defaultPath) + + expect(result).toBe(defaultPath) + expect((fsPromises as any).mkdir).not.toHaveBeenCalled() + expect((fsPromises as any).access).not.toHaveBeenCalled() + }) + + it("falls back to default when mkdir fails and does not attempt access", async () => { + const customPath = "/test/storage/failmkdir" + + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ + get: vi.fn().mockReturnValue(customPath), + } as any) + + const showErrorSpy = vi.spyOn(vscode.window, "showErrorMessage").mockResolvedValue(undefined as any) + + const fsPromises = await import("fs/promises") + const { getStorageBasePath } = await import("../storage") + + const mkdirMock = (fsPromises as any).mkdir as ReturnType + mkdirMock.mockImplementationOnce(async (p: string) => { + if (p === customPath) { + const err: any = new Error("EACCES: permission denied") + err.code = "EACCES" + throw err + } + return Promise.resolve() + }) + + const result = await getStorageBasePath(defaultPath) + + expect(result).toBe(defaultPath) + expect((fsPromises as any).access).not.toHaveBeenCalled() + expect(showErrorSpy).toHaveBeenCalledTimes(1) + }) + + it("passes the correct permission flags (R_OK | W_OK | X_OK) to fs.access", async () => { + const customPath = "/test/storage/path" + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ + get: vi.fn().mockReturnValue(customPath), + } as any) + + const fsPromises = await import("fs/promises") + const { getStorageBasePath } = await import("../storage") + + await getStorageBasePath(defaultPath) + + const constants = (fsPromises as any).constants + const expectedFlags = constants.R_OK | constants.W_OK | constants.X_OK + + expect((fsPromises as any).access).toHaveBeenCalledWith(customPath, expectedFlags) + }) + + it("falls back when directory is readable but not writable (partial permissions)", async () => { + const customPath = "/test/storage/readonly" + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ + get: vi.fn().mockReturnValue(customPath), + } as any) + + const showErrorSpy = vi.spyOn(vscode.window, "showErrorMessage").mockResolvedValue(undefined as any) + + const fsPromises = await import("fs/promises") + const { getStorageBasePath } = await import("../storage") + + const accessMock = (fsPromises as any).access as ReturnType + const constants = (fsPromises as any).constants + accessMock.mockImplementationOnce(async (p: string, mode?: number) => { + // Simulate readable (R_OK) but not writable/executable (W_OK | X_OK) + if (p === customPath && mode && mode & (constants.W_OK | constants.X_OK)) { + const err: any = new Error("EACCES: permission denied") + err.code = "EACCES" + throw err + } + return Promise.resolve() + }) + + const result = await getStorageBasePath(defaultPath) + + expect(result).toBe(defaultPath) + expect(showErrorSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 8240588794..5125d6cf44 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import * as path from "path" import * as fs from "fs/promises" +import { constants as fsConstants } from "fs" import { Package } from "../shared/package" import { t } from "../i18n" @@ -32,10 +33,8 @@ export async function getStorageBasePath(defaultPath: string): Promise { // Ensure custom path exists await fs.mkdir(customStoragePath, { recursive: true }) - // Test if path is writable - const testFile = path.join(customStoragePath, ".write_test") - await fs.writeFile(testFile, "test") - await fs.rm(testFile) + // Check directory write permission without creating temp files + await fs.access(customStoragePath, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK) return customStoragePath } catch (error) { @@ -132,6 +131,7 @@ export async function promptForCustomStoragePath(): Promise { try { // Test if path is accessible await fs.mkdir(result, { recursive: true }) + await fs.access(result, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK) vscode.window.showInformationMessage(t("common:info.custom_storage_path_set", { path: result })) } catch (error) { vscode.window.showErrorMessage( From 007b58db773b72410bdf5342621760526533dceb Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 02:17:41 -0700 Subject: [PATCH 05/35] feat: add "Installed" filter to Roo Marketplace (#7007) * feat: add "Installed" filter to Roo Marketplace - Added installed boolean filter to ViewState interface - Implemented UI checkbox for "Show installed only" filter - Updated filterItems method to filter by installation status - Added translation strings for the new filter - Updated tests to include the new filter property Fixes #7004 * fix: add missing translations for "Installed" filter in marketplace - Added "filters.installed.label" and "filters.installed.description" keys - Updated all backend marketplace.json files (src/i18n/locales/*/marketplace.json) - Updated all frontend marketplace.json files (webview-ui/src/i18n/locales/*/marketplace.json) - All translation checks now pass successfully * fix: improve code quality for installed filter feature - Replace plain HTML checkbox with UI library Checkbox component for consistency - Add proper TypeScript typing for installedMetadata (MarketplaceInstalledMetadata) - Optimize filterItems method to avoid unnecessary object copying - Remove dead code for subcomponent filtering * refactor: improve installed filter UI and functionality - Replace checkbox with dropdown for better UX - Place dropdown inline with search field for better layout - Add three filter options: All Items, Installed Only, Not Installed - Update filter logic to handle all three states - Improve translation labels to clarify it's a filter - Optimize filterItems method for better performance - Add proper TypeScript types throughout * fix: ensure both filter dropdowns have matching height - Set both Select and Button components to h-7 for visual consistency - Installation status dropdown and tags dropdown now have identical height - Improved visual alignment for better UX * chore: remove redundant useEffect in MarketplaceListView to prevent re-render loop; rely on state manager handleMessage sync * i18n: update tri-state Installed filter translations across all locales - Replace checkbox-based installed keys with dropdown keys: { label, all, installed, notInstalled } - Updated both frontend and backend marketplace.json files for all non-English locales - Ensures translation completeness for CI check * i18n: change Installed option text to 'Installed' (remove 'Only') across all locales (frontend + backend) --------- Co-authored-by: Roo Code Co-authored-by: daniel-lxs --- src/i18n/locales/ca/marketplace.json | 6 + src/i18n/locales/de/marketplace.json | 6 + src/i18n/locales/en/marketplace.json | 6 + src/i18n/locales/es/marketplace.json | 6 + src/i18n/locales/fr/marketplace.json | 6 + src/i18n/locales/hi/marketplace.json | 6 + src/i18n/locales/id/marketplace.json | 6 + src/i18n/locales/it/marketplace.json | 6 + src/i18n/locales/ja/marketplace.json | 6 + src/i18n/locales/ko/marketplace.json | 6 + src/i18n/locales/nl/marketplace.json | 6 + src/i18n/locales/pl/marketplace.json | 6 + src/i18n/locales/pt-BR/marketplace.json | 6 + src/i18n/locales/ru/marketplace.json | 6 + src/i18n/locales/tr/marketplace.json | 6 + src/i18n/locales/vi/marketplace.json | 6 + src/i18n/locales/zh-CN/marketplace.json | 6 + src/i18n/locales/zh-TW/marketplace.json | 6 + .../marketplace/MarketplaceListView.tsx | 239 ++++++++++-------- .../MarketplaceViewStateManager.ts | 118 ++++++--- .../__tests__/MarketplaceListView.spec.tsx | 1 + .../MarketplaceViewStateManager.spec.ts | 1 + .../__tests__/MarketplaceItemCard.spec.tsx | 8 +- .../src/i18n/locales/ca/marketplace.json | 6 + .../src/i18n/locales/de/marketplace.json | 6 + .../src/i18n/locales/en/marketplace.json | 6 + .../src/i18n/locales/es/marketplace.json | 6 + .../src/i18n/locales/fr/marketplace.json | 6 + .../src/i18n/locales/hi/marketplace.json | 6 + .../src/i18n/locales/id/marketplace.json | 6 + .../src/i18n/locales/it/marketplace.json | 6 + .../src/i18n/locales/ja/marketplace.json | 6 + .../src/i18n/locales/ko/marketplace.json | 6 + .../src/i18n/locales/nl/marketplace.json | 6 + .../src/i18n/locales/pl/marketplace.json | 6 + .../src/i18n/locales/pt-BR/marketplace.json | 6 + .../src/i18n/locales/ru/marketplace.json | 6 + .../src/i18n/locales/tr/marketplace.json | 6 + .../src/i18n/locales/vi/marketplace.json | 6 + .../src/i18n/locales/zh-CN/marketplace.json | 6 + .../src/i18n/locales/zh-TW/marketplace.json | 6 + 41 files changed, 438 insertions(+), 145 deletions(-) diff --git a/src/i18n/locales/ca/marketplace.json b/src/i18n/locales/ca/marketplace.json index 6c64374447..cf0710ac97 100644 --- a/src/i18n/locales/ca/marketplace.json +++ b/src/i18n/locales/ca/marketplace.json @@ -38,6 +38,12 @@ "noResults": "No s'han trobat etiquetes.", "selected": "Mostrant elements amb qualsevol de les etiquetes seleccionades" }, + "installed": { + "label": "Filtra per estat", + "all": "Tots els articles", + "installed": "Instal·lats", + "notInstalled": "No instal·lats" + }, "title": "Marketplace" }, "done": "Fet", diff --git a/src/i18n/locales/de/marketplace.json b/src/i18n/locales/de/marketplace.json index 2981441cf0..dea82aa44c 100644 --- a/src/i18n/locales/de/marketplace.json +++ b/src/i18n/locales/de/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Keine Tags gefunden.", "selected": "Zeige Elemente mit einem der ausgewählten Tags" }, + "installed": { + "label": "Nach Status filtern", + "all": "Alle Artikel", + "installed": "Installierte", + "notInstalled": "Nicht installiert" + }, "title": "Marketplace" }, "done": "Fertig", diff --git a/src/i18n/locales/en/marketplace.json b/src/i18n/locales/en/marketplace.json index f141aaeb05..17ae20078a 100644 --- a/src/i18n/locales/en/marketplace.json +++ b/src/i18n/locales/en/marketplace.json @@ -38,6 +38,12 @@ "noResults": "No tags found.", "selected": "Showing items with any of the selected tags" }, + "installed": { + "label": "Filter by status", + "all": "All Items", + "installed": "Installed", + "notInstalled": "Not Installed" + }, "title": "Marketplace" }, "done": "Done", diff --git a/src/i18n/locales/es/marketplace.json b/src/i18n/locales/es/marketplace.json index e12e1d1dc1..ecee7c1645 100644 --- a/src/i18n/locales/es/marketplace.json +++ b/src/i18n/locales/es/marketplace.json @@ -38,6 +38,12 @@ "noResults": "No se encontraron etiquetas.", "selected": "Mostrando elementos con cualquiera de las etiquetas seleccionadas" }, + "installed": { + "label": "Filtrar por estado", + "all": "Todos los artículos", + "installed": "Instalados", + "notInstalled": "No instalados" + }, "title": "Marketplace" }, "done": "Hecho", diff --git a/src/i18n/locales/fr/marketplace.json b/src/i18n/locales/fr/marketplace.json index 7a42b0033e..7a6a3dbd74 100644 --- a/src/i18n/locales/fr/marketplace.json +++ b/src/i18n/locales/fr/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Aucune étiquette trouvée.", "selected": "Affichage des éléments avec l'une des étiquettes sélectionnées" }, + "installed": { + "label": "Filtrer par statut", + "all": "Tous les articles", + "installed": "Installés", + "notInstalled": "Non installés" + }, "title": "Marketplace" }, "done": "Terminé", diff --git a/src/i18n/locales/hi/marketplace.json b/src/i18n/locales/hi/marketplace.json index 94013c20e4..3797c50b01 100644 --- a/src/i18n/locales/hi/marketplace.json +++ b/src/i18n/locales/hi/marketplace.json @@ -38,6 +38,12 @@ "noResults": "कोई टैग नहीं मिले।", "selected": "चयनित टैग्स में से किसी भी के साथ आइटम दिखा रहे हैं" }, + "installed": { + "label": "स्थिति के अनुसार फ़िल्टर करें", + "all": "सभी आइटम", + "installed": "स्थापित", + "notInstalled": "स्थापित नहीं" + }, "title": "मार्केटप्लेस" }, "done": "हो गया", diff --git a/src/i18n/locales/id/marketplace.json b/src/i18n/locales/id/marketplace.json index 77d93973a9..7f31c78cbf 100644 --- a/src/i18n/locales/id/marketplace.json +++ b/src/i18n/locales/id/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Tidak ada tag ditemukan.", "selected": "Menampilkan item dengan salah satu tag yang dipilih" }, + "installed": { + "label": "Filter berdasarkan status", + "all": "Semua Item", + "installed": "Terpasang", + "notInstalled": "Tidak Terpasang" + }, "title": "Marketplace" }, "done": "Selesai", diff --git a/src/i18n/locales/it/marketplace.json b/src/i18n/locales/it/marketplace.json index 3cdcd2b76c..46c50acc7d 100644 --- a/src/i18n/locales/it/marketplace.json +++ b/src/i18n/locales/it/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Nessun tag trovato.", "selected": "Mostrando elementi con uno qualsiasi dei tag selezionati" }, + "installed": { + "label": "Filtra per stato", + "all": "Tutti gli articoli", + "installed": "Installati", + "notInstalled": "Non installati" + }, "title": "Marketplace" }, "done": "Fatto", diff --git a/src/i18n/locales/ja/marketplace.json b/src/i18n/locales/ja/marketplace.json index 26cff2ade9..426a5db1fd 100644 --- a/src/i18n/locales/ja/marketplace.json +++ b/src/i18n/locales/ja/marketplace.json @@ -38,6 +38,12 @@ "noResults": "タグが見つかりません。", "selected": "選択されたタグのいずれかを持つアイテムを表示" }, + "installed": { + "label": "ステータスで絞り込む", + "all": "すべてのアイテム", + "installed": "インストール済み", + "notInstalled": "未インストール" + }, "title": "マーケットプレイス" }, "done": "完了", diff --git a/src/i18n/locales/ko/marketplace.json b/src/i18n/locales/ko/marketplace.json index 52bd03edf7..349f0c5ad8 100644 --- a/src/i18n/locales/ko/marketplace.json +++ b/src/i18n/locales/ko/marketplace.json @@ -38,6 +38,12 @@ "noResults": "태그를 찾을 수 없습니다.", "selected": "선택된 태그 중 하나를 가진 항목 표시" }, + "installed": { + "label": "상태별로 필터링", + "all": "모든 항목", + "installed": "설치됨", + "notInstalled": "설치되지 않음" + }, "title": "마켓플레이스" }, "done": "완료", diff --git a/src/i18n/locales/nl/marketplace.json b/src/i18n/locales/nl/marketplace.json index 5628b8f628..1274666b9c 100644 --- a/src/i18n/locales/nl/marketplace.json +++ b/src/i18n/locales/nl/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Geen tags gevonden.", "selected": "Items tonen met een van de geselecteerde tags" }, + "installed": { + "label": "Filteren op status", + "all": "Alle items", + "installed": "Geïnstalleerd", + "notInstalled": "Niet geïnstalleerd" + }, "title": "Marketplace" }, "done": "Klaar", diff --git a/src/i18n/locales/pl/marketplace.json b/src/i18n/locales/pl/marketplace.json index 029dbd95a3..3ba3c076ad 100644 --- a/src/i18n/locales/pl/marketplace.json +++ b/src/i18n/locales/pl/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Nie znaleziono tagów.", "selected": "Pokazywanie elementów z dowolnym z wybranych tagów" }, + "installed": { + "label": "Filtruj według statusu", + "all": "Wszystkie elementy", + "installed": "Zainstalowane", + "notInstalled": "Niezainstalowane" + }, "title": "Marketplace" }, "done": "Gotowe", diff --git a/src/i18n/locales/pt-BR/marketplace.json b/src/i18n/locales/pt-BR/marketplace.json index b0af013888..b0e763b0ff 100644 --- a/src/i18n/locales/pt-BR/marketplace.json +++ b/src/i18n/locales/pt-BR/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Nenhuma tag encontrada.", "selected": "Mostrando itens com qualquer uma das tags selecionadas" }, + "installed": { + "label": "Filtrar por status", + "all": "Todos os itens", + "installed": "Instalados", + "notInstalled": "Não instalados" + }, "title": "Marketplace" }, "done": "Concluído", diff --git a/src/i18n/locales/ru/marketplace.json b/src/i18n/locales/ru/marketplace.json index a84b1ce3e9..1c4ba45df5 100644 --- a/src/i18n/locales/ru/marketplace.json +++ b/src/i18n/locales/ru/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Теги не найдены.", "selected": "Показ элементов с любым из выбранных тегов" }, + "installed": { + "label": "Фильтр по статусу", + "all": "Все элементы", + "installed": "Установленные", + "notInstalled": "Не установленные" + }, "title": "Marketplace" }, "done": "Готово", diff --git a/src/i18n/locales/tr/marketplace.json b/src/i18n/locales/tr/marketplace.json index b08381d71c..628c239a12 100644 --- a/src/i18n/locales/tr/marketplace.json +++ b/src/i18n/locales/tr/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Etiket bulunamadı.", "selected": "Seçilen etiketlerden herhangi birine sahip öğeleri göster" }, + "installed": { + "label": "Duruma göre filtrele", + "all": "Tüm Öğeler", + "installed": "Yüklü", + "notInstalled": "Yüklü Değil" + }, "title": "Marketplace" }, "done": "Tamam", diff --git a/src/i18n/locales/vi/marketplace.json b/src/i18n/locales/vi/marketplace.json index be39054309..8fbc1a7ad0 100644 --- a/src/i18n/locales/vi/marketplace.json +++ b/src/i18n/locales/vi/marketplace.json @@ -38,6 +38,12 @@ "noResults": "Không tìm thấy thẻ nào.", "selected": "Hiển thị các mục có bất kỳ thẻ nào được chọn" }, + "installed": { + "label": "Lọc theo trạng thái", + "all": "Tất cả các mục", + "installed": "Đã cài đặt", + "notInstalled": "Chưa cài đặt" + }, "title": "Marketplace" }, "done": "Hoàn thành", diff --git a/src/i18n/locales/zh-CN/marketplace.json b/src/i18n/locales/zh-CN/marketplace.json index 50a2ade635..a69e9c1e9d 100644 --- a/src/i18n/locales/zh-CN/marketplace.json +++ b/src/i18n/locales/zh-CN/marketplace.json @@ -38,6 +38,12 @@ "noResults": "未找到标签。", "selected": "显示包含任一选中标签的项目" }, + "installed": { + "label": "按状态筛选", + "all": "所有项目", + "installed": "已安装", + "notInstalled": "未安装" + }, "title": "Marketplace" }, "done": "完成", diff --git a/src/i18n/locales/zh-TW/marketplace.json b/src/i18n/locales/zh-TW/marketplace.json index 3eaeb0e7e0..9270314108 100644 --- a/src/i18n/locales/zh-TW/marketplace.json +++ b/src/i18n/locales/zh-TW/marketplace.json @@ -38,6 +38,12 @@ "noResults": "找不到標籤。", "selected": "顯示包含任一選取標籤的項目" }, + "installed": { + "label": "按狀態篩選", + "all": "所有項目", + "installed": "已安裝", + "notInstalled": "未安裝" + }, "title": "Marketplace" }, "done": "完成", diff --git a/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/webview-ui/src/components/marketplace/MarketplaceListView.tsx index 8ec13be8e5..c3c497ccfe 100644 --- a/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -1,6 +1,7 @@ import * as React from "react" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" import { X, ChevronsUpDown } from "lucide-react" @@ -27,6 +28,9 @@ export function MarketplaceListView({ stateManager, allTags, filteredTags, filte const allItems = state.displayItems || [] const organizationMcps = state.displayOrganizationMcps || [] + // NOTE: installed metadata is already synchronized into the state manager via handleMessage("state"/"marketplaceData") + // in MarketplaceViewStateManager; avoid dispatching UPDATE_FILTERS here to prevent render loops. + // Filter items by type if specified const items = filterByType ? allItems.filter((item) => item.type === filterByType) : allItems const orgMcps = filterByType === "mcp" ? organizationMcps : [] @@ -55,115 +59,132 @@ export function MarketplaceListView({ stateManager, allTags, filteredTags, filte } /> - {allTags.length > 0 && ( -
-
-
- -
- {state.filters.tags.length > 0 && ( - - )} -
- - setIsTagPopoverOpen(open)}> - - - - e.stopPropagation()}> - -
- - {tagSearch && ( - - )} -
- - - {t("marketplace:filters.tags.noResults")} - - - {filteredTags.map((tag: string) => ( - { - const isSelected = state.filters.tags.includes(tag) - manager.transition({ - type: "UPDATE_FILTERS", - payload: { - filters: { - tags: isSelected - ? state.filters.tags.filter((t) => t !== tag) - : [...state.filters.tags, tag], +
+ + {allTags.length > 0 && ( +
+ setIsTagPopoverOpen(open)}> + + + + e.stopPropagation()}> + +
+ + {tagSearch && ( + + )} +
+ + + {t("marketplace:filters.tags.noResults")} + + + {filteredTags.map((tag: string) => ( + { + const isSelected = state.filters.tags.includes(tag) + manager.transition({ + type: "UPDATE_FILTERS", + payload: { + filters: { + tags: isSelected + ? state.filters.tags.filter( + (t) => t !== tag, + ) + : [...state.filters.tags, tag], + }, }, - }, - }) - }} - data-selected={state.filters.tags.includes(tag)} - className="grid grid-cols-[1rem_1fr] gap-2 cursor-pointer text-sm capitalize" - onMouseDown={(e) => { - e.stopPropagation() - e.preventDefault() - }}> - {state.filters.tags.includes(tag) ? ( - - ) : ( - - )} - {tag} - - ))} - - -
-
-
- {state.filters.tags.length > 0 && ( -
- - {t("marketplace:filters.tags.selected")} -
- )} + }) + }} + data-selected={state.filters.tags.includes(tag)} + className="grid grid-cols-[1rem_1fr] gap-2 cursor-pointer text-sm capitalize" + onMouseDown={(e) => { + e.stopPropagation() + e.preventDefault() + }}> + {state.filters.tags.includes(tag) ? ( + + ) : ( + + )} + {tag} + + ))} + + + + + +
+ )} +
+ {state.filters.tags.length > 0 && ( +
+
+ + {t("marketplace:filters.tags.selected")} +
+
)}
@@ -187,7 +208,7 @@ export function MarketplaceListView({ stateManager, allTags, filteredTags, filte onClick={() => manager.transition({ type: "UPDATE_FILTERS", - payload: { filters: { search: "", type: "", tags: [] } }, + payload: { filters: { search: "", type: "", tags: [], installed: "all" } }, }) } className="mt-4 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground hover:bg-vscode-button-secondaryHoverBackground transition-colors"> diff --git a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts index 104f3e7cad..995e982164 100644 --- a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts +++ b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts @@ -14,6 +14,7 @@ import { MarketplaceItem } from "@roo-code/types" import { vscode } from "../../utils/vscode" import { WebviewMessage } from "../../../../src/shared/WebviewMessage" +import type { MarketplaceInstalledMetadata } from "../../../../src/shared/ExtensionMessage" export interface ViewState { allItems: MarketplaceItem[] @@ -26,7 +27,9 @@ export interface ViewState { type: string search: string tags: string[] + installed: "all" | "installed" | "not_installed" // Filter by installation status } + installedMetadata?: MarketplaceInstalledMetadata // Store installed metadata for filtering } type TransitionPayloads = { @@ -65,6 +68,7 @@ export class MarketplaceViewStateManager { type: "", search: "", tags: [], + installed: "all", }, } } @@ -189,8 +193,11 @@ export class MarketplaceViewStateManager { let newDisplayItems: MarketplaceItem[] let newDisplayOrganizationMcps: MarketplaceItem[] if (this.isFilterActive()) { - newDisplayItems = this.filterItems([...items]) - newDisplayOrganizationMcps = this.filterItems([...this.state.organizationMcps]) + newDisplayItems = this.filterItems([...items], this.state.installedMetadata) + newDisplayOrganizationMcps = this.filterItems( + [...this.state.organizationMcps], + this.state.installedMetadata, + ) } else { // No filters active - show all items newDisplayItems = [...items] @@ -251,6 +258,7 @@ export class MarketplaceViewStateManager { type: filters.type !== undefined ? filters.type : this.state.filters.type, search: filters.search !== undefined ? filters.search : this.state.filters.search, tags: filters.tags !== undefined ? filters.tags : this.state.filters.tags, + installed: filters.installed !== undefined ? filters.installed : this.state.filters.installed, } // Update filters first @@ -260,8 +268,11 @@ export class MarketplaceViewStateManager { } // Apply filters to displayItems and displayOrganizationMcps with the updated filters - const newDisplayItems = this.filterItems(this.state.allItems) - const newDisplayOrganizationMcps = this.filterItems(this.state.organizationMcps) + const newDisplayItems = this.filterItems(this.state.allItems, this.state.installedMetadata) + const newDisplayOrganizationMcps = this.filterItems( + this.state.organizationMcps, + this.state.installedMetadata, + ) // Update state with filtered items this.state = { @@ -284,39 +295,54 @@ export class MarketplaceViewStateManager { } public isFilterActive(): boolean { - return !!(this.state.filters.type || this.state.filters.search || this.state.filters.tags.length > 0) + return !!( + this.state.filters.type || + this.state.filters.search || + this.state.filters.tags.length > 0 || + this.state.filters.installed !== "all" + ) } - public filterItems(items: MarketplaceItem[]): MarketplaceItem[] { - const { type, search, tags } = this.state.filters + public filterItems(items: MarketplaceItem[], installedMetadata?: MarketplaceInstalledMetadata): MarketplaceItem[] { + const { type, search, tags, installed } = this.state.filters + const searchLower = search?.toLowerCase() - return items - .map((item) => { - // Create a copy of the item to modify - const itemCopy = { ...item } + return items.filter((item) => { + // Check type match + if (type && item.type !== type) { + return false + } - // Check specific match conditions for the main item - const typeMatch = !type || item.type === type - const nameMatch = search ? item.name.toLowerCase().includes(search.toLowerCase()) : false - const descriptionMatch = search - ? (item.description || "").toLowerCase().includes(search.toLowerCase()) - : false - const tagMatch = tags.length > 0 ? item.tags?.some((tag) => tags.includes(tag)) : false - - // Determine if the main item matches all filters - const mainItemMatches = - typeMatch && (!search || nameMatch || descriptionMatch) && (!tags.length || tagMatch) - - const hasMatchingSubcomponents = false - - // Return the item if it matches or has matching subcomponents - if (mainItemMatches || Boolean(hasMatchingSubcomponents)) { - return itemCopy + // Check search match + if (searchLower) { + const nameMatch = item.name.toLowerCase().includes(searchLower) + const descriptionMatch = (item.description || "").toLowerCase().includes(searchLower) + if (!nameMatch && !descriptionMatch) { + return false } + } - return null - }) - .filter((item): item is MarketplaceItem => item !== null) + // Check tag match + if (tags.length > 0 && !item.tags?.some((tag) => tags.includes(tag))) { + return false + } + + // Check installed status if filter is active + if (installed !== "all" && installedMetadata) { + const isInstalledGlobally = !!installedMetadata?.global?.[item.id] + const isInstalledInProject = !!installedMetadata?.project?.[item.id] + const isInstalled = isInstalledGlobally || isInstalledInProject + + if (installed === "installed" && !isInstalled) { + return false + } + if (installed === "not_installed" && isInstalled) { + return false + } + } + + return true + }) } public async handleMessage(message: any): Promise { @@ -343,20 +369,29 @@ export class MarketplaceViewStateManager { // Handle state updates for marketplace items // The state.marketplaceItems come from ClineProvider, see the file src/core/webview/ClineProvider.ts const marketplaceItems = message.state.marketplaceItems + const marketplaceInstalledMetadata = message.state.marketplaceInstalledMetadata if (marketplaceItems !== undefined) { // Always use the marketplace items from the extension when they're provided // This ensures fresh data is always displayed const items = [...marketplaceItems] + // Update installed metadata if provided + if (marketplaceInstalledMetadata !== undefined) { + this.state.installedMetadata = marketplaceInstalledMetadata + } + // Calculate display items based on current filters // If no filters are active, show all items // If filters are active, apply filtering let newDisplayItems: MarketplaceItem[] let newDisplayOrganizationMcps: MarketplaceItem[] if (this.isFilterActive()) { - newDisplayItems = this.filterItems(items) - newDisplayOrganizationMcps = this.filterItems(this.state.organizationMcps) + newDisplayItems = this.filterItems(items, this.state.installedMetadata) + newDisplayOrganizationMcps = this.filterItems( + this.state.organizationMcps, + this.state.installedMetadata, + ) } else { // No filters active - show all items newDisplayItems = items @@ -370,6 +405,7 @@ export class MarketplaceViewStateManager { allItems: items, displayItems: newDisplayItems, displayOrganizationMcps: newDisplayOrganizationMcps, + installedMetadata: marketplaceInstalledMetadata || this.state.installedMetadata, } // Notification is handled below after all state parts are processed } @@ -411,14 +447,25 @@ export class MarketplaceViewStateManager { if (message.type === "marketplaceData") { const marketplaceItems = message.marketplaceItems const organizationMcps = message.organizationMcps || [] + const marketplaceInstalledMetadata = message.marketplaceInstalledMetadata if (marketplaceItems !== undefined) { // Always use the marketplace items from the extension when they're provided // This ensures fresh data is always displayed const items = [...marketplaceItems] const orgMcps = [...organizationMcps] - const newDisplayItems = this.isFilterActive() ? this.filterItems(items) : items - const newDisplayOrganizationMcps = this.isFilterActive() ? this.filterItems(orgMcps) : orgMcps + + // Update installed metadata if provided + if (marketplaceInstalledMetadata !== undefined) { + this.state.installedMetadata = marketplaceInstalledMetadata + } + + const newDisplayItems = this.isFilterActive() + ? this.filterItems(items, this.state.installedMetadata) + : items + const newDisplayOrganizationMcps = this.isFilterActive() + ? this.filterItems(orgMcps, this.state.installedMetadata) + : orgMcps // Update state in a single operation this.state = { @@ -428,6 +475,7 @@ export class MarketplaceViewStateManager { organizationMcps: orgMcps, displayItems: newDisplayItems, displayOrganizationMcps: newDisplayOrganizationMcps, + installedMetadata: marketplaceInstalledMetadata || this.state.installedMetadata, } } diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.spec.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.spec.tsx index d22c381410..02f94001d1 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.spec.tsx +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.spec.tsx @@ -27,6 +27,7 @@ const mockState: ViewState = { type: "", search: "", tags: [], + installed: "all", }, } diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.spec.ts b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.spec.ts index 089226ccc3..a6ffc59518 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.spec.ts +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.spec.ts @@ -64,6 +64,7 @@ describe("MarketplaceViewStateManager", () => { type: "", search: "", tags: [], + installed: "all", }) }) diff --git a/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.spec.tsx b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.spec.tsx index 1f1ed9030b..4fdc685192 100644 --- a/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.spec.tsx +++ b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.spec.tsx @@ -78,6 +78,7 @@ describe("MarketplaceItemCard", () => { type: "", search: "", tags: [], + installed: "all" as "all" | "installed" | "not_installed", }, setFilters: vi.fn(), installed: { @@ -158,7 +159,12 @@ describe("MarketplaceItemCard", () => { renderWithProviders( Date: Thu, 21 Aug 2025 04:20:49 -0500 Subject: [PATCH 06/35] feat: add OpenAI context window error handling (#6967) * feat: add OpenAI context window error handling - Add comprehensive context window error detection for OpenAI, OpenRouter, Anthropic, and Cerebras - Implement automatic retry with aggressive context truncation (25% reduction) - Use proper profile settings for condensing operations - Add robust error handling with try-catch blocks Based on PR #5479 from cline/cline repository * fix: address PR review comments - Improved type safety by using Record instead of direct any casts - Enhanced Anthropic error detection with message pattern matching - Added comprehensive unit tests for context-error-handling module - Added named constant FORCED_CONTEXT_REDUCTION_PERCENT - Added MAX_CONTEXT_WINDOW_RETRIES limit to prevent infinite loops - Added logging for context window exceeded errors - Extracted getCurrentProfileId helper method to reduce duplication - All tests passing (3438 tests) * fix: address PR review comments for context window error handling - Improve Anthropic error detection with more specific patterns and error codes - Add comprehensive unit tests for context-error-handling module - Add logging for context window errors with detailed information - Fix comment for FORCED_CONTEXT_REDUCTION_PERCENT constant - Fix TypeScript error for untyped error parameter - Maintain existing getCurrentProfileId helper method --------- Co-authored-by: Roo Code --- .../__tests__/context-error-handling.test.ts | 329 ++++++++++++++++++ .../context-error-handling.ts | 114 ++++++ src/core/task/Task.ts | 90 ++++- 3 files changed, 529 insertions(+), 4 deletions(-) create mode 100644 src/core/context/context-management/__tests__/context-error-handling.test.ts create mode 100644 src/core/context/context-management/context-error-handling.ts diff --git a/src/core/context/context-management/__tests__/context-error-handling.test.ts b/src/core/context/context-management/__tests__/context-error-handling.test.ts new file mode 100644 index 0000000000..5d2321f0aa --- /dev/null +++ b/src/core/context/context-management/__tests__/context-error-handling.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, vi } from "vitest" +import { APIError } from "openai" +import { checkContextWindowExceededError } from "../context-error-handling" + +describe("checkContextWindowExceededError", () => { + describe("OpenAI errors", () => { + it("should detect OpenAI context window error with APIError instance", () => { + const error = Object.create(APIError.prototype) + Object.assign(error, { + status: 400, + code: "400", + message: "This model's maximum context length is 4096 tokens", + error: { + message: "This model's maximum context length is 4096 tokens", + type: "invalid_request_error", + param: null, + code: "context_length_exceeded", + }, + }) + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should detect OpenAI LengthFinishReasonError", () => { + const error = { + name: "LengthFinishReasonError", + message: "The response was cut off due to length", + } + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should not detect non-context OpenAI errors", () => { + const error = Object.create(APIError.prototype) + Object.assign(error, { + status: 400, + code: "400", + message: "Invalid API key", + error: { + message: "Invalid API key", + type: "invalid_request_error", + param: null, + code: "invalid_api_key", + }, + }) + + expect(checkContextWindowExceededError(error)).toBe(false) + }) + }) + + describe("OpenRouter errors", () => { + it("should detect OpenRouter context window error with status 400", () => { + const error = { + status: 400, + message: "Request exceeds maximum context length of 8192 tokens", + } + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should detect OpenRouter error with nested error structure", () => { + const error = { + error: { + status: 400, + message: "Input tokens exceed model limit", + }, + } + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should detect OpenRouter error with response status", () => { + const error = { + response: { + status: 400, + }, + message: "Too many tokens in the request", + } + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should detect various context error patterns", () => { + const patterns = [ + "context length exceeded", + "maximum context window", + "input tokens exceed limit", + "too many tokens", + ] + + patterns.forEach((pattern) => { + const error = { + status: 400, + message: pattern, + } + expect(checkContextWindowExceededError(error)).toBe(true) + }) + }) + + it("should not detect non-context 400 errors", () => { + const error = { + status: 400, + message: "Invalid request format", + } + + expect(checkContextWindowExceededError(error)).toBe(false) + }) + + it("should not detect errors with different status codes", () => { + const error = { + status: 500, + message: "context length exceeded", + } + + expect(checkContextWindowExceededError(error)).toBe(false) + }) + }) + + describe("Anthropic errors", () => { + it("should detect Anthropic context window error", () => { + const error = { + error: { + error: { + type: "invalid_request_error", + message: "prompt is too long: 150000 tokens > 100000 maximum", + }, + }, + } + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should detect Anthropic error with context_length_exceeded code", () => { + const error = { + error: { + error: { + type: "invalid_request_error", + code: "context_length_exceeded", + message: "The request exceeds the maximum context window", + }, + }, + } + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should detect various Anthropic context error patterns", () => { + const patterns = [ + "prompt is too long", + "maximum 200000 tokens", + "context is too long", + "exceeds the context window", + "token limit exceeded", + ] + + patterns.forEach((pattern) => { + const error = { + error: { + error: { + type: "invalid_request_error", + message: pattern, + }, + }, + } + expect(checkContextWindowExceededError(error)).toBe(true) + }) + }) + + it("should not detect non-context Anthropic errors", () => { + const error = { + error: { + error: { + type: "invalid_request_error", + message: "Invalid model specified", + }, + }, + } + + expect(checkContextWindowExceededError(error)).toBe(false) + }) + + it("should not detect errors with different error types", () => { + const error = { + error: { + error: { + type: "authentication_error", + message: "prompt is too long", + }, + }, + } + + expect(checkContextWindowExceededError(error)).toBe(false) + }) + }) + + describe("Cerebras errors", () => { + it("should detect Cerebras context window error", () => { + const error = { + status: 400, + message: "Please reduce the length of the messages or completion", + } + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should detect Cerebras error with nested structure", () => { + const error = { + error: { + status: 400, + message: "Please reduce the length of the messages or completion", + }, + } + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should not detect non-context Cerebras errors", () => { + const error = { + status: 400, + message: "Invalid request parameters", + } + + expect(checkContextWindowExceededError(error)).toBe(false) + }) + }) + + describe("Edge cases", () => { + it("should handle null input", () => { + expect(checkContextWindowExceededError(null)).toBe(false) + }) + + it("should handle undefined input", () => { + expect(checkContextWindowExceededError(undefined)).toBe(false) + }) + + it("should handle empty object", () => { + expect(checkContextWindowExceededError({})).toBe(false) + }) + + it("should handle string input", () => { + expect(checkContextWindowExceededError("error")).toBe(false) + }) + + it("should handle number input", () => { + expect(checkContextWindowExceededError(123)).toBe(false) + }) + + it("should handle array input", () => { + expect(checkContextWindowExceededError([])).toBe(false) + }) + + it("should handle errors with circular references", () => { + const error: any = { status: 400, message: "context length exceeded" } + error.self = error // Create circular reference + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + + it("should handle errors with deeply nested undefined values", () => { + const error = { + error: { + error: { + type: undefined, + message: undefined, + }, + }, + } + + expect(checkContextWindowExceededError(error)).toBe(false) + }) + + it("should handle errors that throw during property access", () => { + const error = { + get status() { + throw new Error("Property access error") + }, + message: "context length exceeded", + } + + expect(checkContextWindowExceededError(error)).toBe(false) + }) + + it("should handle mixed provider error structures", () => { + // Error that could match multiple providers + const error = { + status: 400, + code: "400", + message: "context length exceeded", + error: { + error: { + type: "invalid_request_error", + message: "prompt is too long", + }, + }, + } + + expect(checkContextWindowExceededError(error)).toBe(true) + }) + }) + + describe("Multiple provider detection", () => { + it("should detect error if any provider check returns true", () => { + // This error should be detected by OpenRouter check + const error1 = { + status: 400, + message: "context window exceeded", + } + expect(checkContextWindowExceededError(error1)).toBe(true) + + // This error should be detected by Anthropic check + const error2 = { + error: { + error: { + type: "invalid_request_error", + message: "prompt is too long", + }, + }, + } + expect(checkContextWindowExceededError(error2)).toBe(true) + + // This error should be detected by Cerebras check + const error3 = { + status: 400, + message: "Please reduce the length of the messages or completion", + } + expect(checkContextWindowExceededError(error3)).toBe(true) + }) + }) +}) diff --git a/src/core/context/context-management/context-error-handling.ts b/src/core/context/context-management/context-error-handling.ts new file mode 100644 index 0000000000..006d7b1607 --- /dev/null +++ b/src/core/context/context-management/context-error-handling.ts @@ -0,0 +1,114 @@ +import { APIError } from "openai" + +export function checkContextWindowExceededError(error: unknown): boolean { + return ( + checkIsOpenAIContextWindowError(error) || + checkIsOpenRouterContextWindowError(error) || + checkIsAnthropicContextWindowError(error) || + checkIsCerebrasContextWindowError(error) + ) +} + +function checkIsOpenRouterContextWindowError(error: unknown): boolean { + try { + if (!error || typeof error !== "object") { + return false + } + + // Use Record for proper type narrowing + const err = error as Record + const status = err.status ?? err.code ?? err.error?.status ?? err.response?.status + const message: string = String(err.message || err.error?.message || "") + + // Known OpenAI/OpenRouter-style signal (code 400 and message includes "context length") + const CONTEXT_ERROR_PATTERNS = [ + /\bcontext\s*(?:length|window)\b/i, + /\bmaximum\s*context\b/i, + /\b(?:input\s*)?tokens?\s*exceed/i, + /\btoo\s*many\s*tokens?\b/i, + ] as const + + return String(status) === "400" && CONTEXT_ERROR_PATTERNS.some((pattern) => pattern.test(message)) + } catch { + return false + } +} + +// Docs: https://platform.openai.com/docs/guides/error-codes/api-errors +function checkIsOpenAIContextWindowError(error: unknown): boolean { + try { + // Check for LengthFinishReasonError + if (error && typeof error === "object" && "name" in error && error.name === "LengthFinishReasonError") { + return true + } + + const KNOWN_CONTEXT_ERROR_SUBSTRINGS = ["token", "context length"] as const + + return ( + Boolean(error) && + error instanceof APIError && + error.code?.toString() === "400" && + KNOWN_CONTEXT_ERROR_SUBSTRINGS.some((substring) => error.message.includes(substring)) + ) + } catch { + return false + } +} + +function checkIsAnthropicContextWindowError(response: unknown): boolean { + try { + // Type guard to safely access properties + if (!response || typeof response !== "object") { + return false + } + + // Use type assertions with proper checks + const res = response as Record + + // Check for Anthropic-specific error structure with more specific validation + if (res.error?.error?.type === "invalid_request_error") { + const message: string = String(res.error?.error?.message || "") + + // More specific patterns for context window errors + const contextWindowPatterns = [ + /prompt is too long/i, + /maximum.*tokens/i, + /context.*too.*long/i, + /exceeds.*context/i, + /token.*limit/i, + /context_length_exceeded/i, + /max_tokens_to_sample/i, + ] + + // Additional check for Anthropic-specific error codes + const errorCode = res.error?.error?.code + if (errorCode === "context_length_exceeded" || errorCode === "invalid_request_error") { + return contextWindowPatterns.some((pattern) => pattern.test(message)) + } + + return contextWindowPatterns.some((pattern) => pattern.test(message)) + } + + return false + } catch { + return false + } +} + +function checkIsCerebrasContextWindowError(response: unknown): boolean { + try { + // Type guard to safely access properties + if (!response || typeof response !== "object") { + return false + } + + // Use type assertions with proper checks + const res = response as Record + const status = res.status ?? res.code ?? res.error?.status ?? res.response?.status + const message: string = String(res.message || res.error?.message || "") + + return String(status) === "400" && message.includes("Please reduce the length of the messages or completion") + } catch { + return false + } +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 34f3218236..3c3afeaadf 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -88,6 +88,7 @@ import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search- import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "../task-persistence" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" +import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" import { type CheckpointDiffOptions, type CheckpointRestoreOptions, @@ -105,6 +106,8 @@ import { AutoApprovalHandler } from "./AutoApprovalHandler" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds +const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors +const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors export type TaskOptions = { provider: ClineProvider @@ -1387,7 +1390,7 @@ export class Task extends EventEmitter implements TaskLike { if (this.bridgeService) { this.bridgeService .unsubscribeFromTask(this.taskId) - .catch((error) => console.error("Error unsubscribing from task bridge:", error)) + .catch((error: unknown) => console.error("Error unsubscribing from task bridge:", error)) this.bridgeService = null } @@ -2232,6 +2235,71 @@ export class Task extends EventEmitter implements TaskLike { })() } + private getCurrentProfileId(state: any): string { + return ( + state?.listApiConfigMeta?.find((profile: any) => profile.name === state?.currentApiConfigName)?.id ?? + "default" + ) + } + + private async handleContextWindowExceededError(): Promise { + const state = await this.providerRef.deref()?.getState() + const { profileThresholds = {} } = state ?? {} + + const { contextTokens } = this.getTokenUsage() + const modelInfo = this.api.getModel().info + const maxTokens = getModelMaxOutputTokens({ + modelId: this.api.getModel().id, + model: modelInfo, + settings: this.apiConfiguration, + }) + const contextWindow = modelInfo.contextWindow + + // Get the current profile ID using the helper method + const currentProfileId = this.getCurrentProfileId(state) + + // Log the context window error for debugging + console.warn( + `[Task#${this.taskId}] Context window exceeded for model ${this.api.getModel().id}. ` + + `Current tokens: ${contextTokens}, Context window: ${contextWindow}. ` + + `Forcing truncation to ${FORCED_CONTEXT_REDUCTION_PERCENT}% of current context.`, + ) + + // Force aggressive truncation by keeping only 75% of the conversation history + const truncateResult = await truncateConversationIfNeeded({ + messages: this.apiConversationHistory, + totalTokens: contextTokens || 0, + maxTokens, + contextWindow, + apiHandler: this.api, + autoCondenseContext: true, + autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, + systemPrompt: await this.getSystemPrompt(), + taskId: this.taskId, + profileThresholds, + currentProfileId, + }) + + if (truncateResult.messages !== this.apiConversationHistory) { + await this.overwriteApiConversationHistory(truncateResult.messages) + } + + if (truncateResult.summary) { + const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult + const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } + await this.say( + "condense_context", + undefined /* text */, + undefined /* images */, + false /* partial */, + undefined /* checkpoint */, + undefined /* progressStatus */, + { isNonInteractive: true } /* options */, + contextCondense, + ) + } + } + public async *attemptApiRequest(retryAttempt: number = 0): ApiStream { const state = await this.providerRef.deref()?.getState() @@ -2310,9 +2378,8 @@ export class Task extends EventEmitter implements TaskLike { const contextWindow = modelInfo.contextWindow - const currentProfileId = - state?.listApiConfigMeta.find((profile) => profile.name === state?.currentApiConfigName)?.id ?? - "default" + // Get the current profile ID using the helper method + const currentProfileId = this.getCurrentProfileId(state) const truncateResult = await truncateConversationIfNeeded({ messages: this.apiConversationHistory, @@ -2419,6 +2486,21 @@ export class Task extends EventEmitter implements TaskLike { this.isWaitingForFirstChunk = false } catch (error) { this.isWaitingForFirstChunk = false + const isContextWindowExceededError = checkContextWindowExceededError(error) + + // If it's a context window error and we haven't exceeded max retries for this error type + if (isContextWindowExceededError && retryAttempt < MAX_CONTEXT_WINDOW_RETRIES) { + console.warn( + `[Task#${this.taskId}] Context window exceeded for model ${this.api.getModel().id}. ` + + `Retry attempt ${retryAttempt + 1}/${MAX_CONTEXT_WINDOW_RETRIES}. ` + + `Attempting automatic truncation...`, + ) + await this.handleContextWindowExceededError() + // Retry the request after handling the context window error + yield* this.attemptApiRequest(retryAttempt + 1) + return + } + // note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely. if (autoApprovalEnabled && alwaysApproveResubmit) { let errorMsg From b2fdb9ac5758293fcc06af31dc2ee1555c9171d8 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 02:21:41 -0700 Subject: [PATCH 07/35] fix: handle null/undefined token values in ContextCondenseRow to prevent UI crash (#6916) * fix: handle null/undefined token values in ContextCondenseRow to prevent UI crash - Added null/undefined checks for prevContextTokens, newContextTokens, and cost - Default to 0 when values are null or undefined - Added comprehensive test coverage for edge cases - Fixes #6914 * Delete webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx --------- Co-authored-by: Roo Code Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com> --- webview-ui/src/components/chat/ContextCondenseRow.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/chat/ContextCondenseRow.tsx b/webview-ui/src/components/chat/ContextCondenseRow.tsx index 9664b03e00..c2fdba802d 100644 --- a/webview-ui/src/components/chat/ContextCondenseRow.tsx +++ b/webview-ui/src/components/chat/ContextCondenseRow.tsx @@ -11,6 +11,11 @@ export const ContextCondenseRow = ({ cost, prevContextTokens, newContextTokens, const { t } = useTranslation() const [isExpanded, setIsExpanded] = useState(false) + // Handle null/undefined token values to prevent crashes + const prevTokens = prevContextTokens ?? 0 + const newTokens = newContextTokens ?? 0 + const displayCost = cost ?? 0 + return (
{t("chat:contextCondense.title")} - {prevContextTokens.toLocaleString()} → {newContextTokens.toLocaleString()} {t("tokens")} + {prevTokens.toLocaleString()} → {newTokens.toLocaleString()} {t("tokens")} - 0 ? "opacity-100" : "opacity-0"}>${cost.toFixed(2)} + 0 ? "opacity-100" : "opacity-0"}> + ${displayCost.toFixed(2)} +
From 4fdbcb5d78abfbb077872b3696ac5263ff7e5997 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 02:28:00 -0700 Subject: [PATCH 08/35] fix(deps): update dependency tmp to v0.2.4 [security] (#6762) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b05ddb264..be701e50e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -750,7 +750,7 @@ importers: version: 1.0.21 tmp: specifier: ^0.2.3 - version: 0.2.3 + version: 0.2.4 tree-sitter-wasms: specifier: ^0.1.12 version: 0.1.12 @@ -9110,6 +9110,10 @@ packages: resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} engines: {node: '>=14.14'} + tmp@0.2.4: + resolution: {integrity: sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==} + engines: {node: '>=14.14'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -15292,7 +15296,7 @@ snapshots: jszip: 3.10.1 readable-stream: 3.6.2 saxes: 5.0.1 - tmp: 0.2.3 + tmp: 0.2.4 unzipper: 0.10.14 uuid: 8.3.2 @@ -19437,6 +19441,8 @@ snapshots: tmp@0.2.3: {} + tmp@0.2.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 From 6fd261d3b63cf05c95434903e35c34d49fbe4d81 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 02:56:55 -0700 Subject: [PATCH 09/35] feat: mark non-English package.nls.*.json files as linguist-generated (#7271) Co-authored-by: Roo Code --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitattributes b/.gitattributes index 284eab4f98..e9e36432cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,11 @@ src/assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text *.snap linguist-generated=true # Non-English translation files - mark as linguist-generated to exclude from GitHub language statistics +# Package NLS files - mark non-English ones as generated +src/package.nls.*.json linguist-generated=true +# Exclude the base English file from being marked as generated +src/package.nls.json linguist-generated=false + # Root locales directory (contains only non-English translations) locales/** linguist-generated=true From 4216618c7283df035df535e8979b104e1fc75510 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 21 Aug 2025 13:12:04 -0500 Subject: [PATCH 10/35] feat: add MDM authentication notification when navigation is blocked (#7291) - Show VSCode warning when users under MDM policy try to leave AccountView without auth - Add showMdmAuthRequiredNotification message type to WebviewMessage interface - Implement handler in webviewMessageHandler to display localized warning - Add 'Your organization requires authentication' translation in all 17 languages - Fix translation key path to use common:mdm.info.organization_requires_auth --- src/core/webview/ClineProvider.ts | 9 ++++++--- src/core/webview/webviewMessageHandler.ts | 5 +++++ src/i18n/locales/ca/common.json | 3 +++ src/i18n/locales/de/common.json | 3 +++ src/i18n/locales/en/common.json | 3 +++ src/i18n/locales/es/common.json | 3 +++ src/i18n/locales/fr/common.json | 3 +++ src/i18n/locales/hi/common.json | 3 +++ src/i18n/locales/id/common.json | 3 +++ src/i18n/locales/it/common.json | 3 +++ src/i18n/locales/ja/common.json | 3 +++ src/i18n/locales/ko/common.json | 3 +++ src/i18n/locales/nl/common.json | 3 +++ src/i18n/locales/pl/common.json | 3 +++ src/i18n/locales/pt-BR/common.json | 3 +++ src/i18n/locales/ru/common.json | 3 +++ src/i18n/locales/tr/common.json | 3 +++ src/i18n/locales/vi/common.json | 3 +++ src/i18n/locales/zh-CN/common.json | 3 +++ src/i18n/locales/zh-TW/common.json | 3 +++ src/shared/WebviewMessage.ts | 1 + webview-ui/src/App.tsx | 5 ++++- 22 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9c28120f17..77dfa42a3b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1553,7 +1553,8 @@ export class ClineProvider this.postMessageToWebview({ type: "state", state }) // Check MDM compliance and send user to account tab if not compliant - if (!this.checkMdmCompliance()) { + // Only redirect if there's an actual MDM policy requiring authentication + if (this.mdmService?.requiresCloudAuth() && !this.checkMdmCompliance()) { await this.postMessageToWebview({ type: "action", action: "accountButtonClicked" }) } } @@ -1872,7 +1873,9 @@ export class ClineProvider codebaseIndexSearchMaxResults: codebaseIndexConfig?.codebaseIndexSearchMaxResults, codebaseIndexSearchMinScore: codebaseIndexConfig?.codebaseIndexSearchMinScore, }, - mdmCompliant: this.checkMdmCompliance(), + // Only set mdmCompliant if there's an actual MDM policy + // undefined means no MDM policy, true means compliant, false means non-compliant + mdmCompliant: this.mdmService?.requiresCloudAuth() ? this.checkMdmCompliance() : undefined, profileThresholds: profileThresholds ?? {}, cloudApiUrl: getRooCodeApiUrl(), hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false, @@ -2172,7 +2175,7 @@ export class ClineProvider /** * Check if the current state is compliant with MDM policy - * @returns true if compliant, false if blocked + * @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant */ public checkMdmCompliance(): boolean { if (!this.mdmService) { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4dd0fee75e..5e4971ecaf 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2618,5 +2618,10 @@ export const webviewMessageHandler = async ( } break } + case "showMdmAuthRequiredNotification": { + // Show notification that organization requires authentication + vscode.window.showWarningMessage(t("common:mdm.info.organization_requires_auth")) + break + } } } diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 6235593f7e..74b265f513 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "La teva organització requereix autenticació de Roo Code Cloud. Si us plau, inicia sessió per continuar.", "organization_mismatch": "Has d'estar autenticat amb el compte de Roo Code Cloud de la teva organització.", "verification_failed": "No s'ha pogut verificar l'autenticació de l'organització." + }, + "info": { + "organization_requires_auth": "La teva organització requereix autenticació." } }, "prompts": { diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 6819b27d73..856e4e1dce 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Deine Organisation erfordert eine Roo Code Cloud-Authentifizierung. Bitte melde dich an, um fortzufahren.", "organization_mismatch": "Du musst mit dem Roo Code Cloud-Konto deiner Organisation authentifiziert sein.", "verification_failed": "Die Organisationsauthentifizierung konnte nicht verifiziert werden." + }, + "info": { + "organization_requires_auth": "Deine Organisation erfordert eine Authentifizierung." } }, "prompts": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 696ecb44d4..e413bc0890 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -180,6 +180,9 @@ "cloud_auth_required": "Your organization requires Roo Code Cloud authentication. Please sign in to continue.", "organization_mismatch": "You must be authenticated with your organization's Roo Code Cloud account.", "verification_failed": "Unable to verify organization authentication." + }, + "info": { + "organization_requires_auth": "Your organization requires authentication." } }, "prompts": { diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index c1b399b84f..7b2b9a4347 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Tu organización requiere autenticación de Roo Code Cloud. Por favor, inicia sesión para continuar.", "organization_mismatch": "Debes estar autenticado con la cuenta de Roo Code Cloud de tu organización.", "verification_failed": "No se pudo verificar la autenticación de la organización." + }, + "info": { + "organization_requires_auth": "Tu organización requiere autenticación." } }, "prompts": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 682e12e224..e9282a0b97 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Votre organisation nécessite une authentification Roo Code Cloud. Veuillez vous connecter pour continuer.", "organization_mismatch": "Vous devez être authentifié avec le compte Roo Code Cloud de votre organisation.", "verification_failed": "Impossible de vérifier l'authentification de l'organisation." + }, + "info": { + "organization_requires_auth": "Votre organisation nécessite une authentification." } }, "prompts": { diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 05e0a622cc..3f5ab60413 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "आपके संगठन को Roo Code Cloud प्रमाणीकरण की आवश्यकता है। कृपया जारी रखने के लिए साइन इन करें।", "organization_mismatch": "आपको अपने संगठन के Roo Code Cloud खाते से प्रमाणित होना होगा।", "verification_failed": "संगठन प्रमाणीकरण सत्यापित करने में असमर्थ।" + }, + "info": { + "organization_requires_auth": "आपके संगठन को प्रमाणीकरण की आवश्यकता है।" } }, "prompts": { diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 1595b795cf..3c43056503 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Organisasi kamu memerlukan autentikasi Roo Code Cloud. Silakan masuk untuk melanjutkan.", "organization_mismatch": "Kamu harus diautentikasi dengan akun Roo Code Cloud organisasi kamu.", "verification_failed": "Tidak dapat memverifikasi autentikasi organisasi." + }, + "info": { + "organization_requires_auth": "Organisasi kamu memerlukan autentikasi." } }, "prompts": { diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 73f4d47788..c19114baf1 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "La tua organizzazione richiede l'autenticazione Roo Code Cloud. Accedi per continuare.", "organization_mismatch": "Devi essere autenticato con l'account Roo Code Cloud della tua organizzazione.", "verification_failed": "Impossibile verificare l'autenticazione dell'organizzazione." + }, + "info": { + "organization_requires_auth": "La tua organizzazione richiede l'autenticazione." } }, "prompts": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index cb55c7bf0b..d595484fa1 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "あなたの組織では Roo Code Cloud 認証が必要です。続行するにはサインインしてください。", "organization_mismatch": "組織の Roo Code Cloud アカウントで認証する必要があります。", "verification_failed": "組織認証の確認ができませんでした。" + }, + "info": { + "organization_requires_auth": "あなたの組織では認証が必要です。" } }, "prompts": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 9bb61b6563..3209952c6d 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "조직에서 Roo Code Cloud 인증이 필요합니다. 계속하려면 로그인하세요.", "organization_mismatch": "조직의 Roo Code Cloud 계정으로 인증해야 합니다.", "verification_failed": "조직 인증을 확인할 수 없습니다." + }, + "info": { + "organization_requires_auth": "조직에서 인증이 필요합니다." } }, "prompts": { diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index fb2fcec9f9..c0c6ba35e9 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Je organisatie vereist Roo Code Cloud-authenticatie. Log in om door te gaan.", "organization_mismatch": "Je moet geauthenticeerd zijn met het Roo Code Cloud-account van je organisatie.", "verification_failed": "Kan organisatie-authenticatie niet verifiëren." + }, + "info": { + "organization_requires_auth": "Je organisatie vereist authenticatie." } }, "prompts": { diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 2a6fee3e23..475ba069ee 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Twoja organizacja wymaga uwierzytelnienia Roo Code Cloud. Zaloguj się, aby kontynuować.", "organization_mismatch": "Musisz być uwierzytelniony kontem Roo Code Cloud swojej organizacji.", "verification_failed": "Nie można zweryfikować uwierzytelnienia organizacji." + }, + "info": { + "organization_requires_auth": "Twoja organizacja wymaga uwierzytelnienia." } }, "prompts": { diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 83d960ad2d..55a41fcf1b 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Sua organização requer autenticação do Roo Code Cloud. Faça login para continuar.", "organization_mismatch": "Você deve estar autenticado com a conta Roo Code Cloud da sua organização.", "verification_failed": "Não foi possível verificar a autenticação da organização." + }, + "info": { + "organization_requires_auth": "Sua organização requer autenticação." } }, "prompts": { diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 9c37cfe3ed..505998daa2 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Ваша организация требует аутентификации Roo Code Cloud. Войдите в систему, чтобы продолжить.", "organization_mismatch": "Вы должны быть аутентифицированы с учетной записью Roo Code Cloud вашей организации.", "verification_failed": "Не удается проверить аутентификацию организации." + }, + "info": { + "organization_requires_auth": "Ваша организация требует аутентификации." } }, "prompts": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index d99008755e..9b8af8d94c 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Kuruluşunuz Roo Code Cloud kimlik doğrulaması gerektiriyor. Devam etmek için giriş yapın.", "organization_mismatch": "Kuruluşunuzun Roo Code Cloud hesabıyla kimlik doğrulaması yapmalısınız.", "verification_failed": "Kuruluş kimlik doğrulaması doğrulanamıyor." + }, + "info": { + "organization_requires_auth": "Kuruluşunuz kimlik doğrulaması gerektiriyor." } }, "prompts": { diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index d29525cc03..4877f297ad 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "Tổ chức của bạn yêu cầu xác thực Roo Code Cloud. Vui lòng đăng nhập để tiếp tục.", "organization_mismatch": "Bạn phải được xác thực bằng tài khoản Roo Code Cloud của tổ chức.", "verification_failed": "Không thể xác minh xác thực tổ chức." + }, + "info": { + "organization_requires_auth": "Tổ chức của bạn yêu cầu xác thực." } }, "prompts": { diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index fc0386c95d..5bac0d2847 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -196,6 +196,9 @@ "cloud_auth_required": "您的组织需要 Roo Code Cloud 身份验证。请登录以继续。", "organization_mismatch": "您必须使用组织的 Roo Code Cloud 账户进行身份验证。", "verification_failed": "无法验证组织身份验证。" + }, + "info": { + "organization_requires_auth": "您的组织需要身份验证。" } }, "prompts": { diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 753463b9f5..0f82f48d13 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -191,6 +191,9 @@ "cloud_auth_required": "您的組織需要 Roo Code Cloud 身份驗證。請登入以繼續。", "organization_mismatch": "您必須使用組織的 Roo Code Cloud 帳戶進行身份驗證。", "verification_failed": "無法驗證組織身份驗證。" + }, + "info": { + "organization_requires_auth": "您的組織需要身份驗證。" } }, "prompts": { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d59ccd556c..d8b873e40a 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -211,6 +211,7 @@ export interface WebviewMessage { | "deleteCommand" | "createCommand" | "insertTextIntoTextarea" + | "showMdmAuthRequiredNotification" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 3782242707..f24e4556a1 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -105,8 +105,11 @@ const App = () => { const switchTab = useCallback( (newTab: Tab) => { - // Check MDM compliance before allowing tab switching + // Only check MDM compliance if mdmCompliant is explicitly false (meaning there's an MDM policy and user is non-compliant) + // If mdmCompliant is undefined or true, allow tab switching if (mdmCompliant === false && newTab !== "account") { + // Notify the user that authentication is required by their organization + vscode.postMessage({ type: "showMdmAuthRequiredNotification" }) return } From ef340806c7b24807145b802a82974d2270f01733 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Thu, 21 Aug 2025 12:36:33 -0700 Subject: [PATCH 11/35] Evals web app fixes / tweaks (#7299) --- apps/web-evals/.env | 2 +- apps/web-evals/scripts/check-services.sh | 4 +-- apps/web-evals/src/app/runs/new/new-run.tsx | 35 +++---------------- .../src/app/runs/new/settings-diff.tsx | 6 ++-- 4 files changed, 11 insertions(+), 36 deletions(-) diff --git a/apps/web-evals/.env b/apps/web-evals/.env index 7970806bec..1bb6dd6dac 100644 --- a/apps/web-evals/.env +++ b/apps/web-evals/.env @@ -1 +1 @@ -DATABASE_URL=postgres://postgres:password@localhost:5432/evals_development +DATABASE_URL=postgres://postgres:password@localhost:5433/evals_development diff --git a/apps/web-evals/scripts/check-services.sh b/apps/web-evals/scripts/check-services.sh index 104a472208..d72ffd54e8 100755 --- a/apps/web-evals/scripts/check-services.sh +++ b/apps/web-evals/scripts/check-services.sh @@ -5,13 +5,13 @@ if ! docker info &> /dev/null; then exit 1 fi -if ! nc -z localhost 5432 2>/dev/null; then +if ! nc -z postgres 5433 2>/dev/null; then echo "❌ PostgreSQL is not running on port 5432" echo "💡 Start it with: pnpm --filter @roo-code/evals db:up" exit 1 fi -if ! nc -z localhost 6379 2>/dev/null; then +if ! nc -z redis 6380 2>/dev/null; then echo "❌ Redis is not running on port 6379" echo "💡 Start it with: pnpm --filter @roo-code/evals redis:up" exit 1 diff --git a/apps/web-evals/src/app/runs/new/new-run.tsx b/apps/web-evals/src/app/runs/new/new-run.tsx index f8633611b6..41d35f3c4c 100644 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ b/apps/web-evals/src/app/runs/new/new-run.tsx @@ -8,7 +8,7 @@ import { useForm, FormProvider } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import fuzzysort from "fuzzysort" import { toast } from "sonner" -import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, Book, CircleCheck } from "lucide-react" +import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, CircleCheck } from "lucide-react" import { globalSettingsSchema, providerSettingsSchema, EVALS_SETTINGS, getModelId } from "@roo-code/types" @@ -49,11 +49,8 @@ import { PopoverContent, PopoverTrigger, ScrollArea, + ScrollBar, Slider, - Dialog, - DialogContent, - DialogTitle, - DialogFooter, } from "@/components/ui" import { SettingsDiff } from "./settings-diff" @@ -93,10 +90,6 @@ export function NewRun() { const [model, suite, settings] = watch(["model", "suite", "settings", "concurrency"]) - const [systemPromptDialogOpen, setSystemPromptDialogOpen] = useState(false) - const [systemPrompt, setSystemPrompt] = useState("") - const systemPromptRef = useRef(null) - const onSubmit = useCallback( async (values: CreateRun) => { try { @@ -104,13 +97,13 @@ export function NewRun() { values.settings = { ...(values.settings || {}), openRouterModelId: model } } - const { id } = await createRun({ ...values, systemPrompt }) + const { id } = await createRun(values) router.push(`/runs/${id}`) } catch (e) { toast.error(e instanceof Error ? e.message : "An unknown error occurred.") } }, - [mode, model, router, systemPrompt], + [mode, model, router], ) const onFilterModels = useCallback( @@ -269,29 +262,11 @@ export function NewRun() { + )} - - - - - - Override System Prompt -