From 503f40241db828a5782161da471aa7b5e66225a7 Mon Sep 17 00:00:00 2001
From: Hannes Rudolph
Date: Tue, 6 Jan 2026 13:17:50 -0700
Subject: [PATCH 01/21] feat(proxy): add debug-mode proxy routing (#10467)
---
pnpm-lock.yaml | 74 ++++-
src/esbuild.mjs | 5 +-
src/extension.ts | 6 +
src/package.json | 17 ++
src/package.nls.ca.json | 5 +-
src/package.nls.de.json | 5 +-
src/package.nls.es.json | 5 +-
src/package.nls.fr.json | 5 +-
src/package.nls.hi.json | 5 +-
src/package.nls.id.json | 5 +-
src/package.nls.it.json | 5 +-
src/package.nls.ja.json | 5 +-
src/package.nls.json | 5 +-
src/package.nls.ko.json | 5 +-
src/package.nls.nl.json | 5 +-
src/package.nls.pl.json | 5 +-
src/package.nls.pt-BR.json | 5 +-
src/package.nls.ru.json | 5 +-
src/package.nls.tr.json | 5 +-
src/package.nls.vi.json | 5 +-
src/package.nls.zh-CN.json | 5 +-
src/package.nls.zh-TW.json | 5 +-
src/types/global-agent.d.ts | 47 +++
src/utils/__tests__/networkProxy.spec.ts | 308 +++++++++++++++++++
src/utils/networkProxy.ts | 364 +++++++++++++++++++++++
25 files changed, 890 insertions(+), 21 deletions(-)
create mode 100644 src/types/global-agent.d.ts
create mode 100644 src/utils/__tests__/networkProxy.spec.ts
create mode 100644 src/utils/networkProxy.ts
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 190639f11d..983a6e97b5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -797,6 +797,9 @@ importers:
get-folder-size:
specifier: ^5.0.0
version: 5.0.0
+ global-agent:
+ specifier: ^3.0.0
+ version: 3.0.0
google-auth-library:
specifier: ^9.15.1
version: 9.15.1
@@ -923,6 +926,9 @@ importers:
turndown:
specifier: ^7.2.0
version: 7.2.0
+ undici:
+ specifier: '>=5.29.0'
+ version: 6.21.3
uuid:
specifier: ^11.1.0
version: 11.1.0
@@ -4776,6 +4782,10 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+ boolean@3.2.0:
+ resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
+
bowser@2.11.0:
resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==}
@@ -5554,6 +5564,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ detect-node@2.1.0:
+ resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
+
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -5870,6 +5883,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
+ es6-error@4.1.1:
+ resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
+
esbuild-register@3.6.0:
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
peerDependencies:
@@ -6449,6 +6465,10 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
+ global-agent@3.0.0:
+ resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
+ engines: {node: '>=10.0'}
+
globals@11.12.0:
resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
engines: {node: '>=4'}
@@ -7574,6 +7594,10 @@ packages:
engines: {node: '>= 20'}
hasBin: true
+ matcher@3.0.0:
+ resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
+ engines: {node: '>=10'}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -8920,6 +8944,10 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
+ roarr@2.15.4:
+ resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
+ engines: {node: '>=8.0'}
+
robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
@@ -9041,6 +9069,10 @@ packages:
resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==}
engines: {node: '>=18'}
+ serialize-error@7.0.1:
+ resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
+ engines: {node: '>=10'}
+
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
@@ -9740,6 +9772,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
+ type-fest@0.13.1:
+ resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
+ engines: {node: '>=10'}
+
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
@@ -14682,6 +14718,8 @@ snapshots:
boolbase@1.0.0: {}
+ boolean@3.2.0: {}
+
bowser@2.11.0: {}
brace-expansion@2.0.2:
@@ -15469,6 +15507,8 @@ snapshots:
detect-node-es@1.1.0: {}
+ detect-node@2.1.0: {}
+
devlop@1.1.0:
dependencies:
dequal: 2.0.3
@@ -15761,6 +15801,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
+ es6-error@4.1.1: {}
+
esbuild-register@3.6.0(esbuild@0.25.9):
dependencies:
debug: 4.4.1(supports-color@8.1.1)
@@ -16522,6 +16564,15 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 2.0.0
+ global-agent@3.0.0:
+ dependencies:
+ boolean: 3.2.0
+ es6-error: 4.1.1
+ matcher: 3.0.0
+ roarr: 2.15.4
+ semver: 7.7.3
+ serialize-error: 7.0.1
+
globals@11.12.0: {}
globals@14.0.0: {}
@@ -17292,7 +17343,7 @@ snapshots:
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
- semver: 7.7.2
+ semver: 7.7.3
jsx-ast-utils@3.3.5:
dependencies:
@@ -17716,6 +17767,10 @@ snapshots:
marked@16.2.0: {}
+ matcher@3.0.0:
+ dependencies:
+ escape-string-regexp: 4.0.0
+
math-intrinsics@1.1.0: {}
mdast-util-definitions@4.0.0:
@@ -18358,7 +18413,7 @@ snapshots:
node-abi@3.75.0:
dependencies:
- semver: 7.7.2
+ semver: 7.7.3
optional: true
node-addon-api@4.3.0:
@@ -19471,6 +19526,15 @@ snapshots:
glob: 11.1.0
package-json-from-dist: 1.0.1
+ roarr@2.15.4:
+ dependencies:
+ boolean: 3.2.0
+ detect-node: 2.1.0
+ globalthis: 1.0.4
+ json-stringify-safe: 5.0.1
+ semver-compare: 1.0.0
+ sprintf-js: 1.1.3
+
robust-predicates@3.0.2: {}
rollup@4.40.2:
@@ -19632,6 +19696,10 @@ snapshots:
dependencies:
type-fest: 4.41.0
+ serialize-error@7.0.1:
+ dependencies:
+ type-fest: 0.13.1
+
serialize-javascript@6.0.2:
dependencies:
randombytes: 2.1.0
@@ -20403,6 +20471,8 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
+ type-fest@0.13.1: {}
+
type-fest@4.41.0: {}
type-is@2.0.1:
diff --git a/src/esbuild.mjs b/src/esbuild.mjs
index 68298eb3de..aabacfcee9 100644
--- a/src/esbuild.mjs
+++ b/src/esbuild.mjs
@@ -100,7 +100,10 @@ async function main() {
plugins,
entryPoints: ["extension.ts"],
outfile: "dist/extension.js",
- external: ["vscode", "esbuild"],
+ // global-agent must be external because it dynamically patches Node.js http/https modules
+ // which breaks when bundled. It needs access to the actual Node.js module instances.
+ // undici must be bundled because our VSIX is packaged with `--no-dependencies`.
+ external: ["vscode", "esbuild", "global-agent"],
}
/**
diff --git a/src/extension.ts b/src/extension.ts
index 59a1797f53..76f02af6de 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -19,6 +19,7 @@ import { customToolRegistry } from "@roo-code/core"
import "./utils/path" // Necessary to have access to String.prototype.toPosix.
import { createOutputChannelLogger, createDualLogger } from "./utils/outputChannelLogger"
+import { initializeNetworkProxy } from "./utils/networkProxy"
import { Package } from "./shared/package"
import { formatLanguage } from "./shared/language"
@@ -68,6 +69,11 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(outputChannel)
outputChannel.appendLine(`${Package.name} extension activated - ${JSON.stringify(Package)}`)
+ // Initialize network proxy configuration early, before any network requests.
+ // When proxyUrl is configured, all HTTP/HTTPS traffic will be routed through it.
+ // Only applied in debug mode (F5).
+ await initializeNetworkProxy(context, outputChannel)
+
// Set extension path for custom tool registry to find bundled esbuild
customToolRegistry.setExtensionPath(context.extensionPath)
diff --git a/src/package.json b/src/package.json
index 4b4f1b811b..8e8ad31c5a 100644
--- a/src/package.json
+++ b/src/package.json
@@ -411,6 +411,21 @@
"type": "boolean",
"default": false,
"description": "%settings.debug.description%"
+ },
+ "roo-cline.debugProxy.enabled": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "%settings.debugProxy.enabled.description%"
+ },
+ "roo-cline.debugProxy.serverUrl": {
+ "type": "string",
+ "default": "http://127.0.0.1:8888",
+ "markdownDescription": "%settings.debugProxy.serverUrl.description%"
+ },
+ "roo-cline.debugProxy.tlsInsecure": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "%settings.debugProxy.tlsInsecure.description%"
}
}
}
@@ -461,6 +476,7 @@
"fastest-levenshtein": "^1.0.16",
"fzf": "^0.5.2",
"get-folder-size": "^5.0.0",
+ "global-agent": "^3.0.0",
"google-auth-library": "^9.15.1",
"gray-matter": "^4.0.3",
"i18next": "^25.0.0",
@@ -503,6 +519,7 @@
"tmp": "^0.2.3",
"tree-sitter-wasms": "^0.1.12",
"turndown": "^7.2.0",
+ "undici": "^6.21.3",
"uuid": "^11.1.0",
"vscode-material-icons": "^0.1.1",
"web-tree-sitter": "^0.25.6",
diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json
index de3bb987cc..2781ed169c 100644
--- a/src/package.nls.ca.json
+++ b/src/package.nls.ca.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Temps màxim en segons per esperar les respostes de l'API (0 = sense temps d'espera, 1-3600s, per defecte: 600s). Es recomanen valors més alts per a proveïdors locals com LM Studio i Ollama que poden necessitar més temps de processament.",
"settings.newTaskRequireTodos.description": "Requerir el paràmetre de tasques pendents quan es creïn noves tasques amb l'eina new_task",
"settings.codeIndex.embeddingBatchSize.description": "La mida del lot per a operacions d'incrustació durant la indexació de codi. Ajusta això segons els límits del teu proveïdor d'API. Per defecte és 60.",
- "settings.debug.description": "Activa el mode de depuració per mostrar botons addicionals per veure l'historial de conversa de l'API i els missatges de la interfície d'usuari com a JSON embellert en fitxers temporals."
+ "settings.debug.description": "Activa el mode de depuració per mostrar botons addicionals per veure l'historial de conversa de l'API i els missatges de la interfície d'usuari com a JSON embellert en fitxers temporals.",
+ "settings.debugProxy.enabled.description": "**Habilita el Debug Proxy** — Redirigeix totes les sol·licituds de xarxa sortints a través d'un proxy per a debugging MITM. Només està actiu quan s'executa en mode debug (F5).",
+ "settings.debugProxy.serverUrl.description": "URL del proxy (p. ex., `http://127.0.0.1:8888`). Només s'utilitza quan el **Debug Proxy** està habilitat.",
+ "settings.debugProxy.tlsInsecure.description": "Accepta certificats auto-signats del proxy. **Requerit per a la inspecció MITM.** ⚠️ Insegur — utilitza-ho només per a debugging local."
}
diff --git a/src/package.nls.de.json b/src/package.nls.de.json
index d0f9184937..a77a253ef0 100644
--- a/src/package.nls.de.json
+++ b/src/package.nls.de.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Maximale Wartezeit in Sekunden auf API-Antworten (0 = kein Timeout, 1-3600s, Standard: 600s). Höhere Werte werden für lokale Anbieter wie LM Studio und Ollama empfohlen, die möglicherweise mehr Verarbeitungszeit benötigen.",
"settings.newTaskRequireTodos.description": "Todos-Parameter beim Erstellen neuer Aufgaben mit dem new_task-Tool erfordern",
"settings.codeIndex.embeddingBatchSize.description": "Die Batch-Größe für Embedding-Operationen während der Code-Indexierung. Passe dies an die Limits deines API-Anbieters an. Standard ist 60.",
- "settings.debug.description": "Aktiviere den Debug-Modus, um zusätzliche Schaltflächen zum Anzeigen des API-Konversationsverlaufs und der UI-Nachrichten als formatiertes JSON in temporären Dateien anzuzeigen."
+ "settings.debug.description": "Aktiviere den Debug-Modus, um zusätzliche Schaltflächen zum Anzeigen des API-Konversationsverlaufs und der UI-Nachrichten als formatiertes JSON in temporären Dateien anzuzeigen.",
+ "settings.debugProxy.enabled.description": "**Debug-Proxy aktivieren** — Leite alle ausgehenden Netzwerkanfragen über einen Proxy für MITM-Debugging. Nur aktiv, wenn du im Debug-Modus (F5) läufst.",
+ "settings.debugProxy.serverUrl.description": "Proxy-URL (z. B. `http://127.0.0.1:8888`). Wird nur verwendet, wenn der **Debug-Proxy** aktiviert ist.",
+ "settings.debugProxy.tlsInsecure.description": "Akzeptiere selbstsignierte Zertifikate vom Proxy. **Erforderlich für MITM-Inspektion.** ⚠️ Unsicher – verwende das nur für lokales Debugging."
}
diff --git a/src/package.nls.es.json b/src/package.nls.es.json
index e2ab5a95c8..a1c729080e 100644
--- a/src/package.nls.es.json
+++ b/src/package.nls.es.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Tiempo máximo en segundos de espera para las respuestas de la API (0 = sin tiempo de espera, 1-3600s, por defecto: 600s). Se recomiendan valores más altos para proveedores locales como LM Studio y Ollama que puedan necesitar más tiempo de procesamiento.",
"settings.newTaskRequireTodos.description": "Requerir el parámetro todos al crear nuevas tareas con la herramienta new_task",
"settings.codeIndex.embeddingBatchSize.description": "El tamaño del lote para operaciones de embedding durante la indexación de código. Ajusta esto según los límites de tu proveedor de API. Por defecto es 60.",
- "settings.debug.description": "Activa el modo de depuración para mostrar botones adicionales para ver el historial de conversación de API y los mensajes de la interfaz de usuario como JSON embellecido en archivos temporales."
+ "settings.debug.description": "Activa el modo de depuración para mostrar botones adicionales para ver el historial de conversación de API y los mensajes de la interfaz de usuario como JSON embellecido en archivos temporales.",
+ "settings.debugProxy.enabled.description": "**Activar Debug Proxy** — Redirige todas las solicitudes de red salientes a través de un proxy para depuración MITM. Solo está activo cuando se ejecuta en modo depuración (F5).",
+ "settings.debugProxy.serverUrl.description": "URL del proxy (p. ej., `http://127.0.0.1:8888`). Solo se usa cuando **Debug Proxy** está activado.",
+ "settings.debugProxy.tlsInsecure.description": "Aceptar certificados autofirmados del proxy. **Necesario para la inspección MITM.** ⚠️ Inseguro: úsalo solo para depuración local."
}
diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json
index c725dbc298..2d009c0038 100644
--- a/src/package.nls.fr.json
+++ b/src/package.nls.fr.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Temps maximum en secondes d'attente pour les réponses de l'API (0 = pas de timeout, 1-3600s, par défaut : 600s). Des valeurs plus élevées sont recommandées pour les fournisseurs locaux comme LM Studio et Ollama qui peuvent nécessiter plus de temps de traitement.",
"settings.newTaskRequireTodos.description": "Exiger le paramètre todos lors de la création de nouvelles tâches avec l'outil new_task",
"settings.codeIndex.embeddingBatchSize.description": "La taille du lot pour les opérations d'embedding lors de l'indexation du code. Ajustez ceci selon les limites de votre fournisseur d'API. Par défaut, c'est 60.",
- "settings.debug.description": "Active le mode debug pour afficher des boutons supplémentaires permettant de visualiser l'historique de conversation de l'API et les messages de l'interface utilisateur sous forme de JSON formaté dans des fichiers temporaires."
+ "settings.debug.description": "Active le mode debug pour afficher des boutons supplémentaires permettant de visualiser l'historique de conversation de l'API et les messages de l'interface utilisateur sous forme de JSON formaté dans des fichiers temporaires.",
+ "settings.debugProxy.enabled.description": "**Activer le Debug Proxy** — Redirige toutes les requêtes réseau sortantes via un proxy pour le debug MITM. Actif uniquement quand tu es en mode debug (F5).",
+ "settings.debugProxy.serverUrl.description": "URL du proxy (par ex. `http://127.0.0.1:8888`). Utilisée uniquement quand le **Debug Proxy** est activé.",
+ "settings.debugProxy.tlsInsecure.description": "Accepter les certificats auto-signés du proxy. **Requis pour l'inspection MITM.** ⚠️ Non sécurisé — à utiliser uniquement pour le debug local."
}
diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json
index 08ae5be0ea..c51f3ee95e 100644
--- a/src/package.nls.hi.json
+++ b/src/package.nls.hi.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "एपीआई प्रतिक्रियाओं की प्रतीक्षा करने के लिए सेकंड में अधिकतम समय (0 = कोई टाइमआउट नहीं, 1-3600s, डिफ़ॉल्ट: 600s)। एलएम स्टूडियो और ओलामा जैसे स्थानीय प्रदाताओं के लिए उच्च मानों की सिफारिश की जाती है जिन्हें अधिक प्रसंस्करण समय की आवश्यकता हो सकती है।",
"settings.newTaskRequireTodos.description": "new_task टूल के साथ नए कार्य बनाते समय टूडू पैरामीटर की आवश्यकता होती है",
"settings.codeIndex.embeddingBatchSize.description": "कोड इंडेक्सिंग के दौरान एम्बेडिंग ऑपरेशन के लिए बैच साइज़। इसे अपने API प्रदाता की सीमाओं के अनुसार समायोजित करें। डिफ़ॉल्ट 60 है।",
- "settings.debug.description": "API conversation history और UI messages को temporary files में prettified JSON के रूप में देखने के लिए अतिरिक्त बटन दिखाने के लिए debug mode सक्षम करें।"
+ "settings.debug.description": "API conversation history और UI messages को temporary files में prettified JSON के रूप में देखने के लिए अतिरिक्त बटन दिखाने के लिए debug mode सक्षम करें।",
+ "settings.debugProxy.enabled.description": "**Debug Proxy सक्षम करो** — सभी आउटबाउंड network requests को MITM debugging के लिए proxy के ज़रिए route करो। सिर्फ तब active रहेगा जब तुम debug mode (F5) में चला रहे हो।",
+ "settings.debugProxy.serverUrl.description": "Proxy URL (जैसे `http://127.0.0.1:8888`)। सिर्फ तब इस्तेमाल होती है जब **Debug Proxy** enabled हो।",
+ "settings.debugProxy.tlsInsecure.description": "Proxy से आने वाले self-signed certificates accept करो। **MITM inspection के लिए ज़रूरी।** ⚠️ Insecure — सिर्फ local debugging के लिए इस्तेमाल करो।"
}
diff --git a/src/package.nls.id.json b/src/package.nls.id.json
index 83410391cf..2a7607f3e7 100644
--- a/src/package.nls.id.json
+++ b/src/package.nls.id.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Waktu maksimum dalam detik untuk menunggu respons API (0 = tidak ada batas waktu, 1-3600s, default: 600s). Nilai yang lebih tinggi disarankan untuk penyedia lokal seperti LM Studio dan Ollama yang mungkin memerlukan lebih banyak waktu pemrosesan.",
"settings.newTaskRequireTodos.description": "Memerlukan parameter todos saat membuat tugas baru dengan alat new_task",
"settings.codeIndex.embeddingBatchSize.description": "Ukuran batch untuk operasi embedding selama pengindeksan kode. Sesuaikan ini berdasarkan batas penyedia API kamu. Default adalah 60.",
- "settings.debug.description": "Aktifkan mode debug untuk menampilkan tombol tambahan untuk melihat riwayat percakapan API dan pesan UI sebagai JSON yang diformat dalam file sementara."
+ "settings.debug.description": "Aktifkan mode debug untuk menampilkan tombol tambahan untuk melihat riwayat percakapan API dan pesan UI sebagai JSON yang diformat dalam file sementara.",
+ "settings.debugProxy.enabled.description": "**Aktifkan Debug Proxy** — Arahkan semua permintaan jaringan keluar lewat proxy untuk debugging MITM. Hanya aktif saat kamu berjalan dalam mode debug (F5).",
+ "settings.debugProxy.serverUrl.description": "URL proxy (mis. `http://127.0.0.1:8888`). Hanya digunakan ketika **Debug Proxy** diaktifkan.",
+ "settings.debugProxy.tlsInsecure.description": "Terima sertifikat self-signed dari proxy. **Diperlukan untuk inspeksi MITM.** ⚠️ Tidak aman — gunakan hanya untuk debugging lokal."
}
diff --git a/src/package.nls.it.json b/src/package.nls.it.json
index 1ddeb596cf..c94471355d 100644
--- a/src/package.nls.it.json
+++ b/src/package.nls.it.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Tempo massimo in secondi di attesa per le risposte API (0 = nessun timeout, 1-3600s, predefinito: 600s). Valori più alti sono consigliati per provider locali come LM Studio e Ollama che potrebbero richiedere più tempo di elaborazione.",
"settings.newTaskRequireTodos.description": "Richiedere il parametro todos quando si creano nuove attività con lo strumento new_task",
"settings.codeIndex.embeddingBatchSize.description": "La dimensione del batch per le operazioni di embedding durante l'indicizzazione del codice. Regola questo in base ai limiti del tuo provider API. Il valore predefinito è 60.",
- "settings.debug.description": "Abilita la modalità debug per mostrare pulsanti aggiuntivi per visualizzare la cronologia delle conversazioni API e i messaggi dell'interfaccia utente come JSON formattato in file temporanei."
+ "settings.debug.description": "Abilita la modalità debug per mostrare pulsanti aggiuntivi per visualizzare la cronologia delle conversazioni API e i messaggi dell'interfaccia utente come JSON formattato in file temporanei.",
+ "settings.debugProxy.enabled.description": "**Abilita Debug Proxy** — Instrada tutte le richieste di rete in uscita tramite un proxy per il debugging MITM. Attivo solo quando esegui in modalità debug (F5).",
+ "settings.debugProxy.serverUrl.description": "URL del proxy (ad es. `http://127.0.0.1:8888`). Usato solo quando **Debug Proxy** è abilitato.",
+ "settings.debugProxy.tlsInsecure.description": "Accetta certificati autofirmati dal proxy. **Necessario per l'ispezione MITM.** ⚠️ Non sicuro — usalo solo per il debugging locale."
}
diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json
index 7fe1adac66..ff6040d773 100644
--- a/src/package.nls.ja.json
+++ b/src/package.nls.ja.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "API応答を待機する最大時間(秒)(0 = タイムアウトなし、1-3600秒、デフォルト: 600秒)。LM StudioやOllamaのような、より多くの処理時間を必要とする可能性のあるローカルプロバイダーには、より高い値が推奨されます。",
"settings.newTaskRequireTodos.description": "new_taskツールで新しいタスクを作成する際にtodosパラメータを必須にする",
"settings.codeIndex.embeddingBatchSize.description": "コードインデックス作成中のエンベディング操作のバッチサイズ。APIプロバイダーの制限に基づいてこれを調整してください。デフォルトは60です。",
- "settings.debug.description": "デバッグモードを有効にして、API会話履歴とUIメッセージをフォーマットされたJSONとして一時ファイルで表示するための追加ボタンを表示します。"
+ "settings.debug.description": "デバッグモードを有効にして、API会話履歴とUIメッセージをフォーマットされたJSONとして一時ファイルで表示するための追加ボタンを表示します。",
+ "settings.debugProxy.enabled.description": "**Debug Proxy を有効化** — すべての送信ネットワーク要求を MITM デバッグのためにプロキシ経由でルーティングします。デバッグモード (F5) で実行しているときだけ有効です。",
+ "settings.debugProxy.serverUrl.description": "プロキシ URL(例: `http://127.0.0.1:8888`)。**Debug Proxy** が有効なときにだけ使用されます。",
+ "settings.debugProxy.tlsInsecure.description": "プロキシからの自己署名証明書を許可します。**MITM インスペクションに必須です。** ⚠️ 危険な設定なので、ローカルでのデバッグにだけ使用してください。"
}
diff --git a/src/package.nls.json b/src/package.nls.json
index 0030d1ce7b..177b392f77 100644
--- a/src/package.nls.json
+++ b/src/package.nls.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Maximum time in seconds to wait for API responses (0 = no timeout, 1-3600s, default: 600s). Higher values are recommended for local providers like LM Studio and Ollama that may need more processing time.",
"settings.newTaskRequireTodos.description": "Require todos parameter when creating new tasks with the new_task tool",
"settings.codeIndex.embeddingBatchSize.description": "The batch size for embedding operations during code indexing. Adjust this based on your API provider's limits. Default is 60.",
- "settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files."
+ "settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files.",
+ "settings.debugProxy.enabled.description": "**Enable Debug Proxy** — Route all outbound network requests through a proxy for MITM debugging. Only active when running in debug mode (F5).",
+ "settings.debugProxy.serverUrl.description": "Proxy URL (e.g., `http://127.0.0.1:8888`). Only used when **Debug Proxy** is enabled.",
+ "settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging."
}
diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json
index ce471aa937..f0912835b8 100644
--- a/src/package.nls.ko.json
+++ b/src/package.nls.ko.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "API 응답을 기다리는 최대 시간(초) (0 = 시간 초과 없음, 1-3600초, 기본값: 600초). 더 많은 처리 시간이 필요할 수 있는 LM Studio 및 Ollama와 같은 로컬 공급자에게는 더 높은 값을 사용하는 것이 좋습니다.",
"settings.newTaskRequireTodos.description": "new_task 도구로 새 작업을 생성할 때 todos 매개변수 필요",
"settings.codeIndex.embeddingBatchSize.description": "코드 인덱싱 중 임베딩 작업의 배치 크기입니다. API 공급자의 제한에 따라 이를 조정하세요. 기본값은 60입니다.",
- "settings.debug.description": "디버그 모드를 활성화하여 API 대화 기록과 UI 메시지를 임시 파일에 포맷된 JSON으로 보기 위한 추가 버튼을 표시합니다."
+ "settings.debug.description": "디버그 모드를 활성화하여 API 대화 기록과 UI 메시지를 임시 파일에 포맷된 JSON으로 보기 위한 추가 버튼을 표시합니다.",
+ "settings.debugProxy.enabled.description": "**Debug Proxy 활성화** — 모든 아웃바운드 네트워크 요청을 MITM 디버깅을 위해 프록시를 통해 라우팅합니다. 디버그 모드(F5)로 실행 중일 때만 활성화됩니다.",
+ "settings.debugProxy.serverUrl.description": "프록시 URL(예: `http://127.0.0.1:8888`). **Debug Proxy** 가 활성화된 경우에만 사용됩니다.",
+ "settings.debugProxy.tlsInsecure.description": "프록시의 self-signed 인증서를 허용합니다. **MITM 검사에 필요합니다.** ⚠️ 안전하지 않으므로 로컬 디버깅에만 사용하세요."
}
diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json
index 0d2d58b62a..fef3ca7219 100644
--- a/src/package.nls.nl.json
+++ b/src/package.nls.nl.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Maximale tijd in seconden om te wachten op API-reacties (0 = geen time-out, 1-3600s, standaard: 600s). Hogere waarden worden aanbevolen voor lokale providers zoals LM Studio en Ollama die mogelijk meer verwerkingstijd nodig hebben.",
"settings.newTaskRequireTodos.description": "Todos-parameter vereisen bij het maken van nieuwe taken met de new_task tool",
"settings.codeIndex.embeddingBatchSize.description": "De batchgrootte voor embedding-operaties tijdens code-indexering. Pas dit aan op basis van de limieten van je API-provider. Standaard is 60.",
- "settings.debug.description": "Schakel debug-modus in om extra knoppen te tonen voor het bekijken van API-conversatiegeschiedenis en UI-berichten als opgemaakte JSON in tijdelijke bestanden."
+ "settings.debug.description": "Schakel debug-modus in om extra knoppen te tonen voor het bekijken van API-conversatiegeschiedenis en UI-berichten als opgemaakte JSON in tijdelijke bestanden.",
+ "settings.debugProxy.enabled.description": "**Debug Proxy inschakelen** — Leid alle uitgaande netwerkverzoeken via een proxy voor MITM-debugging. Alleen actief wanneer je in debugmodus (F5) draait.",
+ "settings.debugProxy.serverUrl.description": "Proxy-URL (bijv. `http://127.0.0.1:8888`). Wordt alleen gebruikt wanneer **Debug Proxy** is ingeschakeld.",
+ "settings.debugProxy.tlsInsecure.description": "Accepteer zelfondertekende certificaten van de proxy. **Vereist voor MITM-inspectie.** ⚠️ Onveilig — gebruik dit alleen voor lokale debugging."
}
diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json
index a3314e3886..8c1f66450d 100644
--- a/src/package.nls.pl.json
+++ b/src/package.nls.pl.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Maksymalny czas w sekundach oczekiwania na odpowiedzi API (0 = brak limitu czasu, 1-3600s, domyślnie: 600s). Wyższe wartości są zalecane dla lokalnych dostawców, takich jak LM Studio i Ollama, którzy mogą potrzebować więcej czasu na przetwarzanie.",
"settings.newTaskRequireTodos.description": "Wymagaj parametru todos podczas tworzenia nowych zadań za pomocą narzędzia new_task",
"settings.codeIndex.embeddingBatchSize.description": "Rozmiar partii dla operacji osadzania podczas indeksowania kodu. Dostosuj to w oparciu o limity twojego dostawcy API. Domyślnie to 60.",
- "settings.debug.description": "Włącz tryb debugowania, aby wyświetlić dodatkowe przyciski do przeglądania historii rozmów API i komunikatów interfejsu użytkownika jako sformatowany JSON w plikach tymczasowych."
+ "settings.debug.description": "Włącz tryb debugowania, aby wyświetlić dodatkowe przyciski do przeglądania historii rozmów API i komunikatów interfejsu użytkownika jako sformatowany JSON w plikach tymczasowych.",
+ "settings.debugProxy.enabled.description": "**Włącz Debug Proxy** — Kieruj wszystkie wychodzące żądania sieciowe przez proxy na potrzeby debugowania MITM. Aktywne tylko wtedy, gdy uruchamiasz w trybie debugowania (F5).",
+ "settings.debugProxy.serverUrl.description": "URL proxy (np. `http://127.0.0.1:8888`). Używany tylko wtedy, gdy **Debug Proxy** jest włączony.",
+ "settings.debugProxy.tlsInsecure.description": "Akceptuj certyfikaty self-signed z proxy. **Wymagane do inspekcji MITM.** ⚠️ Niezabezpieczone — używaj tylko do lokalnego debugowania."
}
diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json
index 648fc0fa47..84cbf42c09 100644
--- a/src/package.nls.pt-BR.json
+++ b/src/package.nls.pt-BR.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Tempo máximo em segundos de espera pelas respostas da API (0 = sem tempo limite, 1-3600s, padrão: 600s). Valores mais altos são recomendados para provedores locais como LM Studio e Ollama que podem precisar de mais tempo de processamento.",
"settings.newTaskRequireTodos.description": "Exigir parâmetro todos ao criar novas tarefas com a ferramenta new_task",
"settings.codeIndex.embeddingBatchSize.description": "O tamanho do lote para operações de embedding durante a indexação de código. Ajuste isso com base nos limites do seu provedor de API. O padrão é 60.",
- "settings.debug.description": "Ativa o modo de depuração para mostrar botões adicionais para visualizar o histórico de conversas da API e mensagens da interface como JSON formatado em arquivos temporários."
+ "settings.debug.description": "Ativa o modo de depuração para mostrar botões adicionais para visualizar o histórico de conversas da API e mensagens da interface como JSON formatado em arquivos temporários.",
+ "settings.debugProxy.enabled.description": "**Ativar Debug Proxy** — Redireciona todas as solicitações de rede de saída por meio de um proxy para depuração MITM. Só fica ativo quando você está executando em modo de depuração (F5).",
+ "settings.debugProxy.serverUrl.description": "URL do proxy (por exemplo, `http://127.0.0.1:8888`). Só é usada quando o **Debug Proxy** está ativado.",
+ "settings.debugProxy.tlsInsecure.description": "Aceitar certificados self-signed do proxy. **Necessário para inspeção MITM.** ⚠️ Inseguro — use apenas para depuração local."
}
diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json
index 00d39e1cf3..be8df04032 100644
--- a/src/package.nls.ru.json
+++ b/src/package.nls.ru.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Максимальное время в секундах для ожидания ответов API (0 = нет тайм-аута, 1-3600 с, по умолчанию: 600 с). Рекомендуются более высокие значения для локальных провайдеров, таких как LM Studio и Ollama, которым может потребоваться больше времени на обработку.",
"settings.newTaskRequireTodos.description": "Требовать параметр todos при создании новых задач с помощью инструмента new_task",
"settings.codeIndex.embeddingBatchSize.description": "Размер пакета для операций встраивания во время индексации кода. Настройте это в соответствии с ограничениями вашего API-провайдера. По умолчанию 60.",
- "settings.debug.description": "Включить режим отладки, чтобы отображать дополнительные кнопки для просмотра истории разговоров API и сообщений интерфейса в виде форматированного JSON во временных файлах."
+ "settings.debug.description": "Включить режим отладки, чтобы отображать дополнительные кнопки для просмотра истории разговоров API и сообщений интерфейса в виде форматированного JSON во временных файлах.",
+ "settings.debugProxy.enabled.description": "**Включить Debug Proxy** — направлять все исходящие сетевые запросы через прокси для MITM-отладки. Активен только когда ты запускаешь расширение в режиме отладки (F5).",
+ "settings.debugProxy.serverUrl.description": "URL прокси (например, `http://127.0.0.1:8888`). Используется только если **Debug Proxy** включён.",
+ "settings.debugProxy.tlsInsecure.description": "Принимать self-signed сертификаты от прокси. **Требуется для MITM-инспекции.** ⚠️ Небезопасно — используй только для локальной отладки."
}
diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json
index da05051f6d..a815188e8a 100644
--- a/src/package.nls.tr.json
+++ b/src/package.nls.tr.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "API yanıtları için beklenecek maksimum süre (saniye cinsinden) (0 = zaman aşımı yok, 1-3600s, varsayılan: 600s). LM Studio ve Ollama gibi daha fazla işlem süresi gerektirebilecek yerel sağlayıcılar için daha yüksek değerler önerilir.",
"settings.newTaskRequireTodos.description": "new_task aracıyla yeni görevler oluştururken todos parametresini gerekli kıl",
"settings.codeIndex.embeddingBatchSize.description": "Kod indeksleme sırasında gömme işlemleri için toplu iş boyutu. Bunu API sağlayıcınızın sınırlarına göre ayarlayın. Varsayılan 60'tır.",
- "settings.debug.description": "API konuşma geçmişini ve kullanıcı arayüzü mesajlarını geçici dosyalarda biçimlendirilmiş JSON olarak görüntülemek için ek düğmeler göstermek üzere hata ayıklama modunu etkinleştir."
+ "settings.debug.description": "API konuşma geçmişini ve kullanıcı arayüzü mesajlarını geçici dosyalarda biçimlendirilmiş JSON olarak görüntülemek için ek düğmeler göstermek üzere hata ayıklama modunu etkinleştir.",
+ "settings.debugProxy.enabled.description": "**Debug Proxy'yi etkinleştir** — Tüm giden ağ isteklerini MITM hata ayıklaması için bir proxy üzerinden yönlendir. Yalnızca debug modunda (F5) çalıştırırken aktiftir.",
+ "settings.debugProxy.serverUrl.description": "Proxy URL'si (ör. `http://127.0.0.1:8888`). Yalnızca **Debug Proxy** etkin olduğunda kullanılır.",
+ "settings.debugProxy.tlsInsecure.description": "Proxy'den gelen self-signed sertifikaları kabul et. **MITM incelemesi için gerekli.** ⚠️ Güvensiz — yalnızca lokal debugging için kullan."
}
diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json
index 984b009e98..6052080dfa 100644
--- a/src/package.nls.vi.json
+++ b/src/package.nls.vi.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "Thời gian tối đa tính bằng giây để đợi phản hồi API (0 = không có thời gian chờ, 1-3600 giây, mặc định: 600 giây). Nên sử dụng các giá trị cao hơn cho các nhà cung cấp cục bộ như LM Studio và Ollama có thể cần thêm thời gian xử lý.",
"settings.newTaskRequireTodos.description": "Yêu cầu tham số todos khi tạo nhiệm vụ mới với công cụ new_task",
"settings.codeIndex.embeddingBatchSize.description": "Kích thước lô cho các hoạt động nhúng trong quá trình lập chỉ mục mã. Điều chỉnh điều này dựa trên giới hạn của nhà cung cấp API của bạn. Mặc định là 60.",
- "settings.debug.description": "Bật chế độ gỡ lỗi để hiển thị các nút bổ sung để xem lịch sử hội thoại API và thông điệp giao diện người dùng dưới dạng JSON được định dạng trong các tệp tạm thời."
+ "settings.debug.description": "Bật chế độ gỡ lỗi để hiển thị các nút bổ sung để xem lịch sử hội thoại API và thông điệp giao diện người dùng dưới dạng JSON được định dạng trong các tệp tạm thời.",
+ "settings.debugProxy.enabled.description": "**Bật Debug Proxy** — Chuyển hướng tất cả yêu cầu mạng đi ra qua một proxy để debug MITM. Chỉ hoạt động khi bạn chạy ở chế độ gỡ lỗi (F5).",
+ "settings.debugProxy.serverUrl.description": "Proxy URL (vd: `http://127.0.0.1:8888`). Chỉ được dùng khi **Debug Proxy** được bật.",
+ "settings.debugProxy.tlsInsecure.description": "Chấp nhận chứng chỉ self-signed từ proxy. **Bắt buộc cho việc kiểm tra MITM.** ⚠️ Không an toàn — chỉ dùng cho debug cục bộ."
}
diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json
index e6619a9491..9254d494d9 100644
--- a/src/package.nls.zh-CN.json
+++ b/src/package.nls.zh-CN.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "等待 API 响应的最长时间(秒)(0 = 无超时,1-3600秒,默认值:600秒)。对于像 LM Studio 和 Ollama 这样可能需要更多处理时间的本地提供商,建议使用更高的值。",
"settings.newTaskRequireTodos.description": "使用 new_task 工具创建新任务时需要 todos 参数",
"settings.codeIndex.embeddingBatchSize.description": "代码索引期间嵌入操作的批处理大小。根据 API 提供商的限制调整此设置。默认值为 60。",
- "settings.debug.description": "启用调试模式以显示额外按钮,用于在临时文件中以格式化 JSON 查看 API 对话历史和 UI 消息。"
+ "settings.debug.description": "启用调试模式以显示额外按钮,用于在临时文件中以格式化 JSON 查看 API 对话历史和 UI 消息。",
+ "settings.debugProxy.enabled.description": "**启用 Debug Proxy** — 通过代理转发所有出站网络请求,用于 MITM 调试。只在调试模式 (F5) 运行时生效。",
+ "settings.debugProxy.serverUrl.description": "代理 URL(例如 `http://127.0.0.1:8888`)。仅在启用 **Debug Proxy** 时使用。",
+ "settings.debugProxy.tlsInsecure.description": "接受来自代理的 self-signed 证书。**MITM 检查所必需。** ⚠️ 不安全——只在本地调试时使用。"
}
diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json
index 38a9807126..a8030d6914 100644
--- a/src/package.nls.zh-TW.json
+++ b/src/package.nls.zh-TW.json
@@ -42,5 +42,8 @@
"settings.apiRequestTimeout.description": "等待 API 回應的最長時間(秒)(0 = 無超時,1-3600秒,預設值:600秒)。對於像 LM Studio 和 Ollama 這樣可能需要更多處理時間的本地提供商,建議使用更高的值。",
"settings.newTaskRequireTodos.description": "使用 new_task 工具建立新工作時需要 todos 參數",
"settings.codeIndex.embeddingBatchSize.description": "程式碼索引期間嵌入操作的批次大小。根據 API 提供商的限制調整此設定。預設值為 60。",
- "settings.debug.description": "啟用偵錯模式以顯示額外按鈕,用於在暫存檔案中以格式化 JSON 檢視 API 對話歷史紀錄和使用者介面訊息。"
+ "settings.debug.description": "啟用偵錯模式以顯示額外按鈕,用於在暫存檔案中以格式化 JSON 檢視 API 對話歷史紀錄和使用者介面訊息。",
+ "settings.debugProxy.enabled.description": "**啟用 Debug Proxy** — 將所有出站網路要求透過代理進行路由,以進行 MITM 偵錯。只有在除錯模式 (F5) 執行時才會啟用。",
+ "settings.debugProxy.serverUrl.description": "代理 URL(例如 `http://127.0.0.1:8888`)。只有在啟用 **Debug Proxy** 時才會使用。",
+ "settings.debugProxy.tlsInsecure.description": "接受來自代理的 self-signed 憑證。**MITM 檢查所必需。** ⚠️ 不安全——只在本機偵錯時使用。"
}
diff --git a/src/types/global-agent.d.ts b/src/types/global-agent.d.ts
new file mode 100644
index 0000000000..1dba1e38e1
--- /dev/null
+++ b/src/types/global-agent.d.ts
@@ -0,0 +1,47 @@
+/**
+ * Type declarations for global-agent package.
+ *
+ * global-agent is a library that creates a global HTTP/HTTPS agent
+ * that routes all traffic through a specified proxy.
+ *
+ * @see https://github.com/gajus/global-agent
+ */
+
+declare module "global-agent" {
+ /**
+ * Bootstrap global-agent to intercept all HTTP/HTTPS requests.
+ *
+ * After calling this function, all outgoing HTTP/HTTPS requests
+ * from the Node.js process will be routed through the proxy
+ * specified by the GLOBAL_AGENT_HTTP_PROXY and GLOBAL_AGENT_HTTPS_PROXY
+ * environment variables.
+ *
+ * @returns void
+ */
+ export function bootstrap(): void
+
+ /**
+ * Create a global agent with custom configuration.
+ *
+ * @param options Configuration options for the global agent
+ * @returns void
+ */
+ export function createGlobalProxyAgent(options?: {
+ /**
+ * Environment variable namespace prefix.
+ * Default: "GLOBAL_AGENT_"
+ */
+ environmentVariableNamespace?: string
+
+ /**
+ * Force global agent to be used for all HTTP/HTTPS requests.
+ * Default: true
+ */
+ forceGlobalAgent?: boolean
+
+ /**
+ * Socket connection timeout in milliseconds.
+ */
+ socketConnectionTimeout?: number
+ }): void
+}
diff --git a/src/utils/__tests__/networkProxy.spec.ts b/src/utils/__tests__/networkProxy.spec.ts
new file mode 100644
index 0000000000..97c046d1b0
--- /dev/null
+++ b/src/utils/__tests__/networkProxy.spec.ts
@@ -0,0 +1,308 @@
+import * as vscode from "vscode"
+import { initializeNetworkProxy, getProxyConfig, isProxyEnabled, isDebugMode } from "../networkProxy"
+
+// Mock global-agent
+vi.mock("global-agent", () => ({
+ bootstrap: vi.fn(),
+}))
+
+// Mock vscode
+vi.mock("vscode", () => ({
+ workspace: {
+ getConfiguration: vi.fn(),
+ onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })),
+ },
+ ExtensionMode: {
+ Development: 2,
+ Production: 1,
+ Test: 3,
+ },
+}))
+
+describe("networkProxy", () => {
+ let mockOutputChannel: vscode.OutputChannel
+ let mockConfig: { get: ReturnType }
+
+ // Helper to create mock context with configurable extensionMode
+ function createMockContext(mode: vscode.ExtensionMode = vscode.ExtensionMode.Production): vscode.ExtensionContext {
+ return {
+ extensionMode: mode,
+ subscriptions: [],
+ extensionPath: "/test/path",
+ globalState: {
+ get: vi.fn(),
+ update: vi.fn(),
+ keys: vi.fn().mockReturnValue([]),
+ setKeysForSync: vi.fn(),
+ },
+ workspaceState: {
+ get: vi.fn(),
+ update: vi.fn(),
+ keys: vi.fn().mockReturnValue([]),
+ },
+ secrets: {
+ get: vi.fn(),
+ store: vi.fn(),
+ delete: vi.fn(),
+ onDidChange: vi.fn(),
+ },
+ extensionUri: { fsPath: "/test/path" } as vscode.Uri,
+ globalStorageUri: { fsPath: "/test/global" } as vscode.Uri,
+ logUri: { fsPath: "/test/logs" } as vscode.Uri,
+ storageUri: { fsPath: "/test/storage" } as vscode.Uri,
+ storagePath: "/test/storage",
+ globalStoragePath: "/test/global",
+ logPath: "/test/logs",
+ asAbsolutePath: vi.fn((p) => `/test/path/${p}`),
+ environmentVariableCollection: {} as vscode.GlobalEnvironmentVariableCollection,
+ extension: {} as vscode.Extension,
+ languageModelAccessInformation: {} as vscode.LanguageModelAccessInformation,
+ } as unknown as vscode.ExtensionContext
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Reset environment variables
+ delete process.env.GLOBAL_AGENT_HTTP_PROXY
+ delete process.env.GLOBAL_AGENT_HTTPS_PROXY
+ delete process.env.GLOBAL_AGENT_NO_PROXY
+ delete process.env.NODE_TLS_REJECT_UNAUTHORIZED
+
+ mockConfig = {
+ get: vi.fn().mockReturnValue(""),
+ }
+
+ vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(
+ mockConfig as unknown as vscode.WorkspaceConfiguration,
+ )
+
+ mockOutputChannel = {
+ appendLine: vi.fn(),
+ append: vi.fn(),
+ clear: vi.fn(),
+ show: vi.fn(),
+ hide: vi.fn(),
+ dispose: vi.fn(),
+ name: "Test",
+ replace: vi.fn(),
+ } as unknown as vscode.OutputChannel
+ })
+
+ describe("initializeNetworkProxy", () => {
+ it("should initialize without proxy when debugProxy.enabled is false", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.enabled") return false
+ if (key === "debugProxy.serverUrl") return "http://127.0.0.1:8888"
+ return ""
+ })
+ const context = createMockContext()
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(process.env.GLOBAL_AGENT_HTTP_PROXY).toBeUndefined()
+ expect(process.env.GLOBAL_AGENT_HTTPS_PROXY).toBeUndefined()
+ })
+
+ it("should configure proxy environment variables when debugProxy.enabled is true", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.enabled") return true
+ if (key === "debugProxy.serverUrl") return "http://localhost:8080"
+ return ""
+ })
+ // Proxy is only applied in debug mode.
+ const context = createMockContext(vscode.ExtensionMode.Development)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(process.env.GLOBAL_AGENT_HTTP_PROXY).toBe("http://localhost:8080")
+ expect(process.env.GLOBAL_AGENT_HTTPS_PROXY).toBe("http://localhost:8080")
+ })
+
+ it("should not modify TLS settings in debug mode by default", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.enabled") return true
+ if (key === "debugProxy.serverUrl") return "http://localhost:8080"
+ if (key === "debugProxy.tlsInsecure") return false
+ return ""
+ })
+ const context = createMockContext(vscode.ExtensionMode.Development)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined()
+ })
+
+ it("should disable TLS verification when tlsInsecure is enabled (debug mode only)", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.enabled") return true
+ if (key === "debugProxy.serverUrl") return "http://localhost:8080"
+ if (key === "debugProxy.tlsInsecure") return true
+ return ""
+ })
+ const context = createMockContext(vscode.ExtensionMode.Development)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBe("0")
+ })
+
+ it("should register configuration change listener in debug mode", () => {
+ const context = createMockContext(vscode.ExtensionMode.Development)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(vscode.workspace.onDidChangeConfiguration).toHaveBeenCalled()
+ expect(context.subscriptions.length).toBeGreaterThan(0)
+ })
+
+ it("should not register listeners in production mode (early exit)", () => {
+ const context = createMockContext(vscode.ExtensionMode.Production)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(vscode.workspace.onDidChangeConfiguration).not.toHaveBeenCalled()
+ expect(context.subscriptions.length).toBe(0)
+ })
+
+ it("should not throw in non-debug mode if proxy deps are not installed", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.enabled") return true
+ if (key === "debugProxy.serverUrl") return "http://localhost:8080"
+ return ""
+ })
+ const context = createMockContext(vscode.ExtensionMode.Production)
+
+ expect(() => {
+ void initializeNetworkProxy(context, mockOutputChannel)
+ }).not.toThrow()
+ })
+ })
+
+ describe("getProxyConfig", () => {
+ it("should return default config before initialization", () => {
+ // Reset the module to clear internal state
+ vi.resetModules()
+
+ const config = getProxyConfig()
+
+ expect(config.enabled).toBe(false)
+ expect(config.serverUrl).toBe("http://127.0.0.1:8888") // default value
+ expect(config.isDebugMode).toBe(false)
+ })
+
+ it("should return correct config after initialization", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.enabled") return true
+ if (key === "debugProxy.serverUrl") return "http://proxy.example.com:3128"
+ if (key === "debugProxy.tlsInsecure") return true
+ return ""
+ })
+ const context = createMockContext(vscode.ExtensionMode.Production)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+ const config = getProxyConfig()
+
+ expect(config.enabled).toBe(true)
+ expect(config.serverUrl).toBe("http://proxy.example.com:3128")
+ expect(config.tlsInsecure).toBe(true)
+ expect(config.isDebugMode).toBe(false)
+ })
+
+ it("should trim whitespace from server URL", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.serverUrl") return " http://proxy.example.com:3128 "
+ return ""
+ })
+ const context = createMockContext()
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+ const config = getProxyConfig()
+
+ expect(config.serverUrl).toBe("http://proxy.example.com:3128")
+ })
+
+ it("should return default URL for empty server URL", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.serverUrl") return " "
+ return ""
+ })
+ const context = createMockContext()
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+ const config = getProxyConfig()
+
+ expect(config.serverUrl).toBe("http://127.0.0.1:8888") // falls back to default
+ })
+ })
+
+ describe("isProxyEnabled", () => {
+ it("should return false when proxy is not enabled", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.enabled") return false
+ return ""
+ })
+ const context = createMockContext()
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(isProxyEnabled()).toBe(false)
+ })
+
+ it("should return true when proxy is enabled in debug mode", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.enabled") return true
+ if (key === "debugProxy.serverUrl") return "http://localhost:8080"
+ return ""
+ })
+ // Proxy is only applied in debug mode.
+ const context = createMockContext(vscode.ExtensionMode.Development)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(isProxyEnabled()).toBe(true)
+ })
+ })
+
+ describe("isDebugMode", () => {
+ it("should return false in production mode", () => {
+ const context = createMockContext(vscode.ExtensionMode.Production)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(isDebugMode()).toBe(false)
+ })
+
+ it("should return true in development mode", () => {
+ const context = createMockContext(vscode.ExtensionMode.Development)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(isDebugMode()).toBe(true)
+ })
+
+ // Note: This test is skipped because module state persists across tests.
+ // In a real scenario, isDebugMode() returns false before any initialization.
+ // The actual behavior is verified in integration testing.
+ it.skip("should return false before initialization", () => {
+ // This would require full module isolation which isn't practical here
+ expect(isDebugMode()).toBe(false)
+ })
+ })
+
+ describe("security", () => {
+ it("should not disable TLS verification unless tlsInsecure is enabled", () => {
+ mockConfig.get.mockImplementation((key: string) => {
+ if (key === "debugProxy.enabled") return true
+ if (key === "debugProxy.serverUrl") return "http://localhost:8080"
+ if (key === "debugProxy.tlsInsecure") return false
+ return ""
+ })
+ const context = createMockContext(vscode.ExtensionMode.Development)
+
+ void initializeNetworkProxy(context, mockOutputChannel)
+
+ expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined()
+ })
+ })
+})
diff --git a/src/utils/networkProxy.ts b/src/utils/networkProxy.ts
new file mode 100644
index 0000000000..448bc1b576
--- /dev/null
+++ b/src/utils/networkProxy.ts
@@ -0,0 +1,364 @@
+/**
+ * Network Proxy Configuration Module
+ *
+ * Provides proxy configuration for all outbound HTTP/HTTPS requests from the Roo Code extension.
+ * When running in debug mode (F5), a proxy can be enabled for outbound traffic.
+ * Optionally, TLS certificate verification can be disabled (debug only) to allow
+ * MITM proxy inspection.
+ *
+ * Uses global-agent to globally route all HTTP/HTTPS traffic through the proxy,
+ * which works with axios, fetch, and most SDKs that use native Node.js http/https.
+ */
+
+import * as vscode from "vscode"
+import { Package } from "../shared/package"
+
+/**
+ * Proxy configuration state
+ */
+export interface ProxyConfig {
+ /** Whether the debug proxy is enabled */
+ enabled: boolean
+ /** The proxy server URL (e.g., http://127.0.0.1:8888) */
+ serverUrl: string
+ /** Accept self-signed/insecure TLS certificates from the proxy (required for MITM) */
+ tlsInsecure: boolean
+ /** Whether running in debug/development mode */
+ isDebugMode: boolean
+}
+
+let extensionContext: vscode.ExtensionContext | null = null
+let proxyInitialized = false
+let undiciProxyInitialized = false
+let fetchPatched = false
+let originalFetch: typeof fetch | undefined
+let outputChannel: vscode.OutputChannel | null = null
+
+let loggingEnabled = false
+let consoleLoggingEnabled = false
+
+let tlsVerificationOverridden = false
+let originalNodeTlsRejectUnauthorized: string | undefined
+
+function redactProxyUrl(proxyUrl: string | undefined): string {
+ if (!proxyUrl) {
+ return "(not set)"
+ }
+
+ try {
+ const url = new URL(proxyUrl)
+ url.username = ""
+ url.password = ""
+ return url.toString()
+ } catch {
+ // Fallback for invalid URLs: redact basic auth if present.
+ return proxyUrl.replace(/\/\/[^@/]+@/g, "//REDACTED@")
+ }
+}
+
+function restoreGlobalFetchPatch(): void {
+ if (!fetchPatched) {
+ return
+ }
+
+ if (originalFetch) {
+ globalThis.fetch = originalFetch
+ }
+
+ fetchPatched = false
+ originalFetch = undefined
+}
+
+function restoreTlsVerificationOverride(): void {
+ if (!tlsVerificationOverridden) {
+ return
+ }
+
+ if (typeof originalNodeTlsRejectUnauthorized === "string") {
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalNodeTlsRejectUnauthorized
+ } else {
+ delete process.env.NODE_TLS_REJECT_UNAUTHORIZED
+ }
+
+ tlsVerificationOverridden = false
+ originalNodeTlsRejectUnauthorized = undefined
+}
+
+function applyTlsVerificationOverride(config: ProxyConfig): void {
+ // Only relevant in debug mode with an active proxy.
+ if (!config.isDebugMode || !config.enabled) {
+ restoreTlsVerificationOverride()
+ return
+ }
+
+ if (!config.tlsInsecure) {
+ restoreTlsVerificationOverride()
+ return
+ }
+
+ if (!tlsVerificationOverridden) {
+ originalNodeTlsRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED
+ }
+
+ // CodeQL: debug-only opt-in for MITM debugging.
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" // lgtm[js/disabling-certificate-validation]
+ tlsVerificationOverridden = true
+}
+
+/**
+ * Initialize the network proxy module with the extension context.
+ * Must be called early in extension activation before any network requests.
+ *
+ * @param context The VS Code extension context
+ * @param channel Optional output channel for logging
+ */
+export async function initializeNetworkProxy(
+ context: vscode.ExtensionContext,
+ channel?: vscode.OutputChannel,
+): Promise {
+ extensionContext = context
+
+ // extensionMode is immutable for the process lifetime - exit early if not in debug mode.
+ // This avoids any overhead (listeners, logging, etc.) in production.
+ const isDebugMode = context.extensionMode === vscode.ExtensionMode.Development
+ if (!isDebugMode) {
+ return
+ }
+
+ outputChannel = channel ?? null
+ loggingEnabled = true
+ consoleLoggingEnabled = !outputChannel
+
+ const config = getProxyConfig()
+
+ log(`Initializing network proxy module...`)
+ log(
+ `Proxy config: enabled=${config.enabled}, serverUrl=${redactProxyUrl(config.serverUrl)}, tlsInsecure=${config.tlsInsecure}`,
+ )
+
+ // Listen for configuration changes to allow toggling proxy during a debug session.
+ // Guard for test environments where onDidChangeConfiguration may not be mocked.
+ if (typeof vscode.workspace.onDidChangeConfiguration === "function") {
+ context.subscriptions.push(
+ vscode.workspace.onDidChangeConfiguration((e) => {
+ if (
+ e.affectsConfiguration(`${Package.name}.debugProxy.enabled`) ||
+ e.affectsConfiguration(`${Package.name}.debugProxy.serverUrl`) ||
+ e.affectsConfiguration(`${Package.name}.debugProxy.tlsInsecure`)
+ ) {
+ const newConfig = getProxyConfig()
+
+ if (newConfig.enabled) {
+ applyTlsVerificationOverride(newConfig)
+ configureGlobalProxy(newConfig)
+ configureUndiciProxy(newConfig)
+ } else {
+ // Proxy disabled - but we can't easily un-bootstrap global-agent or reset undici dispatcher safely.
+ // We *can* restore any global fetch patch immediately.
+ restoreGlobalFetchPatch()
+ restoreTlsVerificationOverride()
+ log("Debug proxy disabled. Restart VS Code to fully disable proxy routing.")
+ }
+ }
+ }),
+ )
+ }
+
+ // Ensure we restore any overrides when the extension unloads.
+ context.subscriptions.push({
+ dispose: () => {
+ restoreGlobalFetchPatch()
+ restoreTlsVerificationOverride()
+ },
+ })
+
+ if (config.enabled) {
+ applyTlsVerificationOverride(config)
+ await configureGlobalProxy(config)
+ await configureUndiciProxy(config)
+ } else {
+ log(`Debug proxy not enabled.`)
+ }
+}
+
+/**
+ * Get the current proxy configuration based on VS Code settings and extension mode.
+ */
+export function getProxyConfig(): ProxyConfig {
+ const defaultServerUrl = "http://127.0.0.1:8888"
+
+ if (!extensionContext) {
+ // Fallback if called before initialization
+ return {
+ enabled: false,
+ serverUrl: defaultServerUrl,
+ tlsInsecure: false,
+ isDebugMode: false,
+ }
+ }
+
+ const config = vscode.workspace.getConfiguration(Package.name)
+ const enabled = Boolean(config.get("debugProxy.enabled"))
+ const rawServerUrl = config.get("debugProxy.serverUrl")
+ const serverUrl = typeof rawServerUrl === "string" && rawServerUrl.trim() ? rawServerUrl.trim() : defaultServerUrl
+ const tlsInsecure = Boolean(config.get("debugProxy.tlsInsecure"))
+
+ // Debug mode only.
+ const isDebugMode = extensionContext.extensionMode === vscode.ExtensionMode.Development
+
+ return {
+ enabled,
+ serverUrl,
+ tlsInsecure,
+ isDebugMode,
+ }
+}
+
+/**
+ * Configure global-agent to route all HTTP/HTTPS traffic through the proxy.
+ */
+async function configureGlobalProxy(config: ProxyConfig): Promise {
+ if (proxyInitialized) {
+ // global-agent can only be bootstrapped once
+ // Update environment variables for any new connections
+ log(`Proxy already initialized, updating env vars only`)
+ updateProxyEnvVars(config)
+ return
+ }
+
+ // Set up environment variables before bootstrapping
+ log(`Setting proxy environment variables before bootstrap (values redacted)...`)
+ updateProxyEnvVars(config)
+
+ let bootstrap: (() => void) | undefined
+ try {
+ const mod = (await import("global-agent")) as typeof import("global-agent")
+ bootstrap = mod.bootstrap
+ } catch (error) {
+ log(
+ `Failed to load global-agent (proxy support is only available in debug/dev builds): ${error instanceof Error ? error.message : String(error)}`,
+ )
+ return
+ }
+
+ // Bootstrap global-agent to intercept all HTTP/HTTPS requests
+ log(`Calling global-agent bootstrap()...`)
+ try {
+ bootstrap()
+ proxyInitialized = true
+ log(`global-agent bootstrap() completed successfully`)
+ } catch (error) {
+ log(`global-agent bootstrap() FAILED: ${error instanceof Error ? error.message : String(error)}`)
+ return
+ }
+
+ log(`Network proxy configured: ${redactProxyUrl(config.serverUrl)}`)
+}
+
+/**
+ * Configure undici's global dispatcher so Node's built-in `fetch()` and any undici-based
+ * clients route through the proxy.
+ */
+async function configureUndiciProxy(config: ProxyConfig): Promise {
+ if (!config.enabled || !config.serverUrl) {
+ return
+ }
+
+ if (undiciProxyInitialized) {
+ log(`undici global dispatcher already configured; restart VS Code to change proxy safely`)
+ return
+ }
+
+ try {
+ const {
+ ProxyAgent,
+ setGlobalDispatcher,
+ fetch: undiciFetch,
+ } = (await import("undici")) as typeof import("undici")
+
+ const proxyAgent = new ProxyAgent({
+ uri: config.serverUrl,
+ // If the user enabled TLS insecure mode (debug only), apply it to undici.
+ requestTls: config.tlsInsecure
+ ? ({ rejectUnauthorized: false } satisfies import("tls").ConnectionOptions) // lgtm[js/disabling-certificate-validation]
+ : undefined,
+ proxyTls: config.tlsInsecure
+ ? ({ rejectUnauthorized: false } satisfies import("tls").ConnectionOptions) // lgtm[js/disabling-certificate-validation]
+ : undefined,
+ })
+ setGlobalDispatcher(proxyAgent)
+ undiciProxyInitialized = true
+ log(`undici global dispatcher configured for proxy: ${redactProxyUrl(config.serverUrl)}`)
+
+ // Node's built-in `fetch()` (Node 18+) is powered by an internal undici copy.
+ // Setting a dispatcher on our `undici` dependency does NOT affect that internal fetch.
+ // To ensure Roo Code's `fetch()` calls are proxied, patch global fetch in debug mode.
+ // This patch is scoped to the extension lifecycle (restored on deactivate) and can be restored
+ // immediately if the proxy is disabled.
+ if (!fetchPatched) {
+ if (typeof globalThis.fetch === "function") {
+ originalFetch = globalThis.fetch
+ }
+
+ globalThis.fetch = undiciFetch as unknown as typeof fetch
+ fetchPatched = true
+ log(`globalThis.fetch patched to undici.fetch (debug proxy mode)`)
+
+ if (extensionContext) {
+ extensionContext.subscriptions.push({
+ dispose: () => restoreGlobalFetchPatch(),
+ })
+ }
+ }
+ } catch (error) {
+ log(`Failed to configure undici proxy dispatcher: ${error instanceof Error ? error.message : String(error)}`)
+ }
+}
+/**
+ * Update environment variables for proxy configuration.
+ * global-agent reads from GLOBAL_AGENT_* environment variables.
+ */
+function updateProxyEnvVars(config: ProxyConfig): void {
+ if (config.serverUrl) {
+ // global-agent uses these environment variables
+ process.env.GLOBAL_AGENT_HTTP_PROXY = config.serverUrl
+ process.env.GLOBAL_AGENT_HTTPS_PROXY = config.serverUrl
+ process.env.GLOBAL_AGENT_NO_PROXY = "" // Proxy all requests
+ }
+}
+
+/**
+ * Check if a proxy is currently configured and active.
+ */
+export function isProxyEnabled(): boolean {
+ const config = getProxyConfig()
+ // Active proxy is only applied in debug mode.
+ return config.enabled && config.isDebugMode
+}
+
+/**
+ * Check if we're running in debug mode.
+ */
+export function isDebugMode(): boolean {
+ if (!extensionContext) {
+ return false
+ }
+ return extensionContext.extensionMode === vscode.ExtensionMode.Development
+}
+
+/**
+ * Log a message to the output channel if available.
+ */
+function log(message: string): void {
+ if (!loggingEnabled) {
+ return
+ }
+
+ const logMessage = `[NetworkProxy] ${message}`
+ if (outputChannel) {
+ outputChannel.appendLine(logMessage)
+ }
+ if (consoleLoggingEnabled) {
+ console.log(logMessage)
+ }
+}
From 741b2680b5ac4d48cffce6b0e0fb74e0da03d172 Mon Sep 17 00:00:00 2001
From: Daniel <57051444+daniel-lxs@users.noreply.github.com>
Date: Tue, 6 Jan 2026 18:27:27 -0500
Subject: [PATCH 02/21] fix: prevent duplicate tool_result blocks causing API
errors (#10497)
---
...resentAssistantMessage-custom-tool.spec.ts | 12 ++
.../presentAssistantMessage-images.spec.ts | 12 ++
...esentAssistantMessage-unknown-tool.spec.ts | 12 ++
.../presentAssistantMessage.ts | 32 +--
src/core/task/Task.ts | 22 ++
src/core/task/__tests__/Task.spec.ts | 202 ++++++++++++++++++
6 files changed, 276 insertions(+), 16 deletions(-)
diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts
index 6ad8c58282..e90646fd9a 100644
--- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts
+++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts
@@ -77,6 +77,18 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}
+
+ // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask
+ mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
+ const existingResult = mockTask.userMessageContent.find(
+ (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
+ )
+ if (existingResult) {
+ return false
+ }
+ mockTask.userMessageContent.push(toolResult)
+ return true
+ })
})
describe("Custom tool usage recording", () => {
diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts
index 39d71bc88b..72ee430609 100644
--- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts
+++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts
@@ -60,6 +60,18 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calls", () =>
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}
+
+ // Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask
+ mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
+ const existingResult = mockTask.userMessageContent.find(
+ (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
+ )
+ if (existingResult) {
+ return false
+ }
+ mockTask.userMessageContent.push(toolResult)
+ return true
+ })
})
it("should preserve images in tool_result for native protocol", async () => {
diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts
index 2c71dc7811..d4ae2764a0 100644
--- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts
+++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts
@@ -59,6 +59,18 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
}
+
+ // Add pushToolResultToUserContent method after mockTask is created so 'this' binds correctly
+ mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
+ const existingResult = mockTask.userMessageContent.find(
+ (block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
+ )
+ if (existingResult) {
+ return false
+ }
+ mockTask.userMessageContent.push(toolResult)
+ return true
+ })
})
it("should return error for unknown tool in native protocol", async () => {
diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts
index 9b1a5fcffb..28cb038d3e 100644
--- a/src/core/assistant-message/presentAssistantMessage.ts
+++ b/src/core/assistant-message/presentAssistantMessage.ts
@@ -115,12 +115,12 @@ export async function presentAssistantMessage(cline: Task) {
: `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.`
if (toolCallId) {
- cline.userMessageContent.push({
+ cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
- } as Anthropic.ToolResultBlockParam)
+ })
}
break
}
@@ -130,12 +130,12 @@ export async function presentAssistantMessage(cline: Task) {
const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.`
if (toolCallId) {
- cline.userMessageContent.push({
+ cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
- } as Anthropic.ToolResultBlockParam)
+ })
}
break
}
@@ -167,11 +167,11 @@ export async function presentAssistantMessage(cline: Task) {
}
if (toolCallId) {
- cline.userMessageContent.push({
+ cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: resultContent,
- } as Anthropic.ToolResultBlockParam)
+ })
if (imageBlocks.length > 0) {
cline.userMessageContent.push(...imageBlocks)
@@ -446,12 +446,12 @@ export async function presentAssistantMessage(cline: Task) {
if (toolCallId) {
// Native protocol: MUST send tool_result for every tool_use
- cline.userMessageContent.push({
+ cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
- } as Anthropic.ToolResultBlockParam)
+ })
} else {
// XML protocol: send as text
cline.userMessageContent.push({
@@ -471,12 +471,12 @@ export async function presentAssistantMessage(cline: Task) {
if (toolCallId) {
// Native protocol: MUST send tool_result for every tool_use
- cline.userMessageContent.push({
+ cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: errorMessage,
is_error: true,
- } as Anthropic.ToolResultBlockParam)
+ })
} else {
// XML protocol: send as text
cline.userMessageContent.push({
@@ -530,11 +530,11 @@ export async function presentAssistantMessage(cline: Task) {
}
// Add tool_result with text content only
- cline.userMessageContent.push({
+ cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: resultContent,
- } as Anthropic.ToolResultBlockParam)
+ })
// Add image blocks separately after tool_result
if (imageBlocks.length > 0) {
@@ -735,12 +735,12 @@ export async function presentAssistantMessage(cline: Task) {
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
// For native protocol, push tool_result directly without setting didAlreadyUseTool
- cline.userMessageContent.push({
+ cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: typeof errorContent === "string" ? errorContent : "(validation error)",
is_error: true,
- } as Anthropic.ToolResultBlockParam)
+ })
} else {
// For XML protocol, use the standard pushToolResult
pushToolResult(errorContent)
@@ -1110,12 +1110,12 @@ export async function presentAssistantMessage(cline: Task) {
// Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
- cline.userMessageContent.push({
+ cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: toolCallId,
content: formatResponse.toolError(errorMessage, toolProtocol),
is_error: true,
- } as Anthropic.ToolResultBlockParam)
+ })
} else {
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
}
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index 33e8245ccc..3340ae5d6c 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -337,6 +337,28 @@ export class Task extends EventEmitter implements TaskLike {
presentAssistantMessageHasPendingUpdates = false
userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = []
userMessageContentReady = false
+
+ /**
+ * Push a tool_result block to userMessageContent, preventing duplicates.
+ * This is critical for native tool protocol where duplicate tool_use_ids cause API errors.
+ *
+ * @param toolResult - The tool_result block to add
+ * @returns true if added, false if duplicate was skipped
+ */
+ public pushToolResultToUserContent(toolResult: Anthropic.ToolResultBlockParam): boolean {
+ const existingResult = this.userMessageContent.find(
+ (block): block is Anthropic.ToolResultBlockParam =>
+ block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
+ )
+ if (existingResult) {
+ console.warn(
+ `[Task#pushToolResultToUserContent] Skipping duplicate tool_result for tool_use_id: ${toolResult.tool_use_id}`,
+ )
+ return false
+ }
+ this.userMessageContent.push(toolResult)
+ return true
+ }
didRejectTool = false
didAlreadyUseTool = false
didToolFailInCurrentTurn = false
diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts
index c3a88b3e29..5b7346d49d 100644
--- a/src/core/task/__tests__/Task.spec.ts
+++ b/src/core/task/__tests__/Task.spec.ts
@@ -1977,3 +1977,205 @@ describe("Queued message processing after condense", () => {
expect(taskB.messageQueueService.isEmpty()).toBe(true)
})
})
+
+describe("pushToolResultToUserContent", () => {
+ let mockProvider: any
+ let mockApiConfig: ProviderSettings
+
+ beforeEach(() => {
+ mockApiConfig = {
+ apiProvider: "anthropic",
+ apiModelId: "claude-3-5-sonnet-20241022",
+ apiKey: "test-api-key",
+ }
+
+ const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") }
+ const mockExtensionContext = {
+ globalState: {
+ get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined),
+ update: vi.fn().mockResolvedValue(undefined),
+ keys: vi.fn().mockReturnValue([]),
+ },
+ globalStorageUri: storageUri,
+ workspaceState: {
+ get: vi.fn().mockImplementation((_key) => undefined),
+ update: vi.fn().mockResolvedValue(undefined),
+ keys: vi.fn().mockReturnValue([]),
+ },
+ secrets: {
+ get: vi.fn().mockResolvedValue(undefined),
+ store: vi.fn().mockResolvedValue(undefined),
+ delete: vi.fn().mockResolvedValue(undefined),
+ },
+ extensionUri: { fsPath: "/mock/extension/path" },
+ extension: { packageJSON: { version: "1.0.0" } },
+ } as unknown as vscode.ExtensionContext
+
+ const mockOutputChannel = {
+ name: "test-output",
+ appendLine: vi.fn(),
+ append: vi.fn(),
+ replace: vi.fn(),
+ clear: vi.fn(),
+ show: vi.fn(),
+ hide: vi.fn(),
+ dispose: vi.fn(),
+ }
+
+ mockProvider = new ClineProvider(
+ mockExtensionContext,
+ mockOutputChannel,
+ "sidebar",
+ new ContextProxy(mockExtensionContext),
+ ) as any
+
+ mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
+ mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
+ })
+
+ it("should add tool_result when not a duplicate", () => {
+ const task = new Task({
+ provider: mockProvider,
+ apiConfiguration: mockApiConfig,
+ task: "test task",
+ startTask: false,
+ })
+
+ const toolResult: Anthropic.ToolResultBlockParam = {
+ type: "tool_result",
+ tool_use_id: "test-id-1",
+ content: "Test result",
+ }
+
+ const added = task.pushToolResultToUserContent(toolResult)
+
+ expect(added).toBe(true)
+ expect(task.userMessageContent).toHaveLength(1)
+ expect(task.userMessageContent[0]).toEqual(toolResult)
+ })
+
+ it("should prevent duplicate tool_result with same tool_use_id", () => {
+ const task = new Task({
+ provider: mockProvider,
+ apiConfiguration: mockApiConfig,
+ task: "test task",
+ startTask: false,
+ })
+
+ const toolResult1: Anthropic.ToolResultBlockParam = {
+ type: "tool_result",
+ tool_use_id: "duplicate-id",
+ content: "First result",
+ }
+
+ const toolResult2: Anthropic.ToolResultBlockParam = {
+ type: "tool_result",
+ tool_use_id: "duplicate-id",
+ content: "Second result (should be skipped)",
+ }
+
+ // Spy on console.warn to verify warning is logged
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
+
+ // Add first result - should succeed
+ const added1 = task.pushToolResultToUserContent(toolResult1)
+ expect(added1).toBe(true)
+ expect(task.userMessageContent).toHaveLength(1)
+
+ // Add second result with same ID - should be skipped
+ const added2 = task.pushToolResultToUserContent(toolResult2)
+ expect(added2).toBe(false)
+ expect(task.userMessageContent).toHaveLength(1)
+
+ // Verify only the first result is in the array
+ expect(task.userMessageContent[0]).toEqual(toolResult1)
+
+ // Verify warning was logged
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining("Skipping duplicate tool_result for tool_use_id: duplicate-id"),
+ )
+
+ warnSpy.mockRestore()
+ })
+
+ it("should allow different tool_use_ids to be added", () => {
+ const task = new Task({
+ provider: mockProvider,
+ apiConfiguration: mockApiConfig,
+ task: "test task",
+ startTask: false,
+ })
+
+ const toolResult1: Anthropic.ToolResultBlockParam = {
+ type: "tool_result",
+ tool_use_id: "id-1",
+ content: "Result 1",
+ }
+
+ const toolResult2: Anthropic.ToolResultBlockParam = {
+ type: "tool_result",
+ tool_use_id: "id-2",
+ content: "Result 2",
+ }
+
+ const added1 = task.pushToolResultToUserContent(toolResult1)
+ const added2 = task.pushToolResultToUserContent(toolResult2)
+
+ expect(added1).toBe(true)
+ expect(added2).toBe(true)
+ expect(task.userMessageContent).toHaveLength(2)
+ expect(task.userMessageContent[0]).toEqual(toolResult1)
+ expect(task.userMessageContent[1]).toEqual(toolResult2)
+ })
+
+ it("should handle tool_result with is_error flag", () => {
+ const task = new Task({
+ provider: mockProvider,
+ apiConfiguration: mockApiConfig,
+ task: "test task",
+ startTask: false,
+ })
+
+ const errorResult: Anthropic.ToolResultBlockParam = {
+ type: "tool_result",
+ tool_use_id: "error-id",
+ content: "Error message",
+ is_error: true,
+ }
+
+ const added = task.pushToolResultToUserContent(errorResult)
+
+ expect(added).toBe(true)
+ expect(task.userMessageContent).toHaveLength(1)
+ expect(task.userMessageContent[0]).toEqual(errorResult)
+ })
+
+ it("should not interfere with other content types in userMessageContent", () => {
+ const task = new Task({
+ provider: mockProvider,
+ apiConfiguration: mockApiConfig,
+ task: "test task",
+ startTask: false,
+ })
+
+ // Add text and image blocks manually
+ task.userMessageContent.push(
+ { type: "text", text: "Some text" },
+ { type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } },
+ )
+
+ const toolResult: Anthropic.ToolResultBlockParam = {
+ type: "tool_result",
+ tool_use_id: "test-id",
+ content: "Result",
+ }
+
+ const added = task.pushToolResultToUserContent(toolResult)
+
+ expect(added).toBe(true)
+ expect(task.userMessageContent).toHaveLength(3)
+ expect(task.userMessageContent[0].type).toBe("text")
+ expect(task.userMessageContent[1].type).toBe("image")
+ expect(task.userMessageContent[2]).toEqual(toolResult)
+ })
+})
From 781ed1e1780870c2513dbc7b1b9cabf6cf48ab59 Mon Sep 17 00:00:00 2001
From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com>
Date: Tue, 6 Jan 2026 20:31:56 -0500
Subject: [PATCH 03/21] feat: add Kimi K2 thinking model to Fireworks AI
provider (#9202)
Co-authored-by: Roo Code
---
packages/types/src/providers/fireworks.ts | 16 +++
src/api/providers/__tests__/fireworks.spec.ts | 108 ++++++++++++++++--
.../base-openai-compatible-provider.ts | 2 +-
3 files changed, 118 insertions(+), 8 deletions(-)
diff --git a/packages/types/src/providers/fireworks.ts b/packages/types/src/providers/fireworks.ts
index 1918826ca1..3f7b17034e 100644
--- a/packages/types/src/providers/fireworks.ts
+++ b/packages/types/src/providers/fireworks.ts
@@ -3,6 +3,7 @@ import type { ModelInfo } from "../model.js"
export type FireworksModelId =
| "accounts/fireworks/models/kimi-k2-instruct"
| "accounts/fireworks/models/kimi-k2-instruct-0905"
+ | "accounts/fireworks/models/kimi-k2-thinking"
| "accounts/fireworks/models/minimax-m2"
| "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507"
| "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct"
@@ -43,6 +44,21 @@ export const fireworksModels = {
description:
"Kimi K2 is a state-of-the-art mixture-of-experts (MoE) language model with 32 billion activated parameters and 1 trillion total parameters. Trained with the Muon optimizer, Kimi K2 achieves exceptional performance across frontier knowledge, reasoning, and coding tasks while being meticulously optimized for agentic capabilities.",
},
+ "accounts/fireworks/models/kimi-k2-thinking": {
+ maxTokens: 16000,
+ contextWindow: 256000,
+ supportsImages: false,
+ supportsPromptCache: true,
+ supportsNativeTools: true,
+ supportsTemperature: true,
+ preserveReasoning: true,
+ defaultTemperature: 1.0,
+ inputPrice: 0.6,
+ outputPrice: 2.5,
+ cacheReadsPrice: 0.15,
+ description:
+ "The kimi-k2-thinking model is a general-purpose agentic reasoning model developed by Moonshot AI. Thanks to its strength in deep reasoning and multi-turn tool use, it can solve even the hardest problems.",
+ },
"accounts/fireworks/models/minimax-m2": {
maxTokens: 4096,
contextWindow: 204800,
diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts
index 9b837fef60..ac5c4396f1 100644
--- a/src/api/providers/__tests__/fireworks.spec.ts
+++ b/src/api/providers/__tests__/fireworks.spec.ts
@@ -115,6 +115,31 @@ describe("FireworksHandler", () => {
)
})
+ it("should return Kimi K2 Thinking model with correct configuration", () => {
+ const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking"
+ const handlerWithModel = new FireworksHandler({
+ apiModelId: testModelId,
+ fireworksApiKey: "test-fireworks-api-key",
+ })
+ const model = handlerWithModel.getModel()
+ expect(model.id).toBe(testModelId)
+ expect(model.info).toEqual(
+ expect.objectContaining({
+ maxTokens: 16000,
+ contextWindow: 256000,
+ supportsImages: false,
+ supportsPromptCache: true,
+ supportsNativeTools: true,
+ supportsTemperature: true,
+ preserveReasoning: true,
+ defaultTemperature: 1.0,
+ inputPrice: 0.6,
+ outputPrice: 2.5,
+ cacheReadsPrice: 0.15,
+ }),
+ )
+ })
+
it("should return MiniMax M2 model with correct configuration", () => {
const testModelId: FireworksModelId = "accounts/fireworks/models/minimax-m2"
const handlerWithModel = new FireworksHandler({
@@ -424,16 +449,85 @@ describe("FireworksHandler", () => {
)
})
- it("should use default temperature of 0.5", () => {
- const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct"
+ it("should use provider default temperature of 0.5 for models without defaultTemperature", async () => {
+ const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct"
const handlerWithModel = new FireworksHandler({
- apiModelId: testModelId,
+ apiModelId: modelId,
fireworksApiKey: "test-fireworks-api-key",
})
- const model = handlerWithModel.getModel()
- // The temperature is set in the constructor as defaultTemperature: 0.5
- // This test verifies the handler is configured with the correct default temperature
- expect(handlerWithModel).toBeDefined()
+
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ async next() {
+ return { done: true }
+ },
+ }),
+ }))
+
+ const messageGenerator = handlerWithModel.createMessage("system", [])
+ await messageGenerator.next()
+
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ temperature: 0.5,
+ }),
+ undefined,
+ )
+ })
+
+ it("should use model defaultTemperature (1.0) over provider default (0.5) for kimi-k2-thinking", async () => {
+ const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking"
+ const handlerWithModel = new FireworksHandler({
+ apiModelId: modelId,
+ fireworksApiKey: "test-fireworks-api-key",
+ })
+
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ async next() {
+ return { done: true }
+ },
+ }),
+ }))
+
+ const messageGenerator = handlerWithModel.createMessage("system", [])
+ await messageGenerator.next()
+
+ // Model's defaultTemperature (1.0) should take precedence over provider's default (0.5)
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ temperature: 1.0,
+ }),
+ undefined,
+ )
+ })
+
+ it("should use user-specified temperature over model and provider defaults", async () => {
+ const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking"
+ const handlerWithModel = new FireworksHandler({
+ apiModelId: modelId,
+ fireworksApiKey: "test-fireworks-api-key",
+ modelTemperature: 0.7,
+ })
+
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ async next() {
+ return { done: true }
+ },
+ }),
+ }))
+
+ const messageGenerator = handlerWithModel.createMessage("system", [])
+ await messageGenerator.next()
+
+ // User-specified temperature should take precedence over everything
+ expect(mockCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ temperature: 0.7,
+ }),
+ undefined,
+ )
})
it("should handle empty response in completePrompt", async () => {
diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts
index 5aee7267b3..a2a55cdc10 100644
--- a/src/api/providers/base-openai-compatible-provider.ts
+++ b/src/api/providers/base-openai-compatible-provider.ts
@@ -84,7 +84,7 @@ export abstract class BaseOpenAiCompatibleProvider
format: "openai",
}) ?? undefined
- const temperature = this.options.modelTemperature ?? this.defaultTemperature
+ const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model,
From 7f2978abad19b54adbd7d020bdbc01089ff2e606 Mon Sep 17 00:00:00 2001
From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com>
Date: Tue, 6 Jan 2026 20:46:53 -0500
Subject: [PATCH 04/21] fix: add missing description fields for debugProxy
configuration (#10505)
Co-authored-by: Roo Code
---
src/package.json | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/package.json b/src/package.json
index 8e8ad31c5a..5e808295a2 100644
--- a/src/package.json
+++ b/src/package.json
@@ -415,16 +415,19 @@
"roo-cline.debugProxy.enabled": {
"type": "boolean",
"default": false,
+ "description": "%settings.debugProxy.enabled.description%",
"markdownDescription": "%settings.debugProxy.enabled.description%"
},
"roo-cline.debugProxy.serverUrl": {
"type": "string",
"default": "http://127.0.0.1:8888",
+ "description": "%settings.debugProxy.serverUrl.description%",
"markdownDescription": "%settings.debugProxy.serverUrl.description%"
},
"roo-cline.debugProxy.tlsInsecure": {
"type": "boolean",
"default": false,
+ "description": "%settings.debugProxy.tlsInsecure.description%",
"markdownDescription": "%settings.debugProxy.tlsInsecure.description%"
}
}
From 2d22804d4a3bca31602094195bed02355c2492b4 Mon Sep 17 00:00:00 2001
From: Matt Rubens
Date: Wed, 7 Jan 2026 00:35:28 -0500
Subject: [PATCH 05/21] Tweak the style of the follow up suggestion modes
(#9260)
---
webview-ui/src/components/chat/FollowUpSuggest.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webview-ui/src/components/chat/FollowUpSuggest.tsx b/webview-ui/src/components/chat/FollowUpSuggest.tsx
index 6c7a8394b4..cb367716d3 100644
--- a/webview-ui/src/components/chat/FollowUpSuggest.tsx
+++ b/webview-ui/src/components/chat/FollowUpSuggest.tsx
@@ -135,7 +135,7 @@ export const FollowUpSuggest = ({
)}
{suggestion.mode && (
-
+
{suggestion.mode}
From 41c5ff60768e9ce2c66654289091cb49fa2c93ce Mon Sep 17 00:00:00 2001
From: Hannes Rudolph
Date: Wed, 7 Jan 2026 08:00:34 -0700
Subject: [PATCH 06/21] feat(web-evals): remember last Roo model selection +
add evals skill (#10470)
* feat(web-evals): remember last Roo model selection
* fix(web-evals): reset model selections on provider switch and fix lint warning
- Add useEffect to reset model selections when switching between providers
This prevents OpenRouter model IDs from persisting when switching to Roo,
which was causing Roo's stored selection to be overwritten with wrong IDs
- Remove unused 'executionMethod' from onSubmit dependency array to fix
react-hooks/exhaustive-deps warning
* fix(web-evals): add missing executionMethod to test cases
* fix(web-evals): harden localStorage + keep provider selections
---
.roo/skills/evals-context/SKILL.md | 188 ++++++++++++++++++
apps/web-evals/src/app/runs/new/new-run.tsx | 92 ++++++++-
.../__tests__/normalize-create-run.spec.ts | 65 ++++++
.../roo-last-model-selection.spec.ts | 78 ++++++++
.../web-evals/src/lib/normalize-create-run.ts | 20 ++
.../src/lib/roo-last-model-selection.ts | 76 +++++++
6 files changed, 514 insertions(+), 5 deletions(-)
create mode 100644 .roo/skills/evals-context/SKILL.md
create mode 100644 apps/web-evals/src/lib/__tests__/normalize-create-run.spec.ts
create mode 100644 apps/web-evals/src/lib/__tests__/roo-last-model-selection.spec.ts
create mode 100644 apps/web-evals/src/lib/normalize-create-run.ts
create mode 100644 apps/web-evals/src/lib/roo-last-model-selection.ts
diff --git a/.roo/skills/evals-context/SKILL.md b/.roo/skills/evals-context/SKILL.md
new file mode 100644
index 0000000000..985b788b94
--- /dev/null
+++ b/.roo/skills/evals-context/SKILL.md
@@ -0,0 +1,188 @@
+---
+name: evals-context
+description: Provides context about the Roo Code evals system structure in this monorepo. Use when tasks mention "evals", "evaluation", "eval runs", "eval exercises", or working with the evals infrastructure. Helps distinguish between the evals execution system (packages/evals, apps/web-evals) and the public website evals display page (apps/web-roo-code/src/app/evals).
+---
+
+# Evals Codebase Context
+
+## When to Use This Skill
+
+Use this skill when the task involves:
+
+- Modifying or debugging the evals execution infrastructure
+- Adding new eval exercises or languages
+- Working with the evals web interface (apps/web-evals)
+- Modifying the public evals display page on roocode.com
+- Understanding where evals code lives in this monorepo
+
+## When NOT to Use This Skill
+
+Do NOT use this skill when:
+
+- Working on unrelated parts of the codebase (extension, webview-ui, etc.)
+- The task is purely about the VS Code extension's core functionality
+- Working on the main website pages that don't involve evals
+
+## Key Disambiguation: Two "Evals" Locations
+
+This monorepo has **two distinct evals-related locations** that can cause confusion:
+
+| Component | Path | Purpose |
+| --------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
+| **Evals Execution System** | `packages/evals/` | Core eval infrastructure: CLI, DB schema, Docker configs |
+| **Evals Management UI** | `apps/web-evals/` | Next.js app for creating/monitoring eval runs (localhost:3446) |
+| **Website Evals Page** | `apps/web-roo-code/src/app/evals/` | Public roocode.com page displaying eval results |
+| **External Exercises Repo** | [Roo-Code-Evals](https://github.com/RooCodeInc/Roo-Code-Evals) | Actual coding exercises (NOT in this monorepo) |
+
+## Directory Structure Reference
+
+### `packages/evals/` - Core Evals Package
+
+```
+packages/evals/
+├── ARCHITECTURE.md # Detailed architecture documentation
+├── ADDING-EVALS.md # Guide for adding new exercises/languages
+├── README.md # Setup and running instructions
+├── docker-compose.yml # Container orchestration
+├── Dockerfile.runner # Runner container definition
+├── Dockerfile.web # Web app container
+├── drizzle.config.ts # Database ORM config
+├── src/
+│ ├── index.ts # Package exports
+│ ├── cli/ # CLI commands for running evals
+│ │ ├── runEvals.ts # Orchestrates complete eval runs
+│ │ ├── runTask.ts # Executes individual tasks in containers
+│ │ ├── runUnitTest.ts # Validates task completion via tests
+│ │ └── redis.ts # Redis pub/sub integration
+│ ├── db/
+│ │ ├── schema.ts # Database schema (runs, tasks)
+│ │ ├── queries/ # Database query functions
+│ │ └── migrations/ # SQL migrations
+│ └── exercises/
+│ └── index.ts # Exercise loading utilities
+└── scripts/
+ └── setup.sh # Local macOS setup script
+```
+
+### `apps/web-evals/` - Evals Management Web App
+
+```
+apps/web-evals/
+├── src/
+│ ├── app/
+│ │ ├── page.tsx # Home page (runs list)
+│ │ ├── runs/
+│ │ │ ├── new/ # Create new eval run
+│ │ │ └── [id]/ # View specific run status
+│ │ └── api/runs/ # SSE streaming endpoint
+│ ├── actions/ # Server actions
+│ │ ├── runs.ts # Run CRUD operations
+│ │ ├── tasks.ts # Task queries
+│ │ ├── exercises.ts # Exercise listing
+│ │ └── heartbeat.ts # Controller health checks
+│ ├── hooks/ # React hooks (SSE, models, etc.)
+│ └── lib/ # Utilities and schemas
+```
+
+### `apps/web-roo-code/src/app/evals/` - Public Website Evals Page
+
+```
+apps/web-roo-code/src/app/evals/
+├── page.tsx # Fetches and displays public eval results
+├── evals.tsx # Main evals display component
+├── plot.tsx # Visualization component
+└── types.ts # EvalRun type (extends packages/evals types)
+```
+
+This page **displays** eval results on the public roocode.com website. It imports types from `@roo-code/evals` but does NOT run evals.
+
+## Architecture Overview
+
+The evals system is a distributed evaluation platform that runs AI coding tasks in isolated VS Code environments:
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Web App (apps/web-evals) ──────────────────────────────── │
+│ │ │
+│ ▼ │
+│ PostgreSQL ◄────► Controller Container │
+│ │ │ │
+│ ▼ ▼ │
+│ Redis ◄───► Runner Containers (1-25 parallel) │
+└─────────────────────────────────────────────────────────────┘
+```
+
+**Key components:**
+
+- **Controller**: Orchestrates eval runs, spawns runners, manages task queue (p-queue)
+- **Runner**: Isolated Docker container with VS Code + Roo Code extension + language runtimes
+- **Redis**: Pub/sub for real-time events (NOT task queuing)
+- **PostgreSQL**: Stores runs, tasks, metrics
+
+## Common Tasks Quick Reference
+
+### Adding a New Eval Exercise
+
+1. Add exercise to [Roo-Code-Evals](https://github.com/RooCodeInc/Roo-Code-Evals) repo (external)
+2. See [`packages/evals/ADDING-EVALS.md`](packages/evals/ADDING-EVALS.md) for structure
+
+### Modifying Eval CLI Behavior
+
+Edit files in [`packages/evals/src/cli/`](packages/evals/src/cli/):
+
+- [`runEvals.ts`](packages/evals/src/cli/runEvals.ts) - Run orchestration
+- [`runTask.ts`](packages/evals/src/cli/runTask.ts) - Task execution
+- [`runUnitTest.ts`](packages/evals/src/cli/runUnitTest.ts) - Test validation
+
+### Modifying the Evals Web Interface
+
+Edit files in [`apps/web-evals/src/`](apps/web-evals/src/):
+
+- [`app/runs/new/new-run.tsx`](apps/web-evals/src/app/runs/new/new-run.tsx) - New run form
+- [`actions/runs.ts`](apps/web-evals/src/actions/runs.ts) - Run server actions
+
+### Modifying the Public Evals Display Page
+
+Edit files in [`apps/web-roo-code/src/app/evals/`](apps/web-roo-code/src/app/evals/):
+
+- [`evals.tsx`](apps/web-roo-code/src/app/evals/evals.tsx) - Display component
+- [`plot.tsx`](apps/web-roo-code/src/app/evals/plot.tsx) - Charts
+
+### Database Schema Changes
+
+1. Edit [`packages/evals/src/db/schema.ts`](packages/evals/src/db/schema.ts)
+2. Generate migration: `cd packages/evals && pnpm drizzle-kit generate`
+3. Apply migration: `pnpm drizzle-kit migrate`
+
+## Running Evals Locally
+
+```bash
+# From repo root
+pnpm evals
+
+# Opens web UI at http://localhost:3446
+```
+
+**Ports (defaults):**
+
+- PostgreSQL: 5433
+- Redis: 6380
+- Web: 3446
+
+## Testing
+
+```bash
+# packages/evals tests
+cd packages/evals && npx vitest run
+
+# apps/web-evals tests
+cd apps/web-evals && npx vitest run
+```
+
+## Key Types/Exports from `@roo-code/evals`
+
+The package exports are defined in [`packages/evals/src/index.ts`](packages/evals/src/index.ts):
+
+- Database queries: `getRuns`, `getTasks`, `getTaskMetrics`, etc.
+- Schema types: `Run`, `Task`, `TaskMetrics`
+- Used by both `apps/web-evals` and `apps/web-roo-code`
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 28fb4abfd5..cea15c6ddd 100644
--- a/apps/web-evals/src/app/runs/new/new-run.tsx
+++ b/apps/web-evals/src/app/runs/new/new-run.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useCallback, useEffect, useMemo, useState } from "react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { z } from "zod"
import { useQuery } from "@tanstack/react-query"
@@ -48,6 +48,9 @@ import {
} from "@/lib/schemas"
import { cn } from "@/lib/utils"
+import { loadRooLastModelSelection, saveRooLastModelSelection } from "@/lib/roo-last-model-selection"
+import { normalizeCreateRunForSubmit } from "@/lib/normalize-create-run"
+
import { useOpenRouterModels } from "@/hooks/use-open-router-models"
import { useRooCodeCloudModels } from "@/hooks/use-roo-code-cloud-models"
@@ -103,6 +106,8 @@ type ConfigSelection = {
export function NewRun() {
const router = useRouter()
+ const modelSelectionsByProviderRef = useRef>({})
+ const modelValueByProviderRef = useRef>({})
const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("other")
const [executionMethod, setExecutionMethod] = useState("vscode")
@@ -147,14 +152,43 @@ export function NewRun() {
})
const {
+ register,
setValue,
clearErrors,
watch,
+ getValues,
formState: { isSubmitting },
} = form
const [suite, settings] = watch(["suite", "settings", "concurrency"])
+ const selectedModelIds = useMemo(
+ () => modelSelections.map((s) => s.model).filter((m) => m.length > 0),
+ [modelSelections],
+ )
+
+ const applyModelIds = useCallback(
+ (modelIds: string[]) => {
+ const unique = Array.from(new Set(modelIds.map((m) => m.trim()).filter((m) => m.length > 0)))
+
+ if (unique.length === 0) {
+ setModelSelections([{ id: crypto.randomUUID(), model: "", popoverOpen: false }])
+ setValue("model", "")
+ return
+ }
+
+ setModelSelections(unique.map((model) => ({ id: crypto.randomUUID(), model, popoverOpen: false })))
+ setValue("model", unique[0] ?? "")
+ },
+ [setValue],
+ )
+
+ // Ensure the `exercises` field is registered so RHF always includes it in submit values.
+ useEffect(() => {
+ register("exercises")
+ }, [register])
+
+ // Load settings from localStorage on mount
useEffect(() => {
const savedConcurrency = localStorage.getItem("evals-concurrency")
@@ -215,6 +249,51 @@ export function NewRun() {
}
}, [setValue])
+ // Track previous provider to detect switches
+ const [prevProvider, setPrevProvider] = useState(provider)
+
+ // Preserve selections per provider; avoids cross-contamination while keeping UX stable.
+ useEffect(() => {
+ if (provider === prevProvider) return
+
+ modelSelectionsByProviderRef.current[prevProvider] = modelSelections
+ modelValueByProviderRef.current[prevProvider] = getValues("model")
+
+ const nextModelSelections =
+ modelSelectionsByProviderRef.current[provider] ??
+ ([{ id: crypto.randomUUID(), model: "", popoverOpen: false }] satisfies ModelSelection[])
+
+ setModelSelections(nextModelSelections)
+
+ const nextModelValue =
+ modelValueByProviderRef.current[provider] ??
+ nextModelSelections.find((s) => s.model.trim().length > 0)?.model ??
+ (provider === "other" && importedSettings && configSelections[0]?.configName
+ ? (getModelId(importedSettings.apiConfigs[configSelections[0].configName] ?? {}) ?? "")
+ : "")
+
+ setValue("model", nextModelValue)
+ setPrevProvider(provider)
+ }, [provider, prevProvider, modelSelections, setValue, getValues, importedSettings, configSelections])
+
+ // When switching to Roo provider, restore last-used selection if current selection is empty
+ useEffect(() => {
+ if (provider !== "roo") return
+ if (selectedModelIds.length > 0) return
+
+ const last = loadRooLastModelSelection()
+ if (last.length > 0) {
+ applyModelIds(last)
+ }
+ }, [applyModelIds, provider, selectedModelIds.length])
+
+ // Persist last-used Roo provider model selection
+ useEffect(() => {
+ if (provider !== "roo") return
+ saveRooLastModelSelection(selectedModelIds)
+ }, [provider, selectedModelIds])
+
+ // Extract unique languages from exercises
const languages = useMemo(() => {
if (!exercises.data) {
return []
@@ -337,7 +416,10 @@ export function NewRun() {
const onSubmit = useCallback(
async (values: CreateRun) => {
try {
- if (provider === "roo" && !values.jobToken?.trim()) {
+ const baseValues = normalizeCreateRunForSubmit(values, selectedExercises, suite)
+
+ // Validate jobToken for Roo Code Cloud provider
+ if (provider === "roo" && !baseValues.jobToken?.trim()) {
toast.error("Roo Code Cloud Token is required")
return
}
@@ -374,8 +456,7 @@ export function NewRun() {
await new Promise((resolve) => setTimeout(resolve, 20_000))
}
- const runValues = { ...values }
- runValues.executionMethod = executionMethod
+ const runValues = { ...baseValues }
if (provider === "openrouter") {
runValues.model = selection.model
@@ -424,8 +505,9 @@ export function NewRun() {
}
},
[
+ suite,
+ selectedExercises,
provider,
- executionMethod,
modelSelections,
configSelections,
importedSettings,
diff --git a/apps/web-evals/src/lib/__tests__/normalize-create-run.spec.ts b/apps/web-evals/src/lib/__tests__/normalize-create-run.spec.ts
new file mode 100644
index 0000000000..947df31354
--- /dev/null
+++ b/apps/web-evals/src/lib/__tests__/normalize-create-run.spec.ts
@@ -0,0 +1,65 @@
+import { normalizeCreateRunForSubmit } from "../normalize-create-run"
+
+describe("normalizeCreateRunForSubmit", () => {
+ it("uses selectedExercises for partial suite", () => {
+ const result = normalizeCreateRunForSubmit(
+ {
+ model: "roo/model-a",
+ description: "",
+ suite: "partial",
+ exercises: [],
+ settings: undefined,
+ concurrency: 1,
+ timeout: 5,
+ iterations: 1,
+ jobToken: "",
+ executionMethod: "vscode",
+ },
+ ["js/foo", "py/bar"],
+ )
+
+ expect(result.suite).toBe("partial")
+ expect(result.exercises).toEqual(["js/foo", "py/bar"])
+ })
+
+ it("dedupes selectedExercises for partial suite", () => {
+ const result = normalizeCreateRunForSubmit(
+ {
+ model: "roo/model-a",
+ description: "",
+ suite: "partial",
+ exercises: [],
+ settings: undefined,
+ concurrency: 1,
+ timeout: 5,
+ iterations: 1,
+ jobToken: "",
+ executionMethod: "vscode",
+ },
+ ["js/foo", "js/foo", "py/bar"],
+ )
+
+ expect(result.exercises).toEqual(["js/foo", "py/bar"])
+ })
+
+ it("clears exercises for full suite", () => {
+ const result = normalizeCreateRunForSubmit(
+ {
+ model: "roo/model-a",
+ description: "",
+ suite: "full",
+ exercises: ["js/foo"],
+ settings: undefined,
+ concurrency: 1,
+ timeout: 5,
+ iterations: 1,
+ jobToken: "",
+ executionMethod: "vscode",
+ },
+ ["js/foo"],
+ )
+
+ expect(result.suite).toBe("full")
+ expect(result.exercises).toEqual([])
+ })
+})
diff --git a/apps/web-evals/src/lib/__tests__/roo-last-model-selection.spec.ts b/apps/web-evals/src/lib/__tests__/roo-last-model-selection.spec.ts
new file mode 100644
index 0000000000..45879b4be5
--- /dev/null
+++ b/apps/web-evals/src/lib/__tests__/roo-last-model-selection.spec.ts
@@ -0,0 +1,78 @@
+import {
+ loadRooLastModelSelection,
+ ROO_LAST_MODEL_SELECTION_KEY,
+ saveRooLastModelSelection,
+} from "../roo-last-model-selection"
+
+class LocalStorageMock implements Storage {
+ private store = new Map()
+
+ get length(): number {
+ return this.store.size
+ }
+
+ clear(): void {
+ this.store.clear()
+ }
+
+ getItem(key: string): string | null {
+ return this.store.get(key) ?? null
+ }
+
+ key(index: number): string | null {
+ return Array.from(this.store.keys())[index] ?? null
+ }
+
+ removeItem(key: string): void {
+ this.store.delete(key)
+ }
+
+ setItem(key: string, value: string): void {
+ this.store.set(key, value)
+ }
+}
+
+beforeEach(() => {
+ Object.defineProperty(globalThis, "localStorage", {
+ value: new LocalStorageMock(),
+ configurable: true,
+ })
+})
+
+describe("roo-last-model-selection", () => {
+ it("saves and loads (deduped + trimmed)", () => {
+ saveRooLastModelSelection([" roo/model-a ", "roo/model-a", "roo/model-b"])
+ expect(loadRooLastModelSelection()).toEqual(["roo/model-a", "roo/model-b"])
+ })
+
+ it("ignores invalid JSON", () => {
+ localStorage.setItem(ROO_LAST_MODEL_SELECTION_KEY, "{this is not json")
+ expect(loadRooLastModelSelection()).toEqual([])
+ })
+
+ it("clears when empty", () => {
+ localStorage.setItem(ROO_LAST_MODEL_SELECTION_KEY, JSON.stringify(["roo/model-a"]))
+ saveRooLastModelSelection([])
+ expect(localStorage.getItem(ROO_LAST_MODEL_SELECTION_KEY)).toBeNull()
+ })
+
+ it("does not throw if localStorage access fails", () => {
+ Object.defineProperty(globalThis, "localStorage", {
+ value: {
+ getItem: () => {
+ throw new Error("blocked")
+ },
+ setItem: () => {
+ throw new Error("blocked")
+ },
+ removeItem: () => {
+ throw new Error("blocked")
+ },
+ },
+ configurable: true,
+ })
+
+ expect(() => loadRooLastModelSelection()).not.toThrow()
+ expect(() => saveRooLastModelSelection(["roo/model-a"])).not.toThrow()
+ })
+})
diff --git a/apps/web-evals/src/lib/normalize-create-run.ts b/apps/web-evals/src/lib/normalize-create-run.ts
new file mode 100644
index 0000000000..a5f21ba5ad
--- /dev/null
+++ b/apps/web-evals/src/lib/normalize-create-run.ts
@@ -0,0 +1,20 @@
+import type { CreateRun } from "./schemas"
+
+/**
+ * The New Run UI keeps exercise selection in component state.
+ * This normalizer ensures we submit the *visible/selected* exercises when suite is partial.
+ */
+export function normalizeCreateRunForSubmit(
+ values: CreateRun,
+ selectedExercises: string[],
+ suiteOverride?: CreateRun["suite"],
+): CreateRun {
+ const suite = suiteOverride ?? values.suite
+ const normalizedSelectedExercises = Array.from(new Set(selectedExercises))
+
+ return {
+ ...values,
+ suite,
+ exercises: suite === "partial" ? normalizedSelectedExercises : [],
+ }
+}
diff --git a/apps/web-evals/src/lib/roo-last-model-selection.ts b/apps/web-evals/src/lib/roo-last-model-selection.ts
new file mode 100644
index 0000000000..b66d493172
--- /dev/null
+++ b/apps/web-evals/src/lib/roo-last-model-selection.ts
@@ -0,0 +1,76 @@
+import { z } from "zod"
+
+export const ROO_LAST_MODEL_SELECTION_KEY = "evals-roo-last-model-selection"
+
+const modelIdListSchema = z.array(z.string())
+
+function hasLocalStorage(): boolean {
+ try {
+ return typeof localStorage !== "undefined"
+ } catch {
+ return false
+ }
+}
+
+function safeGetItem(key: string): string | null {
+ try {
+ return localStorage.getItem(key)
+ } catch {
+ return null
+ }
+}
+
+function safeSetItem(key: string, value: string): void {
+ try {
+ localStorage.setItem(key, value)
+ } catch {
+ // ignore
+ }
+}
+
+function safeRemoveItem(key: string): void {
+ try {
+ localStorage.removeItem(key)
+ } catch {
+ // ignore
+ }
+}
+
+function tryParseJson(raw: string | null): unknown {
+ if (raw === null) return undefined
+ try {
+ return JSON.parse(raw)
+ } catch {
+ return undefined
+ }
+}
+
+function normalizeModelIds(modelIds: string[]): string[] {
+ const unique = new Set()
+ for (const id of modelIds) {
+ const trimmed = id.trim()
+ if (trimmed) unique.add(trimmed)
+ }
+ return Array.from(unique)
+}
+
+export function loadRooLastModelSelection(): string[] {
+ if (!hasLocalStorage()) return []
+
+ const parsed = modelIdListSchema.safeParse(tryParseJson(safeGetItem(ROO_LAST_MODEL_SELECTION_KEY)))
+ if (!parsed.success) return []
+
+ return normalizeModelIds(parsed.data)
+}
+
+export function saveRooLastModelSelection(modelIds: string[]): void {
+ if (!hasLocalStorage()) return
+
+ const normalized = normalizeModelIds(modelIds)
+ if (normalized.length === 0) {
+ safeRemoveItem(ROO_LAST_MODEL_SELECTION_KEY)
+ return
+ }
+
+ safeSetItem(ROO_LAST_MODEL_SELECTION_KEY, JSON.stringify(normalized))
+}
From 7bbdcdf5d0d1be64125cd35fb75c0f4970d72c11 Mon Sep 17 00:00:00 2001
From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com>
Date: Wed, 7 Jan 2026 10:03:37 -0500
Subject: [PATCH 07/21] feat: rename YOLO to BRRR (#10507)
Co-authored-by: Roo Code
---
apps/web-roo-code/src/components/homepage/features.tsx | 2 +-
webview-ui/src/i18n/locales/ca/chat.json | 2 +-
webview-ui/src/i18n/locales/de/chat.json | 2 +-
webview-ui/src/i18n/locales/en/chat.json | 2 +-
webview-ui/src/i18n/locales/es/chat.json | 2 +-
webview-ui/src/i18n/locales/fr/chat.json | 2 +-
webview-ui/src/i18n/locales/hi/chat.json | 2 +-
webview-ui/src/i18n/locales/id/chat.json | 2 +-
webview-ui/src/i18n/locales/it/chat.json | 2 +-
webview-ui/src/i18n/locales/ja/chat.json | 2 +-
webview-ui/src/i18n/locales/ko/chat.json | 2 +-
webview-ui/src/i18n/locales/nl/chat.json | 2 +-
webview-ui/src/i18n/locales/pl/chat.json | 2 +-
webview-ui/src/i18n/locales/pt-BR/chat.json | 2 +-
webview-ui/src/i18n/locales/ru/chat.json | 2 +-
webview-ui/src/i18n/locales/tr/chat.json | 2 +-
webview-ui/src/i18n/locales/vi/chat.json | 2 +-
webview-ui/src/i18n/locales/zh-CN/chat.json | 2 +-
webview-ui/src/i18n/locales/zh-TW/chat.json | 2 +-
19 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/apps/web-roo-code/src/components/homepage/features.tsx b/apps/web-roo-code/src/components/homepage/features.tsx
index fd7bb6114a..b78f76db21 100644
--- a/apps/web-roo-code/src/components/homepage/features.tsx
+++ b/apps/web-roo-code/src/components/homepage/features.tsx
@@ -39,7 +39,7 @@ export const features: Feature[] = [
icon: CheckCheck,
title: "Granular auto-approval",
description:
- "Control each action and make Roo as autonomous as you want as you build confidence. Or go YOLO and let it rip.",
+ "Control each action and make Roo as autonomous as you want as you build confidence. Or go BRRR and let it rip.",
},
{
icon: Boxes,
diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json
index efed3677aa..276688951c 100644
--- a/webview-ui/src/i18n/locales/ca/chat.json
+++ b/webview-ui/src/i18n/locales/ca/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0 aprovacions automàtiques",
"triggerLabel_one": "1 aprovació automàtica",
"triggerLabel_other": "{{count}} aprovacions automàtiques",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Pensant",
diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json
index 2f3a6eac2e..3e62726325 100644
--- a/webview-ui/src/i18n/locales/de/chat.json
+++ b/webview-ui/src/i18n/locales/de/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0 automatisch genehmigt",
"triggerLabel_one": "1 automatisch genehmigt",
"triggerLabel_other": "{{count}} automatisch genehmigt",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Denke nach",
diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json
index a4d1ffef9f..797a8f2dfe 100644
--- a/webview-ui/src/i18n/locales/en/chat.json
+++ b/webview-ui/src/i18n/locales/en/chat.json
@@ -322,7 +322,7 @@
"triggerLabel_zero": "0 auto-approve",
"triggerLabel_one": "1 auto-approved",
"triggerLabel_other": "{{count}} auto-approved",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"announcement": {
"title": "Roo Code {{version}} Released",
diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json
index aa59cdfe01..e1557df4b4 100644
--- a/webview-ui/src/i18n/locales/es/chat.json
+++ b/webview-ui/src/i18n/locales/es/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0 aprobaciones automáticas",
"triggerLabel_one": "1 aprobación automática",
"triggerLabel_other": "{{count}} aprobaciones automáticas",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Pensando",
diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json
index d1594116c0..fa8e5eca1b 100644
--- a/webview-ui/src/i18n/locales/fr/chat.json
+++ b/webview-ui/src/i18n/locales/fr/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0 approuvé automatiquement",
"triggerLabel_one": "1 approuvé automatiquement",
"triggerLabel_other": "{{count}} approuvés automatiquement",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Réflexion",
diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json
index cbeafe9b5c..45cd3f651a 100644
--- a/webview-ui/src/i18n/locales/hi/chat.json
+++ b/webview-ui/src/i18n/locales/hi/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0 स्वतः-अनुमोदन",
"triggerLabel_one": "1 स्वतः-अनुमोदित",
"triggerLabel_other": "{{count}} स्वतः-अनुमोदित",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "विचार कर रहा है",
diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json
index 4d1c0fcceb..483e7315ac 100644
--- a/webview-ui/src/i18n/locales/id/chat.json
+++ b/webview-ui/src/i18n/locales/id/chat.json
@@ -328,7 +328,7 @@
"triggerLabel_zero": "0 disetujui otomatis",
"triggerLabel_one": "1 disetujui otomatis",
"triggerLabel_other": "{{count}} disetujui otomatis",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"announcement": {
"title": "Roo Code {{version}} Dirilis",
diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json
index 364a2405d2..2570daccae 100644
--- a/webview-ui/src/i18n/locales/it/chat.json
+++ b/webview-ui/src/i18n/locales/it/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0 approvati automaticamente",
"triggerLabel_one": "1 approvato automaticamente",
"triggerLabel_other": "{{count}} approvati automaticamente",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Sto pensando",
diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json
index 8ce5f80b02..36493aaf06 100644
--- a/webview-ui/src/i18n/locales/ja/chat.json
+++ b/webview-ui/src/i18n/locales/ja/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0個の自動承認",
"triggerLabel_one": "1個の自動承認済み",
"triggerLabel_other": "{{count}}個の自動承認済み",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "考え中",
diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json
index 0cf4e07350..d9faa8f243 100644
--- a/webview-ui/src/i18n/locales/ko/chat.json
+++ b/webview-ui/src/i18n/locales/ko/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0개 자동 승인됨",
"triggerLabel_one": "1개 자동 승인됨",
"triggerLabel_other": "{{count}}개 자동 승인됨",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "생각 중",
diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json
index 5c810e5add..bdc835bcd2 100644
--- a/webview-ui/src/i18n/locales/nl/chat.json
+++ b/webview-ui/src/i18n/locales/nl/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0 automatisch goedgekeurd",
"triggerLabel_one": "1 automatisch goedgekeurd",
"triggerLabel_other": "{{count}} automatisch goedgekeurd",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"announcement": {
"title": "Roo Code {{version}} uitgebracht",
diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json
index 96b4411f6b..441632e2da 100644
--- a/webview-ui/src/i18n/locales/pl/chat.json
+++ b/webview-ui/src/i18n/locales/pl/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0 automatycznie zatwierdzone",
"triggerLabel_one": "1 automatycznie zatwierdzony",
"triggerLabel_other": "{{count}} automatycznie zatwierdzonych",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Myślenie",
diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json
index 05031e84ad..4f964997a5 100644
--- a/webview-ui/src/i18n/locales/pt-BR/chat.json
+++ b/webview-ui/src/i18n/locales/pt-BR/chat.json
@@ -292,7 +292,7 @@
"triggerLabel_zero": "0 aprovados automaticamente",
"triggerLabel_one": "1 aprovado automaticamente",
"triggerLabel_other": "{{count}} aprovados automaticamente",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Pensando",
diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json
index af2c5ae83c..74dfb5d2a0 100644
--- a/webview-ui/src/i18n/locales/ru/chat.json
+++ b/webview-ui/src/i18n/locales/ru/chat.json
@@ -293,7 +293,7 @@
"triggerLabel_zero": "0 авто-утвержденных",
"triggerLabel_one": "1 авто-утвержден",
"triggerLabel_other": "{{count}} авто-утвержденных",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"announcement": {
"title": "Выпущен Roo Code {{version}}",
diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json
index 94a6791b27..09f5057d6a 100644
--- a/webview-ui/src/i18n/locales/tr/chat.json
+++ b/webview-ui/src/i18n/locales/tr/chat.json
@@ -293,7 +293,7 @@
"triggerLabel_zero": "0 otomatik onaylandı",
"triggerLabel_one": "1 otomatik onaylandı",
"triggerLabel_other": "{{count}} otomatik onaylandı",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Düşünüyor",
diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json
index a8380f4c3b..519940adc5 100644
--- a/webview-ui/src/i18n/locales/vi/chat.json
+++ b/webview-ui/src/i18n/locales/vi/chat.json
@@ -293,7 +293,7 @@
"triggerLabel_zero": "0 được tự động phê duyệt",
"triggerLabel_one": "1 được tự động phê duyệt",
"triggerLabel_other": "{{count}} được tự động phê duyệt",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "Đang suy nghĩ",
diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json
index ab834bc1d2..b4401a35f7 100644
--- a/webview-ui/src/i18n/locales/zh-CN/chat.json
+++ b/webview-ui/src/i18n/locales/zh-CN/chat.json
@@ -293,7 +293,7 @@
"triggerLabel_zero": "0 个自动批准",
"triggerLabel_one": "1 个自动批准",
"triggerLabel_other": "{{count}} 个自动批准",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"reasoning": {
"thinking": "思考中",
diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json
index 9cf54ee53f..3856aab3a9 100644
--- a/webview-ui/src/i18n/locales/zh-TW/chat.json
+++ b/webview-ui/src/i18n/locales/zh-TW/chat.json
@@ -326,7 +326,7 @@
"triggerLabel_zero": "0 個自動核准",
"triggerLabel_one": "1 個自動核准",
"triggerLabel_other": "{{count}} 個自動核准",
- "triggerLabelAll": "YOLO"
+ "triggerLabelAll": "BRRR"
},
"announcement": {
"title": "Roo Code {{version}} 已發布",
From 9700eab792a23cf52f0f989cccb85b804fd10441 Mon Sep 17 00:00:00 2001
From: Hannes Rudolph
Date: Wed, 7 Jan 2026 09:03:44 -0700
Subject: [PATCH 08/21] feat: implement sticky provider profile for task-level
API config persistence (#10018)
---
packages/types/src/history.ts | 1 +
src/core/task-persistence/taskMetadata.ts | 4 +
src/core/task/Task.ts | 166 +++-
.../Task.sticky-profile-race.spec.ts | 142 +++
src/core/webview/ClineProvider.ts | 118 ++-
.../ClineProvider.sticky-profile.spec.ts | 883 ++++++++++++++++++
6 files changed, 1291 insertions(+), 23 deletions(-)
create mode 100644 src/core/task/__tests__/Task.sticky-profile-race.spec.ts
create mode 100644 src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts
index d97884d216..b4d84cb9a5 100644
--- a/packages/types/src/history.ts
+++ b/packages/types/src/history.ts
@@ -29,6 +29,7 @@ export const historyItemSchema = z.object({
* This ensures task resumption works correctly even when NTC settings change.
*/
toolProtocol: z.enum(["xml", "native"]).optional(),
+ apiConfigName: z.string().optional(), // Provider profile name for sticky profile feature
status: z.enum(["active", "completed", "delegated"]).optional(),
delegatedToId: z.string().optional(), // Last child this parent delegated to
childIds: z.array(z.string()).optional(), // All children spawned by this task
diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts
index eb872a6f7e..cf8d9adb52 100644
--- a/src/core/task-persistence/taskMetadata.ts
+++ b/src/core/task-persistence/taskMetadata.ts
@@ -21,6 +21,8 @@ export type TaskMetadataOptions = {
globalStoragePath: string
workspace: string
mode?: string
+ /** Provider profile name for the task (sticky profile feature) */
+ apiConfigName?: string
/** Initial status for the task (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
/**
@@ -39,6 +41,7 @@ export async function taskMetadata({
globalStoragePath,
workspace,
mode,
+ apiConfigName,
initialStatus,
toolProtocol,
}: TaskMetadataOptions) {
@@ -116,6 +119,7 @@ export async function taskMetadata({
workspace,
mode,
...(toolProtocol && { toolProtocol }),
+ ...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}),
...(initialStatus && { status: initialStatus }),
}
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index 3340ae5d6c..9e41d5c4dd 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -247,6 +247,49 @@ export class Task extends EventEmitter implements TaskLike {
*/
private taskModeReady: Promise
+ /**
+ * The API configuration name (provider profile) associated with this task.
+ * Persisted across sessions to maintain the provider profile when reopening tasks from history.
+ *
+ * ## Lifecycle
+ *
+ * ### For new tasks:
+ * 1. Initially `undefined` during construction
+ * 2. Asynchronously initialized from provider state via `initializeTaskApiConfigName()`
+ * 3. Falls back to "default" if provider state is unavailable
+ *
+ * ### For history items:
+ * 1. Immediately set from `historyItem.apiConfigName` during construction
+ * 2. Falls back to undefined if not stored in history (for backward compatibility)
+ *
+ * ## Important
+ * If you need a non-`undefined` provider profile (e.g., for profile-dependent operations),
+ * wait for `taskApiConfigReady` first (or use `getTaskApiConfigName()`).
+ * The sync `taskApiConfigName` getter may return `undefined` for backward compatibility.
+ *
+ * @private
+ * @see {@link getTaskApiConfigName} - For safe async access
+ * @see {@link taskApiConfigName} - For sync access after initialization
+ */
+ private _taskApiConfigName: string | undefined
+
+ /**
+ * Promise that resolves when the task API config name has been initialized.
+ * This ensures async API config name initialization completes before the task is used.
+ *
+ * ## Purpose
+ * - Prevents race conditions when accessing task API config name
+ * - Ensures provider state is properly loaded before profile-dependent operations
+ * - Provides a synchronization point for async initialization
+ *
+ * ## Resolution timing
+ * - For history items: Resolves immediately (sync initialization)
+ * - For new tasks: Resolves after provider state is fetched (async initialization)
+ *
+ * @private
+ */
+ private taskApiConfigReady: Promise
+
providerRef: WeakRef
private readonly globalStoragePath: string
abort: boolean = false
@@ -502,21 +545,25 @@ export class Task extends EventEmitter implements TaskLike {
this.taskNumber = taskNumber
this.initialStatus = initialStatus
- // Store the task's mode when it's created.
- // For history items, use the stored mode; for new tasks, we'll set it
+ // Store the task's mode and API config name when it's created.
+ // For history items, use the stored values; for new tasks, we'll set them
// after getting state.
if (historyItem) {
this._taskMode = historyItem.mode || defaultModeSlug
+ this._taskApiConfigName = historyItem.apiConfigName
this.taskModeReady = Promise.resolve()
+ this.taskApiConfigReady = Promise.resolve()
TelemetryService.instance.captureTaskRestarted(this.taskId)
// For history items, use the persisted tool protocol if available.
// If not available (old tasks), it will be detected in resumeTaskFromHistory.
this._taskToolProtocol = historyItem.toolProtocol
} else {
- // For new tasks, don't set the mode yet - wait for async initialization.
+ // For new tasks, don't set the mode/apiConfigName yet - wait for async initialization.
this._taskMode = undefined
+ this._taskApiConfigName = undefined
this.taskModeReady = this.initializeTaskMode(provider)
+ this.taskApiConfigReady = this.initializeTaskApiConfigName(provider)
TelemetryService.instance.captureTaskCreated(this.taskId)
// For new tasks, resolve and lock the tool protocol immediately.
@@ -639,6 +686,47 @@ export class Task extends EventEmitter implements TaskLike {
}
}
+ /**
+ * Initialize the task API config name from the provider state.
+ * This method handles async initialization with proper error handling.
+ *
+ * ## Flow
+ * 1. Attempts to fetch the current API config name from provider state
+ * 2. Sets `_taskApiConfigName` to the fetched name or "default" if unavailable
+ * 3. Handles errors gracefully by falling back to "default"
+ * 4. Logs any initialization errors for debugging
+ *
+ * ## Error handling
+ * - Network failures when fetching provider state
+ * - Provider not yet initialized
+ * - Invalid state structure
+ *
+ * All errors result in fallback to "default" to ensure task can proceed.
+ *
+ * @private
+ * @param provider - The ClineProvider instance to fetch state from
+ * @returns Promise that resolves when initialization is complete
+ */
+ private async initializeTaskApiConfigName(provider: ClineProvider): Promise {
+ try {
+ const state = await provider.getState()
+
+ // Avoid clobbering a newer value that may have been set while awaiting provider state
+ // (e.g., user switches provider profile immediately after task creation).
+ if (this._taskApiConfigName === undefined) {
+ this._taskApiConfigName = state?.currentApiConfigName ?? "default"
+ }
+ } catch (error) {
+ // If there's an error getting state, use the default profile (unless a newer value was set).
+ if (this._taskApiConfigName === undefined) {
+ this._taskApiConfigName = "default"
+ }
+ // Use the provider's log method for better error visibility
+ const errorMessage = `Failed to initialize task API config name: ${error instanceof Error ? error.message : String(error)}`
+ provider.log(errorMessage)
+ }
+ }
+
/**
* Sets up a listener for provider profile changes to automatically update the parser state.
* This ensures the XML/native protocol parser stays synchronized with the current model.
@@ -759,6 +847,73 @@ export class Task extends EventEmitter implements TaskLike {
return this._taskMode
}
+ /**
+ * Wait for the task API config name to be initialized before proceeding.
+ * This method ensures that any operations depending on the task's provider profile
+ * will have access to the correct value.
+ *
+ * ## When to use
+ * - Before accessing provider profile-specific configurations
+ * - When switching between tasks with different provider profiles
+ * - Before operations that depend on the provider profile
+ *
+ * @returns Promise that resolves when the task API config name is initialized
+ * @public
+ */
+ public async waitForApiConfigInitialization(): Promise {
+ return this.taskApiConfigReady
+ }
+
+ /**
+ * Get the task API config name asynchronously, ensuring it's properly initialized.
+ * This is the recommended way to access the task's provider profile as it guarantees
+ * the value is available before returning.
+ *
+ * ## Async behavior
+ * - Internally waits for `taskApiConfigReady` promise to resolve
+ * - Returns the initialized API config name or undefined as fallback
+ * - Safe to call multiple times - subsequent calls return immediately if already initialized
+ *
+ * @returns Promise resolving to the task API config name string or undefined
+ * @public
+ */
+ public async getTaskApiConfigName(): Promise {
+ await this.taskApiConfigReady
+ return this._taskApiConfigName
+ }
+
+ /**
+ * Get the task API config name synchronously. This should only be used when you're certain
+ * that the value has already been initialized (e.g., after waitForApiConfigInitialization).
+ *
+ * ## When to use
+ * - In synchronous contexts where async/await is not available
+ * - After explicitly waiting for initialization via `waitForApiConfigInitialization()`
+ * - In event handlers or callbacks where API config name is guaranteed to be initialized
+ *
+ * Note: Unlike taskMode, this getter does not throw if uninitialized since the API config
+ * name can legitimately be undefined (backward compatibility with tasks created before
+ * this feature was added).
+ *
+ * @returns The task API config name string or undefined
+ * @public
+ */
+ public get taskApiConfigName(): string | undefined {
+ return this._taskApiConfigName
+ }
+
+ /**
+ * Update the task's API config name. This is called when the user switches
+ * provider profiles while a task is active, allowing the task to remember
+ * its new provider profile.
+ *
+ * @param apiConfigName - The new API config name to set
+ * @internal
+ */
+ public setTaskApiConfigName(apiConfigName: string | undefined): void {
+ this._taskApiConfigName = apiConfigName
+ }
+
static create(options: TaskOptions): [Task, Promise] {
const instance = new Task({ ...options, startTask: false })
const { images, task, historyItem } = options
@@ -1027,6 +1182,10 @@ export class Task extends EventEmitter implements TaskLike {
globalStoragePath: this.globalStoragePath,
})
+ if (this._taskApiConfigName === undefined) {
+ await this.taskApiConfigReady
+ }
+
const { historyItem, tokenUsage } = await taskMetadata({
taskId: this.taskId,
rootTaskId: this.rootTaskId,
@@ -1036,6 +1195,7 @@ export class Task extends EventEmitter implements TaskLike {
globalStoragePath: this.globalStoragePath,
workspace: this.cwd,
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode.
+ apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile.
initialStatus: this.initialStatus,
toolProtocol: this._taskToolProtocol, // Persist the locked tool protocol.
})
diff --git a/src/core/task/__tests__/Task.sticky-profile-race.spec.ts b/src/core/task/__tests__/Task.sticky-profile-race.spec.ts
new file mode 100644
index 0000000000..e78301541d
--- /dev/null
+++ b/src/core/task/__tests__/Task.sticky-profile-race.spec.ts
@@ -0,0 +1,142 @@
+// npx vitest run core/task/__tests__/Task.sticky-profile-race.spec.ts
+
+import * as vscode from "vscode"
+
+import type { ProviderSettings } from "@roo-code/types"
+import { Task } from "../Task"
+import { ClineProvider } from "../../webview/ClineProvider"
+
+vi.mock("@roo-code/telemetry", () => ({
+ TelemetryService: {
+ hasInstance: vi.fn().mockReturnValue(true),
+ createInstance: vi.fn(),
+ get instance() {
+ return {
+ captureTaskCreated: vi.fn(),
+ captureTaskRestarted: vi.fn(),
+ captureModeSwitch: vi.fn(),
+ captureConversationMessage: vi.fn(),
+ captureLlmCompletion: vi.fn(),
+ captureConsecutiveMistakeError: vi.fn(),
+ captureCodeActionUsed: vi.fn(),
+ setProvider: vi.fn(),
+ }
+ },
+ },
+}))
+
+vi.mock("vscode", () => {
+ const mockDisposable = { dispose: vi.fn() }
+ const mockEventEmitter = { event: vi.fn(), fire: vi.fn() }
+ const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } }
+ const mockTextEditor = { document: mockTextDocument }
+ const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } }
+ const mockTabGroup = { tabs: [mockTab] }
+
+ return {
+ TabInputTextDiff: vi.fn(),
+ CodeActionKind: {
+ QuickFix: { value: "quickfix" },
+ RefactorRewrite: { value: "refactor.rewrite" },
+ },
+ window: {
+ createTextEditorDecorationType: vi.fn().mockReturnValue({
+ dispose: vi.fn(),
+ }),
+ visibleTextEditors: [mockTextEditor],
+ tabGroups: {
+ all: [mockTabGroup],
+ close: vi.fn(),
+ onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })),
+ },
+ showErrorMessage: vi.fn(),
+ },
+ workspace: {
+ getConfiguration: vi.fn(() => ({ get: (_k: string, d: any) => d })),
+ workspaceFolders: [
+ {
+ uri: { fsPath: "/mock/workspace/path" },
+ name: "mock-workspace",
+ index: 0,
+ },
+ ],
+ createFileSystemWatcher: vi.fn(() => ({
+ onDidCreate: vi.fn(() => mockDisposable),
+ onDidDelete: vi.fn(() => mockDisposable),
+ onDidChange: vi.fn(() => mockDisposable),
+ dispose: vi.fn(),
+ })),
+ fs: {
+ stat: vi.fn().mockResolvedValue({ type: 1 }),
+ },
+ onDidSaveTextDocument: vi.fn(() => mockDisposable),
+ },
+ env: {
+ uriScheme: "vscode",
+ language: "en",
+ },
+ EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter),
+ Disposable: {
+ from: vi.fn(),
+ },
+ TabInputText: vi.fn(),
+ version: "1.85.0",
+ }
+})
+
+vi.mock("../../environment/getEnvironmentDetails", () => ({
+ getEnvironmentDetails: vi.fn().mockResolvedValue(""),
+}))
+
+vi.mock("../../ignore/RooIgnoreController")
+
+vi.mock("p-wait-for", () => ({
+ default: vi.fn().mockImplementation(async () => Promise.resolve()),
+}))
+
+vi.mock("delay", () => ({
+ __esModule: true,
+ default: vi.fn().mockResolvedValue(undefined),
+}))
+
+describe("Task - sticky provider profile init race", () => {
+ it("does not overwrite task apiConfigName if set during async initialization", async () => {
+ const apiConfig: ProviderSettings = {
+ apiProvider: "anthropic",
+ apiModelId: "claude-3-5-sonnet-20241022",
+ apiKey: "test-api-key",
+ } as any
+
+ let resolveGetState: ((v: any) => void) | undefined
+ const getStatePromise = new Promise((resolve) => {
+ resolveGetState = resolve
+ })
+
+ const mockProvider = {
+ context: {
+ globalStorageUri: { fsPath: "/test/storage" },
+ },
+ getState: vi.fn().mockImplementation(() => getStatePromise),
+ log: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ postStateToWebview: vi.fn().mockResolvedValue(undefined),
+ updateTaskHistory: vi.fn().mockResolvedValue(undefined),
+ } as unknown as ClineProvider
+
+ const task = new Task({
+ provider: mockProvider,
+ apiConfiguration: apiConfig,
+ task: "test task",
+ startTask: false,
+ })
+
+ // Simulate a profile switch happening before provider.getState resolves.
+ task.setTaskApiConfigName("new-profile")
+
+ resolveGetState?.({ currentApiConfigName: "old-profile" })
+ await task.waitForApiConfigInitialization()
+
+ expect(task.taskApiConfigName).toBe("new-profile")
+ })
+})
diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts
index a34fb817ee..6153af6160 100644
--- a/src/core/webview/ClineProvider.ts
+++ b/src/core/webview/ClineProvider.ts
@@ -890,32 +890,67 @@ export class ClineProvider
await this.updateGlobalState("mode", historyItem.mode)
// Load the saved API config for the restored mode if it exists.
- const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
- const listApiConfig = await this.providerSettingsManager.listConfig()
+ // Skip mode-based profile activation if historyItem.apiConfigName exists,
+ // since the task's specific provider profile will override it anyway.
+ if (!historyItem.apiConfigName) {
+ const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode)
+ const listApiConfig = await this.providerSettingsManager.listConfig()
- // Update listApiConfigMeta first to ensure UI has latest data.
- await this.updateGlobalState("listApiConfigMeta", listApiConfig)
+ // Update listApiConfigMeta first to ensure UI has latest data.
+ await this.updateGlobalState("listApiConfigMeta", listApiConfig)
- // If this mode has a saved config, use it.
- if (savedConfigId) {
- const profile = listApiConfig.find(({ id }) => id === savedConfigId)
+ // If this mode has a saved config, use it.
+ if (savedConfigId) {
+ const profile = listApiConfig.find(({ id }) => id === savedConfigId)
- if (profile?.name) {
- try {
- await this.activateProviderProfile({ name: profile.name })
- } catch (error) {
- // Log the error but continue with task restoration.
- this.log(
- `Failed to restore API configuration for mode '${historyItem.mode}': ${
- error instanceof Error ? error.message : String(error)
- }. Continuing with default configuration.`,
- )
- // The task will continue with the current/default configuration.
+ if (profile?.name) {
+ try {
+ await this.activateProviderProfile({ name: profile.name })
+ } catch (error) {
+ // Log the error but continue with task restoration.
+ this.log(
+ `Failed to restore API configuration for mode '${historyItem.mode}': ${
+ error instanceof Error ? error.message : String(error)
+ }. Continuing with default configuration.`,
+ )
+ // The task will continue with the current/default configuration.
+ }
}
}
}
}
+ // If the history item has a saved API config name (provider profile), restore it.
+ // This overrides any mode-based config restoration above, because the task's
+ // specific provider profile takes precedence over mode defaults.
+ if (historyItem.apiConfigName) {
+ const listApiConfig = await this.providerSettingsManager.listConfig()
+ // Keep global state/UI in sync with latest profiles for parity with mode restoration above.
+ await this.updateGlobalState("listApiConfigMeta", listApiConfig)
+ const profile = listApiConfig.find(({ name }) => name === historyItem.apiConfigName)
+
+ if (profile?.name) {
+ try {
+ await this.activateProviderProfile(
+ { name: profile.name },
+ { persistModeConfig: false, persistTaskHistory: false },
+ )
+ } catch (error) {
+ // Log the error but continue with task restoration.
+ this.log(
+ `Failed to restore API configuration '${historyItem.apiConfigName}' for task: ${
+ error instanceof Error ? error.message : String(error)
+ }. Continuing with current configuration.`,
+ )
+ }
+ } else {
+ // Profile no longer exists, log warning but continue
+ this.log(
+ `Provider profile '${historyItem.apiConfigName}' from history no longer exists. Using current configuration.`,
+ )
+ }
+ }
+
const {
apiConfiguration,
diffEnabled: enableDiff,
@@ -1399,6 +1434,9 @@ export class ClineProvider
// Change the provider for the current task.
// TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`).
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
+
+ // Keep the current task's sticky provider profile in sync with the newly-activated profile.
+ await this.persistStickyProviderProfileToCurrentTask(name)
} else {
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
}
@@ -1438,9 +1476,42 @@ export class ClineProvider
await this.postStateToWebview()
}
- async activateProviderProfile(args: { name: string } | { id: string }) {
+ private async persistStickyProviderProfileToCurrentTask(apiConfigName: string): Promise {
+ const task = this.getCurrentTask()
+ if (!task) {
+ return
+ }
+
+ try {
+ // Update in-memory state immediately so sticky behavior works even before the task has
+ // been persisted into taskHistory (it will be captured on the next save).
+ task.setTaskApiConfigName(apiConfigName)
+
+ const history = this.getGlobalState("taskHistory") ?? []
+ const taskHistoryItem = history.find((item) => item.id === task.taskId)
+
+ if (taskHistoryItem) {
+ await this.updateTaskHistory({ ...taskHistoryItem, apiConfigName })
+ }
+ } catch (error) {
+ // If persistence fails, log the error but don't fail the profile switch.
+ this.log(
+ `Failed to persist provider profile switch for task ${task.taskId}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ )
+ }
+ }
+
+ async activateProviderProfile(
+ args: { name: string } | { id: string },
+ options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean },
+ ) {
const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args)
+ const persistModeConfig = options?.persistModeConfig ?? true
+ const persistTaskHistory = options?.persistTaskHistory ?? true
+
// See `upsertProviderProfile` for a description of what this is doing.
await Promise.all([
this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
@@ -1450,12 +1521,19 @@ export class ClineProvider
const { mode } = await this.getState()
- if (id) {
+ if (id && persistModeConfig) {
await this.providerSettingsManager.setModeConfig(mode, id)
}
+
// Change the provider for the current task.
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
+ // Update the current task's sticky provider profile, unless this activation is
+ // being used purely as a non-persisting restoration (e.g., reopening a task from history).
+ if (persistTaskHistory) {
+ await this.persistStickyProviderProfileToCurrentTask(name)
+ }
+
await this.postStateToWebview()
if (providerSettings.apiProvider) {
diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
new file mode 100644
index 0000000000..3df4408b71
--- /dev/null
+++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
@@ -0,0 +1,883 @@
+// npx vitest run core/webview/__tests__/ClineProvider.sticky-profile.spec.ts
+
+import * as vscode from "vscode"
+import { TelemetryService } from "@roo-code/telemetry"
+import { ClineProvider } from "../ClineProvider"
+import { ContextProxy } from "../../config/ContextProxy"
+import type { HistoryItem } from "@roo-code/types"
+
+vi.mock("vscode", () => ({
+ ExtensionContext: vi.fn(),
+ OutputChannel: vi.fn(),
+ WebviewView: vi.fn(),
+ Uri: {
+ joinPath: vi.fn(),
+ file: vi.fn(),
+ },
+ CodeActionKind: {
+ QuickFix: { value: "quickfix" },
+ RefactorRewrite: { value: "refactor.rewrite" },
+ },
+ commands: {
+ executeCommand: vi.fn().mockResolvedValue(undefined),
+ },
+ window: {
+ showInformationMessage: vi.fn(),
+ showWarningMessage: vi.fn(),
+ showErrorMessage: vi.fn(),
+ onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
+ },
+ workspace: {
+ getConfiguration: vi.fn().mockReturnValue({
+ get: vi.fn().mockReturnValue([]),
+ update: vi.fn(),
+ }),
+ onDidChangeConfiguration: vi.fn().mockImplementation(() => ({
+ dispose: vi.fn(),
+ })),
+ onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
+ onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
+ onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
+ onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
+ },
+ env: {
+ uriScheme: "vscode",
+ language: "en",
+ appName: "Visual Studio Code",
+ },
+ ExtensionMode: {
+ Production: 1,
+ Development: 2,
+ Test: 3,
+ },
+ version: "1.85.0",
+}))
+
+// Create a counter for unique task IDs.
+let taskIdCounter = 0
+
+vi.mock("../../task/Task", () => ({
+ Task: vi.fn().mockImplementation((options) => ({
+ taskId: options.taskId || `test-task-id-${++taskIdCounter}`,
+ saveClineMessages: vi.fn(),
+ clineMessages: [],
+ apiConversationHistory: [],
+ overwriteClineMessages: vi.fn(),
+ overwriteApiConversationHistory: vi.fn(),
+ abortTask: vi.fn(),
+ handleWebviewAskResponse: vi.fn(),
+ getTaskNumber: vi.fn().mockReturnValue(0),
+ setTaskNumber: vi.fn(),
+ setParentTask: vi.fn(),
+ setRootTask: vi.fn(),
+ emit: vi.fn(),
+ parentTask: options.parentTask,
+ updateApiConfiguration: vi.fn(),
+ setTaskApiConfigName: vi.fn(),
+ _taskApiConfigName: options.historyItem?.apiConfigName,
+ taskApiConfigName: options.historyItem?.apiConfigName,
+ })),
+}))
+
+vi.mock("../../prompts/sections/custom-instructions")
+
+vi.mock("../../../utils/safeWriteJson")
+
+vi.mock("../../../api", () => ({
+ buildApiHandler: vi.fn().mockReturnValue({
+ getModel: vi.fn().mockReturnValue({
+ id: "claude-3-sonnet",
+ }),
+ }),
+}))
+
+vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({
+ default: vi.fn().mockImplementation(() => ({
+ initializeFilePaths: vi.fn(),
+ dispose: vi.fn(),
+ })),
+}))
+
+vi.mock("../../diff/strategies/multi-search-replace", () => ({
+ MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({
+ getToolDescription: () => "test",
+ getName: () => "test-strategy",
+ applyDiff: vi.fn(),
+ })),
+}))
+
+vi.mock("@roo-code/cloud", () => ({
+ CloudService: {
+ hasInstance: vi.fn().mockReturnValue(true),
+ get instance() {
+ return {
+ isAuthenticated: vi.fn().mockReturnValue(false),
+ }
+ },
+ },
+ BridgeOrchestrator: {
+ isEnabled: vi.fn().mockReturnValue(false),
+ },
+ getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
+}))
+
+vi.mock("../../../shared/modes", () => ({
+ modes: [
+ {
+ slug: "code",
+ name: "Code Mode",
+ roleDefinition: "You are a code assistant",
+ groups: ["read", "edit", "browser"],
+ },
+ {
+ slug: "architect",
+ name: "Architect Mode",
+ roleDefinition: "You are an architect",
+ groups: ["read", "edit"],
+ },
+ ],
+ getModeBySlug: vi.fn().mockReturnValue({
+ slug: "code",
+ name: "Code Mode",
+ roleDefinition: "You are a code assistant",
+ groups: ["read", "edit", "browser"],
+ }),
+ defaultModeSlug: "code",
+}))
+
+vi.mock("../../prompts/system", () => ({
+ SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"),
+ codeMode: "code",
+}))
+
+vi.mock("../../../api/providers/fetchers/modelCache", () => ({
+ getModels: vi.fn().mockResolvedValue({}),
+ flushModels: vi.fn(),
+}))
+
+vi.mock("../../../integrations/misc/extract-text", () => ({
+ extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"),
+}))
+
+vi.mock("p-wait-for", () => ({
+ default: vi.fn().mockImplementation(async () => Promise.resolve()),
+}))
+
+vi.mock("fs/promises", () => ({
+ mkdir: vi.fn().mockResolvedValue(undefined),
+ writeFile: vi.fn().mockResolvedValue(undefined),
+ readFile: vi.fn().mockResolvedValue(""),
+ unlink: vi.fn().mockResolvedValue(undefined),
+ rmdir: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock("@roo-code/telemetry", () => ({
+ TelemetryService: {
+ hasInstance: vi.fn().mockReturnValue(true),
+ createInstance: vi.fn(),
+ get instance() {
+ return {
+ trackEvent: vi.fn(),
+ trackError: vi.fn(),
+ setProvider: vi.fn(),
+ captureModeSwitch: vi.fn(),
+ }
+ },
+ },
+}))
+
+describe("ClineProvider - Sticky Provider Profile", () => {
+ let provider: ClineProvider
+ let mockContext: vscode.ExtensionContext
+ let mockOutputChannel: vscode.OutputChannel
+ let mockWebviewView: vscode.WebviewView
+ let mockPostMessage: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ taskIdCounter = 0
+
+ if (!TelemetryService.hasInstance()) {
+ TelemetryService.createInstance([])
+ }
+
+ const globalState: Record = {
+ mode: "code",
+ currentApiConfigName: "default-profile",
+ }
+
+ const secrets: Record = {}
+
+ mockContext = {
+ extensionPath: "/test/path",
+ extensionUri: {} as vscode.Uri,
+ globalState: {
+ get: vi.fn().mockImplementation((key: string) => globalState[key]),
+ update: vi.fn().mockImplementation((key: string, value: string | undefined) => {
+ globalState[key] = value
+ return Promise.resolve()
+ }),
+ keys: vi.fn().mockImplementation(() => Object.keys(globalState)),
+ },
+ secrets: {
+ get: vi.fn().mockImplementation((key: string) => secrets[key]),
+ store: vi.fn().mockImplementation((key: string, value: string | undefined) => {
+ secrets[key] = value
+ return Promise.resolve()
+ }),
+ delete: vi.fn().mockImplementation((key: string) => {
+ delete secrets[key]
+ return Promise.resolve()
+ }),
+ },
+ subscriptions: [],
+ extension: {
+ packageJSON: { version: "1.0.0" },
+ },
+ globalStorageUri: {
+ fsPath: "/test/storage/path",
+ },
+ } as unknown as vscode.ExtensionContext
+
+ mockOutputChannel = {
+ appendLine: vi.fn(),
+ clear: vi.fn(),
+ dispose: vi.fn(),
+ } as unknown as vscode.OutputChannel
+
+ mockPostMessage = vi.fn()
+
+ mockWebviewView = {
+ webview: {
+ postMessage: mockPostMessage,
+ html: "",
+ options: {},
+ onDidReceiveMessage: vi.fn(),
+ asWebviewUri: vi.fn(),
+ cspSource: "vscode-webview://test-csp-source",
+ },
+ visible: true,
+ onDidDispose: vi.fn().mockImplementation((callback) => {
+ callback()
+ return { dispose: vi.fn() }
+ }),
+ onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })),
+ } as unknown as vscode.WebviewView
+
+ provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
+
+ // Mock getMcpHub method
+ provider.getMcpHub = vi.fn().mockReturnValue({
+ listTools: vi.fn().mockResolvedValue([]),
+ callTool: vi.fn().mockResolvedValue({ content: [] }),
+ listResources: vi.fn().mockResolvedValue([]),
+ readResource: vi.fn().mockResolvedValue({ contents: [] }),
+ getAllServers: vi.fn().mockReturnValue([]),
+ })
+ })
+
+ describe("activateProviderProfile", () => {
+ beforeEach(async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+ })
+
+ it("should save provider profile to task metadata when switching profiles", async () => {
+ // Create a mock task
+ const mockTask = {
+ taskId: "test-task-id",
+ _taskApiConfigName: "default-profile",
+ setTaskApiConfigName: vi.fn(),
+ emit: vi.fn(),
+ saveClineMessages: vi.fn(),
+ clineMessages: [],
+ apiConversationHistory: [],
+ updateApiConfiguration: vi.fn(),
+ }
+
+ // Add task to provider stack
+ await provider.addClineToStack(mockTask as any)
+
+ // Mock getGlobalState to return task history
+ vi.spyOn(provider as any, "getGlobalState").mockReturnValue([
+ {
+ id: mockTask.taskId,
+ ts: Date.now(),
+ task: "Test task",
+ number: 1,
+ tokensIn: 0,
+ tokensOut: 0,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0,
+ },
+ ])
+
+ // Mock updateTaskHistory to track calls
+ const updateTaskHistorySpy = vi
+ .spyOn(provider, "updateTaskHistory")
+ .mockImplementation(() => Promise.resolve([]))
+
+ // Mock providerSettingsManager.activateProfile
+ vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
+ name: "new-profile",
+ id: "new-profile-id",
+ apiProvider: "anthropic",
+ })
+
+ // Mock providerSettingsManager.listConfig
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" },
+ ])
+
+ // Switch provider profile
+ await provider.activateProviderProfile({ name: "new-profile" })
+
+ // Verify task history was updated with new provider profile
+ expect(updateTaskHistorySpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: mockTask.taskId,
+ apiConfigName: "new-profile",
+ }),
+ )
+
+ // Verify task's setTaskApiConfigName was called
+ expect(mockTask.setTaskApiConfigName).toHaveBeenCalledWith("new-profile")
+ })
+
+ it("should update task's taskApiConfigName property when switching profiles", async () => {
+ // Create a mock task with initial profile
+ const mockTask = {
+ taskId: "test-task-id",
+ _taskApiConfigName: "default-profile",
+ setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) {
+ this._taskApiConfigName = name
+ }),
+ emit: vi.fn(),
+ saveClineMessages: vi.fn(),
+ clineMessages: [],
+ apiConversationHistory: [],
+ updateApiConfiguration: vi.fn(),
+ }
+
+ // Add task to provider stack
+ await provider.addClineToStack(mockTask as any)
+
+ // Mock getGlobalState to return task history
+ vi.spyOn(provider as any, "getGlobalState").mockReturnValue([
+ {
+ id: mockTask.taskId,
+ ts: Date.now(),
+ task: "Test task",
+ number: 1,
+ tokensIn: 0,
+ tokensOut: 0,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0,
+ },
+ ])
+
+ // Mock updateTaskHistory
+ vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => Promise.resolve([]))
+
+ // Mock providerSettingsManager.activateProfile
+ vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
+ name: "new-profile",
+ id: "new-profile-id",
+ apiProvider: "openrouter",
+ })
+
+ // Mock providerSettingsManager.listConfig
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "new-profile", id: "new-profile-id", apiProvider: "openrouter" },
+ ])
+
+ // Switch provider profile
+ await provider.activateProviderProfile({ name: "new-profile" })
+
+ // Verify task's _taskApiConfigName property was updated
+ expect(mockTask._taskApiConfigName).toBe("new-profile")
+ })
+
+ it("should update in-memory task profile even if task history item does not exist yet", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ const mockTask = {
+ taskId: "test-task-id",
+ _taskApiConfigName: "default-profile",
+ setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) {
+ this._taskApiConfigName = name
+ }),
+ emit: vi.fn(),
+ saveClineMessages: vi.fn(),
+ clineMessages: [],
+ apiConversationHistory: [],
+ updateApiConfiguration: vi.fn(),
+ }
+
+ await provider.addClineToStack(mockTask as any)
+
+ // No history item exists yet
+ vi.spyOn(provider as any, "getGlobalState").mockReturnValue([])
+
+ const updateTaskHistorySpy = vi
+ .spyOn(provider, "updateTaskHistory")
+ .mockImplementation(() => Promise.resolve([]))
+
+ vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
+ name: "new-profile",
+ id: "new-profile-id",
+ apiProvider: "openrouter",
+ })
+
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "new-profile", id: "new-profile-id", apiProvider: "openrouter" },
+ ])
+
+ await provider.activateProviderProfile({ name: "new-profile" })
+
+ // In-memory should still update, even without a history item.
+ expect(mockTask._taskApiConfigName).toBe("new-profile")
+ // No history item => no updateTaskHistory call.
+ expect(updateTaskHistorySpy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe("createTaskWithHistoryItem", () => {
+ it("should restore provider profile from history item when reopening task", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ // Create a history item with saved provider profile
+ const historyItem: HistoryItem = {
+ id: "test-task-id",
+ number: 1,
+ ts: Date.now(),
+ task: "Test task",
+ tokensIn: 100,
+ tokensOut: 200,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0.001,
+ mode: "code",
+ apiConfigName: "saved-profile", // Saved provider profile
+ }
+
+ // Mock activateProviderProfile to track calls
+ const activateProviderProfileSpy = vi
+ .spyOn(provider, "activateProviderProfile")
+ .mockResolvedValue(undefined)
+
+ // Mock providerSettingsManager.listConfig
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "saved-profile", id: "saved-profile-id", apiProvider: "anthropic" },
+ ])
+
+ // Initialize task with history item
+ await provider.createTaskWithHistoryItem(historyItem)
+
+ // Verify provider profile was restored via activateProviderProfile (restore-only: don't persist mode config)
+ expect(activateProviderProfileSpy).toHaveBeenCalledWith(
+ { name: "saved-profile" },
+ { persistModeConfig: false, persistTaskHistory: false },
+ )
+ })
+
+ it("should use current profile if history item has no saved apiConfigName", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ // Create a history item without saved provider profile
+ const historyItem: HistoryItem = {
+ id: "test-task-id",
+ number: 1,
+ ts: Date.now(),
+ task: "Test task",
+ tokensIn: 100,
+ tokensOut: 200,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0.001,
+ // No apiConfigName field
+ }
+
+ // Mock activateProviderProfile to track calls
+ const activateProviderProfileSpy = vi
+ .spyOn(provider, "activateProviderProfile")
+ .mockResolvedValue(undefined)
+
+ // Initialize task with history item
+ await provider.createTaskWithHistoryItem(historyItem)
+
+ // Verify activateProviderProfile was NOT called for apiConfigName restoration
+ // (it might be called for mode-based config, but not for direct apiConfigName)
+ const callsForApiConfigName = activateProviderProfileSpy.mock.calls.filter(
+ (call) => call[0] && "name" in call[0] && call[0].name === historyItem.apiConfigName,
+ )
+ expect(callsForApiConfigName.length).toBe(0)
+ })
+
+ it("should override mode-based config with task's apiConfigName", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ // Create a history item with both mode and apiConfigName
+ const historyItem: HistoryItem = {
+ id: "test-task-id",
+ number: 1,
+ ts: Date.now(),
+ task: "Test task",
+ tokensIn: 100,
+ tokensOut: 200,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0.001,
+ mode: "architect", // Mode has a different preferred profile
+ apiConfigName: "task-specific-profile", // Task's actual profile
+ }
+
+ // Track all activateProviderProfile calls
+ const activateCalls: string[] = []
+ vi.spyOn(provider, "activateProviderProfile").mockImplementation(async (args) => {
+ if ("name" in args) {
+ activateCalls.push(args.name)
+ }
+ })
+
+ // Mock providerSettingsManager methods
+ vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("mode-config-id")
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "mode-preferred-profile", id: "mode-config-id", apiProvider: "anthropic" },
+ { name: "task-specific-profile", id: "task-profile-id", apiProvider: "openai" },
+ ])
+
+ // Initialize task with history item
+ await provider.createTaskWithHistoryItem(historyItem)
+
+ // Verify task's apiConfigName was activated LAST (overriding mode-based config)
+ expect(activateCalls[activateCalls.length - 1]).toBe("task-specific-profile")
+ })
+
+ it("should handle missing provider profile gracefully", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ // Create a history item with a provider profile that no longer exists
+ const historyItem: HistoryItem = {
+ id: "test-task-id",
+ number: 1,
+ ts: Date.now(),
+ task: "Test task",
+ tokensIn: 100,
+ tokensOut: 200,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0.001,
+ apiConfigName: "deleted-profile", // Profile that doesn't exist
+ }
+
+ // Mock providerSettingsManager.listConfig to return empty (profile doesn't exist)
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([])
+
+ // Mock log to verify warning is logged
+ const logSpy = vi.spyOn(provider, "log")
+
+ // Initialize task with history item - should not throw
+ await expect(provider.createTaskWithHistoryItem(historyItem)).resolves.not.toThrow()
+
+ // Verify a warning was logged
+ expect(logSpy).toHaveBeenCalledWith(
+ expect.stringContaining("Provider profile 'deleted-profile' from history no longer exists"),
+ )
+ })
+ })
+
+ describe("Task metadata persistence", () => {
+ it("should include apiConfigName in task metadata when saving", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ // Create a mock task with provider profile
+ const mockTask = {
+ taskId: "test-task-id",
+ _taskApiConfigName: "test-profile",
+ setTaskApiConfigName: vi.fn(),
+ emit: vi.fn(),
+ saveClineMessages: vi.fn(),
+ clineMessages: [],
+ apiConversationHistory: [],
+ updateApiConfiguration: vi.fn(),
+ }
+
+ // Mock getGlobalState to return task history with our task
+ vi.spyOn(provider as any, "getGlobalState").mockReturnValue([
+ {
+ id: mockTask.taskId,
+ ts: Date.now(),
+ task: "Test task",
+ number: 1,
+ tokensIn: 0,
+ tokensOut: 0,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0,
+ },
+ ])
+
+ // Mock updateTaskHistory to capture the updated history item
+ let updatedHistoryItem: any
+ vi.spyOn(provider, "updateTaskHistory").mockImplementation((item) => {
+ updatedHistoryItem = item
+ return Promise.resolve([item])
+ })
+
+ // Add task to provider stack
+ await provider.addClineToStack(mockTask as any)
+
+ // Mock providerSettingsManager.activateProfile
+ vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
+ name: "new-profile",
+ id: "new-profile-id",
+ apiProvider: "anthropic",
+ })
+
+ // Mock providerSettingsManager.listConfig
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" },
+ ])
+
+ // Trigger a profile switch
+ await provider.activateProviderProfile({ name: "new-profile" })
+
+ // Verify apiConfigName was included in the updated history item
+ expect(updatedHistoryItem).toBeDefined()
+ expect(updatedHistoryItem.apiConfigName).toBe("new-profile")
+ })
+ })
+
+ describe("Multiple workspaces isolation", () => {
+ it("should preserve task profile when switching profiles in another workspace", async () => {
+ // This test verifies that each task retains its designated provider profile
+ // so that switching profiles in one workspace doesn't alter other tasks
+
+ await provider.resolveWebviewView(mockWebviewView)
+
+ // Create task 1 with profile A
+ const task1 = {
+ taskId: "task-1",
+ _taskApiConfigName: "profile-a",
+ setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) {
+ this._taskApiConfigName = name
+ }),
+ emit: vi.fn(),
+ saveClineMessages: vi.fn(),
+ clineMessages: [],
+ apiConversationHistory: [],
+ updateApiConfiguration: vi.fn(),
+ }
+
+ // Create task 2 with profile B
+ const task2 = {
+ taskId: "task-2",
+ _taskApiConfigName: "profile-b",
+ setTaskApiConfigName: vi.fn().mockImplementation(function (this: any, name: string) {
+ this._taskApiConfigName = name
+ }),
+ emit: vi.fn(),
+ saveClineMessages: vi.fn(),
+ clineMessages: [],
+ apiConversationHistory: [],
+ updateApiConfiguration: vi.fn(),
+ }
+
+ // Add task 1 to stack
+ await provider.addClineToStack(task1 as any)
+
+ // Mock getGlobalState to return task history for both tasks
+ const taskHistory = [
+ {
+ id: "task-1",
+ ts: Date.now(),
+ task: "Task 1",
+ number: 1,
+ tokensIn: 0,
+ tokensOut: 0,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0,
+ apiConfigName: "profile-a",
+ },
+ {
+ id: "task-2",
+ ts: Date.now(),
+ task: "Task 2",
+ number: 2,
+ tokensIn: 0,
+ tokensOut: 0,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0,
+ apiConfigName: "profile-b",
+ },
+ ]
+
+ vi.spyOn(provider as any, "getGlobalState").mockReturnValue(taskHistory)
+
+ // Mock updateTaskHistory
+ vi.spyOn(provider, "updateTaskHistory").mockImplementation((item) => {
+ const index = taskHistory.findIndex((h) => h.id === item.id)
+ if (index >= 0) {
+ taskHistory[index] = { ...taskHistory[index], ...item }
+ }
+ return Promise.resolve(taskHistory)
+ })
+
+ // Mock providerSettingsManager.activateProfile
+ vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
+ name: "profile-c",
+ id: "profile-c-id",
+ apiProvider: "anthropic",
+ })
+
+ // Mock providerSettingsManager.listConfig
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "profile-a", id: "profile-a-id", apiProvider: "anthropic" },
+ { name: "profile-b", id: "profile-b-id", apiProvider: "openai" },
+ { name: "profile-c", id: "profile-c-id", apiProvider: "anthropic" },
+ ])
+
+ // Switch task 1's profile to profile C
+ await provider.activateProviderProfile({ name: "profile-c" })
+
+ // Verify task 1's profile was updated
+ expect(task1._taskApiConfigName).toBe("profile-c")
+ expect(taskHistory[0].apiConfigName).toBe("profile-c")
+
+ // Verify task 2's profile remains unchanged
+ expect(taskHistory[1].apiConfigName).toBe("profile-b")
+ })
+ })
+
+ describe("Error handling", () => {
+ it("should handle errors gracefully when saving profile fails", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ // Create a mock task
+ const mockTask = {
+ taskId: "test-task-id",
+ _taskApiConfigName: "default-profile",
+ setTaskApiConfigName: vi.fn(),
+ emit: vi.fn(),
+ saveClineMessages: vi.fn(),
+ clineMessages: [],
+ apiConversationHistory: [],
+ updateApiConfiguration: vi.fn(),
+ }
+
+ // Add task to provider stack
+ await provider.addClineToStack(mockTask as any)
+
+ // Mock getGlobalState
+ vi.spyOn(provider as any, "getGlobalState").mockReturnValue([
+ {
+ id: mockTask.taskId,
+ ts: Date.now(),
+ task: "Test task",
+ number: 1,
+ tokensIn: 0,
+ tokensOut: 0,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0,
+ },
+ ])
+
+ // Mock updateTaskHistory to throw error
+ vi.spyOn(provider, "updateTaskHistory").mockRejectedValue(new Error("Save failed"))
+
+ // Mock providerSettingsManager.activateProfile
+ vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({
+ name: "new-profile",
+ id: "new-profile-id",
+ apiProvider: "anthropic",
+ })
+
+ // Mock providerSettingsManager.listConfig
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "new-profile", id: "new-profile-id", apiProvider: "anthropic" },
+ ])
+
+ // Mock log to verify error is logged
+ const logSpy = vi.spyOn(provider, "log")
+
+ // Switch provider profile - should not throw
+ await expect(provider.activateProviderProfile({ name: "new-profile" })).resolves.not.toThrow()
+
+ // Verify error was logged
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to persist provider profile switch"))
+ })
+
+ it("should handle null/undefined apiConfigName gracefully", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ // Create a history item with null apiConfigName
+ const historyItem: HistoryItem = {
+ id: "test-task-id",
+ number: 1,
+ ts: Date.now(),
+ task: "Test task",
+ tokensIn: 100,
+ tokensOut: 200,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0.001,
+ apiConfigName: null as any, // Invalid apiConfigName
+ }
+
+ // Mock activateProviderProfile to track calls
+ const activateProviderProfileSpy = vi
+ .spyOn(provider, "activateProviderProfile")
+ .mockResolvedValue(undefined)
+
+ // Initialize task with history item - should not throw
+ await expect(provider.createTaskWithHistoryItem(historyItem)).resolves.not.toThrow()
+
+ // Verify activateProviderProfile was not called with null
+ expect(activateProviderProfileSpy).not.toHaveBeenCalledWith({ name: null })
+ })
+ })
+
+ describe("Profile restoration with activateProfile failure", () => {
+ it("should continue task restoration even if activateProviderProfile fails", async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+
+ // Create a history item with saved provider profile
+ const historyItem: HistoryItem = {
+ id: "test-task-id",
+ number: 1,
+ ts: Date.now(),
+ task: "Test task",
+ tokensIn: 100,
+ tokensOut: 200,
+ cacheWrites: 0,
+ cacheReads: 0,
+ totalCost: 0.001,
+ apiConfigName: "failing-profile",
+ }
+
+ // Mock providerSettingsManager.listConfig to return the profile
+ vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([
+ { name: "failing-profile", id: "failing-profile-id", apiProvider: "anthropic" },
+ ])
+
+ // Mock activateProviderProfile to throw error
+ vi.spyOn(provider, "activateProviderProfile").mockRejectedValue(new Error("Activation failed"))
+
+ // Mock log to verify error is logged
+ const logSpy = vi.spyOn(provider, "log")
+
+ // Initialize task with history item - should not throw even though activation fails
+ await expect(provider.createTaskWithHistoryItem(historyItem)).resolves.not.toThrow()
+
+ // Verify error was logged
+ expect(logSpy).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to restore API configuration 'failing-profile' for task"),
+ )
+ })
+ })
+})
From e287a821442823be6a20d3544e46d0df737d9b0e Mon Sep 17 00:00:00 2001
From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com>
Date: Wed, 7 Jan 2026 11:30:37 -0500
Subject: [PATCH 09/21] fix: remove legacy Claude 2 series models from Bedrock
provider (#10501)
Co-authored-by: Roo Code
---
packages/types/src/providers/bedrock.ts | 33 -------------------
.../__tests__/bedrock-invokedModelId.spec.ts | 6 ++--
2 files changed, 3 insertions(+), 36 deletions(-)
diff --git a/packages/types/src/providers/bedrock.ts b/packages/types/src/providers/bedrock.ts
index da40e98f43..19dfbf0b30 100644
--- a/packages/types/src/providers/bedrock.ts
+++ b/packages/types/src/providers/bedrock.ts
@@ -264,39 +264,6 @@ export const bedrockModels = {
inputPrice: 0.25,
outputPrice: 1.25,
},
- "anthropic.claude-2-1-v1:0": {
- maxTokens: 4096,
- contextWindow: 100_000,
- supportsImages: false,
- supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
- inputPrice: 8.0,
- outputPrice: 24.0,
- description: "Claude 2.1",
- },
- "anthropic.claude-2-0-v1:0": {
- maxTokens: 4096,
- contextWindow: 100_000,
- supportsImages: false,
- supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
- inputPrice: 8.0,
- outputPrice: 24.0,
- description: "Claude 2.0",
- },
- "anthropic.claude-instant-v1:0": {
- maxTokens: 4096,
- contextWindow: 100_000,
- supportsImages: false,
- supportsPromptCache: false,
- supportsNativeTools: true,
- defaultToolProtocol: "native",
- inputPrice: 0.8,
- outputPrice: 2.4,
- description: "Claude Instant",
- },
"deepseek.r1-v1:0": {
maxTokens: 32_768,
contextWindow: 128_000,
diff --git a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts
index 7fe7255f5b..fe16ea89eb 100644
--- a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts
+++ b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts
@@ -122,7 +122,7 @@ describe("AwsBedrockHandler with invokedModelId", () => {
trace: {
promptRouter: {
invokedModelId:
- "arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-2-1-v1:0",
+ "arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-3-opus-20240229-v1:0",
usage: {
inputTokens: 150,
outputTokens: 250,
@@ -162,12 +162,12 @@ describe("AwsBedrockHandler with invokedModelId", () => {
}
// Verify that getModelById was called with the id, not the full arn
- expect(getModelByIdSpy).toHaveBeenCalledWith("anthropic.claude-2-1-v1:0", "inference-profile")
+ expect(getModelByIdSpy).toHaveBeenCalledWith("anthropic.claude-3-opus-20240229-v1:0", "inference-profile")
// Verify that getModel returns the updated model info
const costModel = handler.getModel()
//expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20240620-v1:0")
- expect(costModel.info.inputPrice).toBe(8)
+ expect(costModel.info.inputPrice).toBe(15)
// Verify that a usage event was emitted after updating the costModelConfig
const usageEvents = events.filter((event) => event.type === "usage")
From 43f7ce025f046fa6f6305e47cf49daf5f3d970b0 Mon Sep 17 00:00:00 2001
From: Hannes Rudolph
Date: Wed, 7 Jan 2026 09:44:04 -0700
Subject: [PATCH 10/21] feat: add support for image file @mentions (#10189)
Co-authored-by: Roo Code
---
.../__tests__/resolveImageMentions.spec.ts | 193 ++++++++++++++++++
src/core/mentions/index.ts | 8 +-
src/core/mentions/resolveImageMentions.ts | 145 +++++++++++++
.../webview/__tests__/ClineProvider.spec.ts | 6 +-
.../webviewMessageHandler.checkpoint.spec.ts | 6 +-
.../webviewMessageHandler.edit.spec.ts | 4 +
...eHandler.imageMentions.integration.spec.ts | 130 ++++++++++++
.../__tests__/webviewMessageHandler.spec.ts | 40 ++++
src/core/webview/webviewMessageHandler.ts | 39 +++-
9 files changed, 561 insertions(+), 10 deletions(-)
create mode 100644 src/core/mentions/__tests__/resolveImageMentions.spec.ts
create mode 100644 src/core/mentions/resolveImageMentions.ts
create mode 100644 src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts
diff --git a/src/core/mentions/__tests__/resolveImageMentions.spec.ts b/src/core/mentions/__tests__/resolveImageMentions.spec.ts
new file mode 100644
index 0000000000..747c778819
--- /dev/null
+++ b/src/core/mentions/__tests__/resolveImageMentions.spec.ts
@@ -0,0 +1,193 @@
+import * as path from "path"
+
+import { resolveImageMentions } from "../resolveImageMentions"
+
+vi.mock("../../tools/helpers/imageHelpers", () => ({
+ isSupportedImageFormat: vi.fn((ext: string) =>
+ [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico", ".tiff", ".tif", ".avif"].includes(
+ ext.toLowerCase(),
+ ),
+ ),
+ readImageAsDataUrlWithBuffer: vi.fn(),
+ validateImageForProcessing: vi.fn(),
+ ImageMemoryTracker: vi.fn().mockImplementation(() => ({
+ getTotalMemoryUsed: vi.fn().mockReturnValue(0),
+ addMemoryUsage: vi.fn(),
+ })),
+ DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5,
+ DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20,
+}))
+
+import { validateImageForProcessing, readImageAsDataUrlWithBuffer } from "../../tools/helpers/imageHelpers"
+
+const mockReadImageAsDataUrl = vi.mocked(readImageAsDataUrlWithBuffer)
+const mockValidateImage = vi.mocked(validateImageForProcessing)
+
+describe("resolveImageMentions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Default: validation passes
+ mockValidateImage.mockResolvedValue({ isValid: true, sizeInMB: 0.1 })
+ })
+
+ it("should append a data URL when a local png mention is present", async () => {
+ const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`
+ mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") })
+
+ const result = await resolveImageMentions({
+ text: "Please look at @/assets/cat.png",
+ images: [],
+ cwd: "/workspace",
+ })
+
+ expect(mockValidateImage).toHaveBeenCalled()
+ expect(mockReadImageAsDataUrl).toHaveBeenCalledWith(path.resolve("/workspace", "assets/cat.png"))
+ expect(result.text).toBe("Please look at @/assets/cat.png")
+ expect(result.images).toEqual([dataUrl])
+ })
+
+ it("should support gif images (matching read_file)", async () => {
+ const dataUrl = `data:image/gif;base64,${Buffer.from("gif-bytes").toString("base64")}`
+ mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("gif-bytes") })
+
+ const result = await resolveImageMentions({
+ text: "See @/animation.gif",
+ images: [],
+ cwd: "/workspace",
+ })
+
+ expect(result.images).toEqual([dataUrl])
+ })
+
+ it("should support svg images (matching read_file)", async () => {
+ const dataUrl = `data:image/svg+xml;base64,${Buffer.from("svg-bytes").toString("base64")}`
+ mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("svg-bytes") })
+
+ const result = await resolveImageMentions({
+ text: "See @/icon.svg",
+ images: [],
+ cwd: "/workspace",
+ })
+
+ expect(result.images).toEqual([dataUrl])
+ })
+
+ it("should ignore non-image mentions", async () => {
+ const result = await resolveImageMentions({
+ text: "See @/src/index.ts",
+ images: [],
+ cwd: "/workspace",
+ })
+
+ expect(mockReadImageAsDataUrl).not.toHaveBeenCalled()
+ expect(result.images).toEqual([])
+ })
+
+ it("should skip unreadable files (fail-soft)", async () => {
+ mockReadImageAsDataUrl.mockRejectedValue(new Error("ENOENT"))
+
+ const result = await resolveImageMentions({
+ text: "See @/missing.webp",
+ images: [],
+ cwd: "/workspace",
+ })
+
+ expect(result.images).toEqual([])
+ })
+
+ it("should respect rooIgnoreController", async () => {
+ const dataUrl = `data:image/jpeg;base64,${Buffer.from("jpg-bytes").toString("base64")}`
+ mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("jpg-bytes") })
+ const rooIgnoreController = {
+ validateAccess: vi.fn().mockReturnValue(false),
+ }
+
+ const result = await resolveImageMentions({
+ text: "See @/secret.jpg",
+ images: [],
+ cwd: "/workspace",
+ rooIgnoreController,
+ })
+
+ expect(rooIgnoreController.validateAccess).toHaveBeenCalledWith("secret.jpg")
+ expect(mockReadImageAsDataUrl).not.toHaveBeenCalled()
+ expect(result.images).toEqual([])
+ })
+
+ it("should dedupe when mention repeats", async () => {
+ const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`
+ mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") })
+
+ const result = await resolveImageMentions({
+ text: "@/a.png and again @/a.png",
+ images: [],
+ cwd: "/workspace",
+ })
+
+ expect(result.images).toHaveLength(1)
+ })
+
+ it("should skip images when supportsImages is false", async () => {
+ const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`
+ mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") })
+
+ const result = await resolveImageMentions({
+ text: "See @/cat.png",
+ images: [],
+ cwd: "/workspace",
+ supportsImages: false,
+ })
+
+ expect(mockReadImageAsDataUrl).not.toHaveBeenCalled()
+ expect(result.images).toEqual([])
+ })
+
+ it("should skip images that exceed size limits", async () => {
+ mockValidateImage.mockResolvedValue({
+ isValid: false,
+ reason: "size_limit",
+ notice: "Image too large",
+ })
+
+ const result = await resolveImageMentions({
+ text: "See @/huge.png",
+ images: [],
+ cwd: "/workspace",
+ })
+
+ expect(mockValidateImage).toHaveBeenCalled()
+ expect(mockReadImageAsDataUrl).not.toHaveBeenCalled()
+ expect(result.images).toEqual([])
+ })
+
+ it("should skip images that would exceed memory limit", async () => {
+ mockValidateImage.mockResolvedValue({
+ isValid: false,
+ reason: "memory_limit",
+ notice: "Would exceed memory limit",
+ })
+
+ const result = await resolveImageMentions({
+ text: "See @/large.png",
+ images: [],
+ cwd: "/workspace",
+ })
+
+ expect(result.images).toEqual([])
+ })
+
+ it("should pass custom size limits to validation", async () => {
+ const dataUrl = `data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`
+ mockReadImageAsDataUrl.mockResolvedValue({ dataUrl, buffer: Buffer.from("png-bytes") })
+
+ await resolveImageMentions({
+ text: "See @/cat.png",
+ images: [],
+ cwd: "/workspace",
+ maxImageFileSize: 10,
+ maxTotalImageSize: 50,
+ })
+
+ expect(mockValidateImage).toHaveBeenCalledWith(expect.any(String), true, 10, 50, 0)
+ })
+})
diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts
index 9ee7cece5d..2bbbf9ed0d 100644
--- a/src/core/mentions/index.ts
+++ b/src/core/mentions/index.ts
@@ -284,7 +284,13 @@ async function getFileOrFolderContent(
const stats = await fs.stat(absPath)
if (stats.isFile()) {
- if (rooIgnoreController && !rooIgnoreController.validateAccess(absPath)) {
+ // Avoid trying to include image binary content as text context.
+ // Image mentions are handled separately via image attachment flow.
+ const isBinary = await isBinaryFile(absPath).catch(() => false)
+ if (isBinary) {
+ return `(Binary file ${mentionPath} omitted)`
+ }
+ if (rooIgnoreController && !rooIgnoreController.validateAccess(unescapedPath)) {
return `(File ${mentionPath} is ignored by .rooignore)`
}
try {
diff --git a/src/core/mentions/resolveImageMentions.ts b/src/core/mentions/resolveImageMentions.ts
new file mode 100644
index 0000000000..0a0344348f
--- /dev/null
+++ b/src/core/mentions/resolveImageMentions.ts
@@ -0,0 +1,145 @@
+import * as path from "path"
+
+import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
+import {
+ isSupportedImageFormat,
+ readImageAsDataUrlWithBuffer,
+ validateImageForProcessing,
+ ImageMemoryTracker,
+ DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
+ DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
+} from "../tools/helpers/imageHelpers"
+
+const MAX_IMAGES_PER_MESSAGE = 20
+
+export interface ResolveImageMentionsOptions {
+ text: string
+ images?: string[]
+ cwd: string
+ rooIgnoreController?: { validateAccess: (filePath: string) => boolean }
+ /** Whether the current model supports images. Defaults to true. */
+ supportsImages?: boolean
+ /** Maximum size per image file in MB. Defaults to 5MB. */
+ maxImageFileSize?: number
+ /** Maximum total size of all images in MB. Defaults to 20MB. */
+ maxTotalImageSize?: number
+}
+
+export interface ResolveImageMentionsResult {
+ text: string
+ images: string[]
+}
+
+function isPathWithinCwd(absPath: string, cwd: string): boolean {
+ const rel = path.relative(cwd, absPath)
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
+}
+
+function dedupePreserveOrder(values: string[]): string[] {
+ const seen = new Set()
+ const result: string[] = []
+ for (const v of values) {
+ if (seen.has(v)) continue
+ seen.add(v)
+ result.push(v)
+ }
+ return result
+}
+
+/**
+ * Resolves local image file mentions like `@/path/to/image.png` found in `text` into `data:image/...;base64,...`
+ * and appends them to the outgoing `images` array.
+ *
+ * Behavior matches the read_file tool:
+ * - Supports the same image formats: png, jpg, jpeg, gif, webp, svg, bmp, ico, tiff, avif
+ * - Respects per-file size limits (default 5MB)
+ * - Respects total memory limits (default 20MB)
+ * - Skips images if model doesn't support them
+ * - Respects `.rooignore` via `rooIgnoreController.validateAccess` when provided
+ */
+export async function resolveImageMentions({
+ text,
+ images,
+ cwd,
+ rooIgnoreController,
+ supportsImages = true,
+ maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
+ maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
+}: ResolveImageMentionsOptions): Promise {
+ const existingImages = Array.isArray(images) ? images : []
+ if (existingImages.length >= MAX_IMAGES_PER_MESSAGE) {
+ return { text, images: existingImages.slice(0, MAX_IMAGES_PER_MESSAGE) }
+ }
+
+ // If model doesn't support images, skip image processing entirely
+ if (!supportsImages) {
+ return { text, images: existingImages }
+ }
+
+ const mentions = Array.from(text.matchAll(mentionRegexGlobal))
+ .map((m) => m[1])
+ .filter(Boolean)
+ if (mentions.length === 0) {
+ return { text, images: existingImages }
+ }
+
+ const imageMentions = mentions.filter((mention) => {
+ if (!mention.startsWith("/")) return false
+ const relPath = unescapeSpaces(mention.slice(1))
+ const ext = path.extname(relPath).toLowerCase()
+ return isSupportedImageFormat(ext)
+ })
+
+ if (imageMentions.length === 0) {
+ return { text, images: existingImages }
+ }
+
+ const imageMemoryTracker = new ImageMemoryTracker()
+ const newImages: string[] = []
+
+ for (const mention of imageMentions) {
+ if (existingImages.length + newImages.length >= MAX_IMAGES_PER_MESSAGE) {
+ break
+ }
+
+ const relPath = unescapeSpaces(mention.slice(1))
+ const absPath = path.resolve(cwd, relPath)
+ if (!isPathWithinCwd(absPath, cwd)) {
+ continue
+ }
+
+ if (rooIgnoreController && !rooIgnoreController.validateAccess(relPath)) {
+ continue
+ }
+
+ // Validate image size limits (matches read_file behavior)
+ try {
+ const validationResult = await validateImageForProcessing(
+ absPath,
+ supportsImages,
+ maxImageFileSize,
+ maxTotalImageSize,
+ imageMemoryTracker.getTotalMemoryUsed(),
+ )
+
+ if (!validationResult.isValid) {
+ // Skip this image due to size/memory limits, but continue processing others
+ continue
+ }
+
+ const { dataUrl } = await readImageAsDataUrlWithBuffer(absPath)
+ newImages.push(dataUrl)
+
+ // Track memory usage
+ if (validationResult.sizeInMB) {
+ imageMemoryTracker.addMemoryUsage(validationResult.sizeInMB)
+ }
+ } catch {
+ // Fail-soft: skip unreadable/missing files.
+ continue
+ }
+ }
+
+ const merged = dedupePreserveOrder([...existingImages, ...newImages]).slice(0, MAX_IMAGES_PER_MESSAGE)
+ return { text, images: merged }
+}
diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts
index 6c125f490d..daab32e93f 100644
--- a/src/core/webview/__tests__/ClineProvider.spec.ts
+++ b/src/core/webview/__tests__/ClineProvider.spec.ts
@@ -3051,7 +3051,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0]])
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }])
// Verify submitUserMessage was called with the edited content
- expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", undefined)
+ expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", [])
})
test("handles editing messages with file attachments", async () => {
@@ -3104,7 +3104,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
})
expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
- expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", undefined)
+ expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", [])
})
})
@@ -3635,7 +3635,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: largeEditedContent })
expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
- expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, undefined)
+ expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, [])
})
test("handles deleting messages with large payloads", async () => {
diff --git a/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts
index a0687d6cc1..2b2b0f78b1 100644
--- a/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts
+++ b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts
@@ -55,6 +55,10 @@ describe("webviewMessageHandler - checkpoint operations", () => {
contextProxy: {
globalStorageUri: { fsPath: "/test/storage" },
},
+ getState: vi.fn().mockResolvedValue({
+ maxImageFileSize: 5,
+ maxTotalImageSize: 20,
+ }),
}
})
@@ -124,7 +128,7 @@ describe("webviewMessageHandler - checkpoint operations", () => {
operation: "edit",
editData: {
editedContent: "Edited checkpoint message",
- images: undefined,
+ images: [],
apiConversationHistoryIndex: 0,
},
})
diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts
index 5b89c723d4..2f11281d2b 100644
--- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts
+++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts
@@ -71,6 +71,10 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
globalStorageUri: { fsPath: "/mock/storage" },
},
log: vi.fn(),
+ getState: vi.fn().mockResolvedValue({
+ maxImageFileSize: 5,
+ maxTotalImageSize: 20,
+ }),
} as unknown as ClineProvider
})
diff --git a/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts
new file mode 100644
index 0000000000..277e56626a
--- /dev/null
+++ b/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts
@@ -0,0 +1,130 @@
+import * as fs from "fs/promises"
+import * as path from "path"
+import * as os from "os"
+
+// Must mock dependencies before importing the handler module.
+vi.mock("../../../api/providers/fetchers/modelCache")
+
+import { webviewMessageHandler } from "../webviewMessageHandler"
+import type { ClineProvider } from "../ClineProvider"
+
+vi.mock("vscode", () => ({
+ window: {
+ showInformationMessage: vi.fn(),
+ showErrorMessage: vi.fn(),
+ },
+ workspace: {
+ workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
+ },
+}))
+
+// Mock imageHelpers - use actual implementations for functions that need real file access
+vi.mock("../../tools/helpers/imageHelpers", async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ validateImageForProcessing: vi.fn().mockResolvedValue({ isValid: true, sizeInMB: 0.001 }),
+ ImageMemoryTracker: vi.fn().mockImplementation(() => ({
+ getTotalMemoryUsed: vi.fn().mockReturnValue(0),
+ addMemoryUsage: vi.fn(),
+ })),
+ }
+})
+
+describe("webviewMessageHandler - image mentions (integration)", () => {
+ it("resolves image mentions for newTask and passes images to createTask", async () => {
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-"))
+ try {
+ const imgBytes = Buffer.from("png-bytes")
+ await fs.writeFile(path.join(tmpRoot, "cat.png"), imgBytes)
+
+ const mockProvider = {
+ cwd: tmpRoot,
+ getCurrentTask: vi.fn().mockReturnValue(undefined),
+ createTask: vi.fn().mockResolvedValue(undefined),
+ postMessageToWebview: vi.fn().mockResolvedValue(undefined),
+ getState: vi.fn().mockResolvedValue({
+ maxImageFileSize: 5,
+ maxTotalImageSize: 20,
+ }),
+ } as unknown as ClineProvider
+
+ await webviewMessageHandler(mockProvider, {
+ type: "newTask",
+ text: "Please look at @/cat.png",
+ images: [],
+ } as any)
+
+ expect(mockProvider.createTask).toHaveBeenCalledWith("Please look at @/cat.png", [
+ `data:image/png;base64,${imgBytes.toString("base64")}`,
+ ])
+ } finally {
+ await fs.rm(tmpRoot, { recursive: true, force: true })
+ }
+ })
+
+ it("resolves image mentions for askResponse and passes images to handleWebviewAskResponse", async () => {
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-"))
+ try {
+ const imgBytes = Buffer.from("jpg-bytes")
+ await fs.writeFile(path.join(tmpRoot, "cat.jpg"), imgBytes)
+
+ const handleWebviewAskResponse = vi.fn()
+ const mockProvider = {
+ cwd: tmpRoot,
+ getCurrentTask: vi.fn().mockReturnValue({
+ cwd: tmpRoot,
+ handleWebviewAskResponse,
+ }),
+ getState: vi.fn().mockResolvedValue({
+ maxImageFileSize: 5,
+ maxTotalImageSize: 20,
+ }),
+ } as unknown as ClineProvider
+
+ await webviewMessageHandler(mockProvider, {
+ type: "askResponse",
+ askResponse: "messageResponse",
+ text: "Please look at @/cat.jpg",
+ images: [],
+ } as any)
+
+ expect(handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Please look at @/cat.jpg", [
+ `data:image/jpeg;base64,${imgBytes.toString("base64")}`,
+ ])
+ } finally {
+ await fs.rm(tmpRoot, { recursive: true, force: true })
+ }
+ })
+
+ it("resolves gif image mentions (matching read_file behavior)", async () => {
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-"))
+ try {
+ const imgBytes = Buffer.from("gif-bytes")
+ await fs.writeFile(path.join(tmpRoot, "animation.gif"), imgBytes)
+
+ const mockProvider = {
+ cwd: tmpRoot,
+ getCurrentTask: vi.fn().mockReturnValue(undefined),
+ createTask: vi.fn().mockResolvedValue(undefined),
+ postMessageToWebview: vi.fn().mockResolvedValue(undefined),
+ getState: vi.fn().mockResolvedValue({
+ maxImageFileSize: 5,
+ maxTotalImageSize: 20,
+ }),
+ } as unknown as ClineProvider
+
+ await webviewMessageHandler(mockProvider, {
+ type: "newTask",
+ text: "See @/animation.gif",
+ images: [],
+ } as any)
+
+ expect(mockProvider.createTask).toHaveBeenCalledWith("See @/animation.gif", [
+ `data:image/gif;base64,${imgBytes.toString("base64")}`,
+ ])
+ } finally {
+ await fs.rm(tmpRoot, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts
index 66423dd379..3e2a9efddd 100644
--- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts
+++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts
@@ -117,6 +117,15 @@ vi.mock("../../../utils/fs")
vi.mock("../../../utils/path")
vi.mock("../../../utils/globalContext")
+vi.mock("../../mentions/resolveImageMentions", () => ({
+ resolveImageMentions: vi.fn(async ({ text, images }: { text: string; images?: string[] }) => ({
+ text,
+ images: [...(images ?? []), "data:image/png;base64,from-mention"],
+ })),
+}))
+
+import { resolveImageMentions } from "../../mentions/resolveImageMentions"
+
describe("webviewMessageHandler - requestLmStudioModels", () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -159,6 +168,37 @@ describe("webviewMessageHandler - requestLmStudioModels", () => {
})
})
+describe("webviewMessageHandler - image mentions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockClineProvider.getState = vi.fn().mockResolvedValue({
+ maxImageFileSize: 5,
+ maxTotalImageSize: 20,
+ })
+ })
+
+ it("should resolve image mentions for askResponse payloads", async () => {
+ const mockHandleWebviewAskResponse = vi.fn()
+ vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
+ cwd: "/mock/workspace",
+ rooIgnoreController: undefined,
+ handleWebviewAskResponse: mockHandleWebviewAskResponse,
+ } as any)
+
+ await webviewMessageHandler(mockClineProvider, {
+ type: "askResponse",
+ askResponse: "messageResponse",
+ text: "See @/img.png",
+ images: [],
+ })
+
+ expect(vi.mocked(resolveImageMentions)).toHaveBeenCalled()
+ expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "See @/img.png", [
+ "data:image/png;base64,from-mention",
+ ])
+ })
+})
+
describe("webviewMessageHandler - requestOllamaModels", () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts
index 35bf08e048..923011e8ca 100644
--- a/src/core/webview/webviewMessageHandler.ts
+++ b/src/core/webview/webviewMessageHandler.ts
@@ -54,6 +54,7 @@ import { exportSettings, importSettingsWithFeedback } from "../config/importExpo
import { getOpenAiModels } from "../../api/providers/openai"
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
import { openMention } from "../mentions"
+import { resolveImageMentions } from "../mentions/resolveImageMentions"
import { getWorkspacePath } from "../../utils/path"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
@@ -79,6 +80,26 @@ export const webviewMessageHandler = async (
const getCurrentCwd = () => {
return provider.getCurrentTask()?.cwd || provider.cwd
}
+
+ /**
+ * Resolves image file mentions in incoming messages.
+ * Matches read_file behavior: respects size limits and model capabilities.
+ */
+ const resolveIncomingImages = async (payload: { text?: string; images?: string[] }) => {
+ const text = payload.text ?? ""
+ const images = payload.images
+ const currentTask = provider.getCurrentTask()
+ const state = await provider.getState()
+ const resolved = await resolveImageMentions({
+ text,
+ images,
+ cwd: getCurrentCwd(),
+ rooIgnoreController: currentTask?.rooIgnoreController,
+ maxImageFileSize: state.maxImageFileSize,
+ maxTotalImageSize: state.maxTotalImageSize,
+ })
+ return resolved
+ }
/**
* Shared utility to find message indices based on timestamp.
* When multiple messages share the same timestamp (e.g., after condense),
@@ -505,7 +526,8 @@ export const webviewMessageHandler = async (
// agentically running promises in old instance don't affect our new
// task. This essentially creates a fresh slate for the new task.
try {
- await provider.createTask(message.text, message.images)
+ const resolved = await resolveIncomingImages({ text: message.text, images: message.images })
+ await provider.createTask(resolved.text, resolved.images)
// Task created successfully - notify the UI to reset
await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" })
} catch (error) {
@@ -522,7 +544,12 @@ export const webviewMessageHandler = async (
break
case "askResponse":
- provider.getCurrentTask()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
+ {
+ const resolved = await resolveIncomingImages({ text: message.text, images: message.images })
+ provider
+ .getCurrentTask()
+ ?.handleWebviewAskResponse(message.askResponse!, resolved.text, resolved.images)
+ }
break
case "updateSettings":
@@ -1877,11 +1904,12 @@ export const webviewMessageHandler = async (
break
case "editMessageConfirm":
if (message.messageTs && message.text) {
+ const resolved = await resolveIncomingImages({ text: message.text, images: message.images })
await handleEditMessageConfirm(
message.messageTs,
- message.text,
+ resolved.text,
message.restoreCheckpoint,
- message.images,
+ resolved.images,
)
}
break
@@ -3052,7 +3080,8 @@ export const webviewMessageHandler = async (
*/
case "queueMessage": {
- provider.getCurrentTask()?.messageQueueService.addMessage(message.text ?? "", message.images)
+ const resolved = await resolveIncomingImages({ text: message.text, images: message.images })
+ provider.getCurrentTask()?.messageQueueService.addMessage(resolved.text, resolved.images)
break
}
case "removeQueuedMessage": {
From d7d3f4099ba605661a942801823a9f6b15e22b52 Mon Sep 17 00:00:00 2001
From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com>
Date: Wed, 7 Jan 2026 13:07:25 -0500
Subject: [PATCH 11/21] fix: handle PowerShell ENOENT error in os-name on
Windows (#9897)
Co-authored-by: Roo Code
---
.../sections/__tests__/system-info.spec.ts | 66 +++++++++++++++++++
src/core/prompts/sections/system-info.ts | 13 +++-
2 files changed, 78 insertions(+), 1 deletion(-)
create mode 100644 src/core/prompts/sections/__tests__/system-info.spec.ts
diff --git a/src/core/prompts/sections/__tests__/system-info.spec.ts b/src/core/prompts/sections/__tests__/system-info.spec.ts
new file mode 100644
index 0000000000..749b53a0fd
--- /dev/null
+++ b/src/core/prompts/sections/__tests__/system-info.spec.ts
@@ -0,0 +1,66 @@
+import os from "os"
+
+// Mock the modules - must be hoisted before imports
+vi.mock("os-name", () => ({
+ default: vi.fn(),
+}))
+
+vi.mock("../../../../utils/shell", () => ({
+ getShell: vi.fn(() => "/bin/bash"),
+}))
+
+import { getSystemInfoSection } from "../system-info"
+import osName from "os-name"
+
+const mockOsName = osName as unknown as ReturnType
+
+describe("getSystemInfoSection", () => {
+ const mockCwd = "/test/workspace"
+ const mockHomeDir = "/home/user"
+
+ beforeEach(() => {
+ vi.spyOn(os, "homedir").mockReturnValue(mockHomeDir)
+ vi.spyOn(os, "platform").mockReturnValue("linux" as any)
+ vi.spyOn(os, "release").mockReturnValue("5.15.0")
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("should return system info with os-name when available", () => {
+ mockOsName.mockReturnValue("Ubuntu 22.04")
+
+ const result = getSystemInfoSection(mockCwd)
+
+ expect(result).toContain("Operating System: Ubuntu 22.04")
+ expect(result).toContain("Default Shell: /bin/bash")
+ expect(result).toContain(`Home Directory: ${mockHomeDir}`)
+ expect(result).toContain(`Current Workspace Directory: ${mockCwd}`)
+ })
+
+ it("should fallback to platform and release when os-name throws error", () => {
+ mockOsName.mockImplementation(() => {
+ throw new Error("Command failed with ENOENT: powershell")
+ })
+
+ const result = getSystemInfoSection(mockCwd)
+
+ expect(result).toContain("Operating System: linux 5.15.0")
+ expect(result).toContain("Default Shell: /bin/bash")
+ expect(result).toContain(`Home Directory: ${mockHomeDir}`)
+ expect(result).toContain(`Current Workspace Directory: ${mockCwd}`)
+ })
+
+ it("should handle Windows platform in fallback", () => {
+ mockOsName.mockImplementation(() => {
+ throw new Error("Command failed with ENOENT: powershell")
+ })
+ vi.spyOn(os, "platform").mockReturnValue("win32" as any)
+ vi.spyOn(os, "release").mockReturnValue("10.0.19043")
+
+ const result = getSystemInfoSection(mockCwd)
+
+ expect(result).toContain("Operating System: win32 10.0.19043")
+ })
+})
diff --git a/src/core/prompts/sections/system-info.ts b/src/core/prompts/sections/system-info.ts
index 8adc90a160..486e46ee23 100644
--- a/src/core/prompts/sections/system-info.ts
+++ b/src/core/prompts/sections/system-info.ts
@@ -4,11 +4,22 @@ import osName from "os-name"
import { getShell } from "../../../utils/shell"
export function getSystemInfoSection(cwd: string): string {
+ // Try to get detailed OS name, fall back to basic info if it fails
+ let osInfo: string
+ try {
+ osInfo = osName()
+ } catch (error) {
+ // Fallback when os-name fails (e.g., PowerShell not available on Windows)
+ const platform = os.platform()
+ const release = os.release()
+ osInfo = `${platform} ${release}`
+ }
+
let details = `====
SYSTEM INFORMATION
-Operating System: ${osName()}
+Operating System: ${osInfo}
Default Shell: ${getShell()}
Home Directory: ${os.homedir().toPosix()}
Current Workspace Directory: ${cwd.toPosix()}
From e3b90fb1825113a98d713b1d132446ed274317d7 Mon Sep 17 00:00:00 2001
From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com>
Date: Wed, 7 Jan 2026 14:32:30 -0700
Subject: [PATCH 12/21] feat: add xhigh reasoning effort to OpenAI compatible
endpoints (#10061)
Co-authored-by: Roo Code
---
.../src/components/settings/providers/OpenAICompatible.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx
index ad338d342a..2cf77b8366 100644
--- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx
+++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx
@@ -280,7 +280,7 @@ export const OpenAICompatible = ({
}}
modelInfo={{
...(apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults),
- supportsReasoningEffort: true,
+ supportsReasoningEffort: ["low", "medium", "high", "xhigh"],
}}
/>
)}
From 75525817ec761da710c0aaa5ed4e34016f04c784 Mon Sep 17 00:00:00 2001
From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com>
Date: Wed, 7 Jan 2026 18:37:32 -0500
Subject: [PATCH 13/21] feat: filter @ mention file search results using
.rooignore (#10174)
* feat: filter @ mention file search results using .rooignore
- Modify searchFiles case in webviewMessageHandler.ts to filter results using RooIgnoreController
- Use existing RooIgnoreController from current task if available, otherwise create a temporary one
- Respect showRooIgnoredFiles setting to allow users to toggle this behavior
- Add comprehensive test coverage for the new filtering behavior
Fixes #10169
* fix: dispose temporary RooIgnoreController to prevent resource leak
Addresses Rooviewer feedback: the temporary RooIgnoreController created
when no task exists was never disposed, causing file watchers to accumulate.
Changes:
- Track temporary controller separately with tempController variable
- Wrap filtering logic in try/finally block
- Call dispose() in finally block to ensure cleanup
- Add test cases to verify dispose is called for temp controllers
- Verify task's controller is NOT disposed (only temp ones)
---------
Co-authored-by: Roo Code
Co-authored-by: Hannes Rudolph
---
.../webviewMessageHandler.searchFiles.spec.ts | 297 ++++++++++++++++++
src/core/webview/webviewMessageHandler.ts | 40 ++-
2 files changed, 331 insertions(+), 6 deletions(-)
create mode 100644 src/core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts
diff --git a/src/core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts
new file mode 100644
index 0000000000..82f4d765ab
--- /dev/null
+++ b/src/core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts
@@ -0,0 +1,297 @@
+// npx vitest core/webview/__tests__/webviewMessageHandler.searchFiles.spec.ts
+
+import type { Mock } from "vitest"
+
+// Mock dependencies - must come before imports
+vi.mock("../../../services/search/file-search")
+vi.mock("../../ignore/RooIgnoreController")
+
+import { webviewMessageHandler } from "../webviewMessageHandler"
+import type { ClineProvider } from "../ClineProvider"
+import { searchWorkspaceFiles } from "../../../services/search/file-search"
+import { RooIgnoreController } from "../../ignore/RooIgnoreController"
+
+const mockSearchWorkspaceFiles = searchWorkspaceFiles as Mock
+
+vi.mock("vscode", () => ({
+ window: {
+ showInformationMessage: vi.fn(),
+ showErrorMessage: vi.fn(),
+ },
+ workspace: {
+ workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
+ },
+}))
+
+describe("webviewMessageHandler - searchFiles with RooIgnore filtering", () => {
+ let mockClineProvider: ClineProvider
+ let mockFilterPaths: Mock
+ let mockDispose: Mock
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Spy on the mock RooIgnoreController prototype methods
+ mockFilterPaths = vi.fn()
+ mockDispose = vi.fn()
+
+ // Override the filterPaths method on the prototype
+ ;(RooIgnoreController.prototype as any).filterPaths = mockFilterPaths
+ ;(RooIgnoreController.prototype as any).initialize = vi.fn().mockResolvedValue(undefined)
+ ;(RooIgnoreController.prototype as any).dispose = mockDispose
+
+ // Create mock ClineProvider
+ mockClineProvider = {
+ getState: vi.fn(),
+ postMessageToWebview: vi.fn(),
+ getCurrentTask: vi.fn(),
+ cwd: "/mock/workspace",
+ } as unknown as ClineProvider
+ })
+
+ it("should filter results using RooIgnoreController when showRooIgnoredFiles is false", async () => {
+ // Setup mock results from file search
+ const mockResults = [
+ { path: "src/index.ts", type: "file" as const, label: "index.ts" },
+ { path: "secrets/config.json", type: "file" as const, label: "config.json" },
+ { path: "src/utils.ts", type: "file" as const, label: "utils.ts" },
+ ]
+ mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
+
+ // Setup state with showRooIgnoredFiles = false
+ ;(mockClineProvider.getState as Mock).mockResolvedValue({
+ showRooIgnoredFiles: false,
+ })
+
+ // Setup filter to exclude secrets folder
+ mockFilterPaths.mockReturnValue(["src/index.ts", "src/utils.ts"])
+
+ // No current task, so temporary controller will be created
+ ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
+
+ await webviewMessageHandler(mockClineProvider, {
+ type: "searchFiles",
+ query: "index",
+ requestId: "test-request-123",
+ })
+
+ // Verify filterPaths was called with all result paths
+ expect(mockFilterPaths).toHaveBeenCalledWith(["src/index.ts", "secrets/config.json", "src/utils.ts"])
+
+ // Verify filtered results were sent to webview
+ expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
+ type: "fileSearchResults",
+ results: [
+ { path: "src/index.ts", type: "file", label: "index.ts" },
+ { path: "src/utils.ts", type: "file", label: "utils.ts" },
+ ],
+ requestId: "test-request-123",
+ })
+ })
+
+ it("should not filter results when showRooIgnoredFiles is true", async () => {
+ // Setup mock results from file search
+ const mockResults = [
+ { path: "src/index.ts", type: "file" as const, label: "index.ts" },
+ { path: "secrets/config.json", type: "file" as const, label: "config.json" },
+ ]
+ mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
+
+ // Setup state with showRooIgnoredFiles = true
+ ;(mockClineProvider.getState as Mock).mockResolvedValue({
+ showRooIgnoredFiles: true,
+ })
+
+ // No current task
+ ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
+
+ await webviewMessageHandler(mockClineProvider, {
+ type: "searchFiles",
+ query: "index",
+ requestId: "test-request-456",
+ })
+
+ // Verify filterPaths was NOT called
+ expect(mockFilterPaths).not.toHaveBeenCalled()
+
+ // Verify all results were sent to webview (unfiltered)
+ expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
+ type: "fileSearchResults",
+ results: mockResults,
+ requestId: "test-request-456",
+ })
+ })
+
+ it("should use existing RooIgnoreController from current task", async () => {
+ // Setup mock results from file search
+ const mockResults = [
+ { path: "src/index.ts", type: "file" as const, label: "index.ts" },
+ { path: "private/secret.ts", type: "file" as const, label: "secret.ts" },
+ ]
+ mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
+
+ // Setup state with showRooIgnoredFiles = false
+ ;(mockClineProvider.getState as Mock).mockResolvedValue({
+ showRooIgnoredFiles: false,
+ })
+
+ // Create a mock task with its own RooIgnoreController
+ const taskFilterPaths = vi.fn().mockReturnValue(["src/index.ts"])
+ const taskRooIgnoreController = {
+ filterPaths: taskFilterPaths,
+ initialize: vi.fn(),
+ }
+ ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue({
+ taskId: "test-task-id",
+ rooIgnoreController: taskRooIgnoreController,
+ })
+
+ await webviewMessageHandler(mockClineProvider, {
+ type: "searchFiles",
+ query: "index",
+ requestId: "test-request-789",
+ })
+
+ // Verify the task's controller was used (not the prototype)
+ expect(taskFilterPaths).toHaveBeenCalledWith(["src/index.ts", "private/secret.ts"])
+
+ // Verify filtered results were sent to webview
+ expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
+ type: "fileSearchResults",
+ results: [{ path: "src/index.ts", type: "file", label: "index.ts" }],
+ requestId: "test-request-789",
+ })
+ })
+
+ it("should handle error when no workspace path is available", async () => {
+ // Create provider without cwd
+ mockClineProvider = {
+ ...mockClineProvider,
+ cwd: undefined,
+ getCurrentTask: vi.fn().mockReturnValue(null),
+ } as unknown as ClineProvider
+
+ await webviewMessageHandler(mockClineProvider, {
+ type: "searchFiles",
+ query: "test",
+ requestId: "test-request-error",
+ })
+
+ // Verify error response was sent
+ expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
+ type: "fileSearchResults",
+ results: [],
+ requestId: "test-request-error",
+ error: "No workspace path available",
+ })
+ })
+
+ it("should handle errors from searchWorkspaceFiles", async () => {
+ mockSearchWorkspaceFiles.mockRejectedValue(new Error("File search failed"))
+
+ // Setup state
+ ;(mockClineProvider.getState as Mock).mockResolvedValue({
+ showRooIgnoredFiles: false,
+ })
+ ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
+
+ await webviewMessageHandler(mockClineProvider, {
+ type: "searchFiles",
+ query: "test",
+ requestId: "test-request-fail",
+ })
+
+ // Verify error response was sent
+ expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
+ type: "fileSearchResults",
+ results: [],
+ error: "File search failed",
+ requestId: "test-request-fail",
+ })
+ })
+
+ it("should default showRooIgnoredFiles to false when state is null", async () => {
+ // Setup mock results from file search
+ const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
+ mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
+
+ // Setup state to return null
+ ;(mockClineProvider.getState as Mock).mockResolvedValue(null)
+
+ // Setup filter to return all paths (no filtering)
+ mockFilterPaths.mockReturnValue(["src/index.ts"])
+
+ // No current task
+ ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
+
+ await webviewMessageHandler(mockClineProvider, {
+ type: "searchFiles",
+ query: "index",
+ requestId: "test-request-default",
+ })
+
+ // Verify filterPaths was called (showRooIgnoredFiles defaults to false)
+ expect(mockFilterPaths).toHaveBeenCalled()
+ })
+
+ it("should dispose temporary RooIgnoreController after use", async () => {
+ // Setup mock results from file search
+ const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
+ mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
+
+ // Setup state
+ ;(mockClineProvider.getState as Mock).mockResolvedValue({
+ showRooIgnoredFiles: false,
+ })
+
+ // Setup filter
+ mockFilterPaths.mockReturnValue(["src/index.ts"])
+
+ // No current task, so temporary controller will be created and should be disposed
+ ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue(null)
+
+ await webviewMessageHandler(mockClineProvider, {
+ type: "searchFiles",
+ query: "index",
+ requestId: "test-request-dispose",
+ })
+
+ // Verify dispose was called on the temporary controller
+ expect(mockDispose).toHaveBeenCalled()
+ })
+
+ it("should not dispose controller from current task", async () => {
+ // Setup mock results from file search
+ const mockResults = [{ path: "src/index.ts", type: "file" as const, label: "index.ts" }]
+ mockSearchWorkspaceFiles.mockResolvedValue(mockResults)
+
+ // Setup state
+ ;(mockClineProvider.getState as Mock).mockResolvedValue({
+ showRooIgnoredFiles: false,
+ })
+
+ // Create a mock task with its own RooIgnoreController
+ const taskFilterPaths = vi.fn().mockReturnValue(["src/index.ts"])
+ const taskDispose = vi.fn()
+ const taskRooIgnoreController = {
+ filterPaths: taskFilterPaths,
+ initialize: vi.fn(),
+ dispose: taskDispose,
+ }
+ ;(mockClineProvider.getCurrentTask as Mock).mockReturnValue({
+ taskId: "test-task-id",
+ rooIgnoreController: taskRooIgnoreController,
+ })
+
+ await webviewMessageHandler(mockClineProvider, {
+ type: "searchFiles",
+ query: "index",
+ requestId: "test-request-no-dispose",
+ })
+
+ // Verify dispose was NOT called on the task's controller
+ expect(taskDispose).not.toHaveBeenCalled()
+ // Verify the prototype dispose was also not called
+ expect(mockDispose).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts
index 923011e8ca..0df014b49a 100644
--- a/src/core/webview/webviewMessageHandler.ts
+++ b/src/core/webview/webviewMessageHandler.ts
@@ -55,6 +55,7 @@ import { getOpenAiModels } from "../../api/providers/openai"
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
import { openMention } from "../mentions"
import { resolveImageMentions } from "../mentions/resolveImageMentions"
+import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { getWorkspacePath } from "../../utils/path"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
@@ -1735,12 +1736,39 @@ export const webviewMessageHandler = async (
20, // Use default limit, as filtering is now done in the backend
)
- // Send results back to webview
- await provider.postMessageToWebview({
- type: "fileSearchResults",
- results,
- requestId: message.requestId,
- })
+ // Get the RooIgnoreController from the current task, or create a new one
+ const currentTask = provider.getCurrentTask()
+ let rooIgnoreController = currentTask?.rooIgnoreController
+ let tempController: RooIgnoreController | undefined
+
+ // If no current task or no controller, create a temporary one
+ if (!rooIgnoreController) {
+ tempController = new RooIgnoreController(workspacePath)
+ await tempController.initialize()
+ rooIgnoreController = tempController
+ }
+
+ try {
+ // Get showRooIgnoredFiles setting from state
+ const { showRooIgnoredFiles = false } = (await provider.getState()) ?? {}
+
+ // Filter results using RooIgnoreController if showRooIgnoredFiles is false
+ let filteredResults = results
+ if (!showRooIgnoredFiles && rooIgnoreController) {
+ const allowedPaths = rooIgnoreController.filterPaths(results.map((r) => r.path))
+ filteredResults = results.filter((r) => allowedPaths.includes(r.path))
+ }
+
+ // Send results back to webview
+ await provider.postMessageToWebview({
+ type: "fileSearchResults",
+ results: filteredResults,
+ requestId: message.requestId,
+ })
+ } finally {
+ // Dispose temporary controller to prevent resource leak
+ tempController?.dispose()
+ }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
From ca0c9010d59d493cf7353276166a6944999eb2f7 Mon Sep 17 00:00:00 2001
From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com>
Date: Wed, 7 Jan 2026 17:21:08 -0700
Subject: [PATCH 14/21] fix: use task stored API config as fallback for rate
limit (#10266)
Co-authored-by: Roo Code
---
src/core/task/Task.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index 9e41d5c4dd..5fcfde37ef 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -4242,7 +4242,7 @@ export class Task extends EventEmitter implements TaskLike {
// Respect provider rate limit window
let rateLimitDelay = 0
- const rateLimit = state?.apiConfiguration?.rateLimitSeconds || 0
+ const rateLimit = (state?.apiConfiguration ?? this.apiConfiguration)?.rateLimitSeconds || 0
if (Task.lastGlobalApiRequestTime && rateLimit > 0) {
const elapsed = performance.now() - Task.lastGlobalApiRequestTime
rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - elapsed) / 1000))
From 1573edf295c8ba4a40d88c24df12de5271e807f2 Mon Sep 17 00:00:00 2001
From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com>
Date: Wed, 7 Jan 2026 20:57:08 -0500
Subject: [PATCH 15/21] fix: make command chaining examples shell-aware for
Windows compatibility (#10434)
* fix: make command chaining examples shell-aware for Windows compatibility
Addresses Issue #10352 where Roo Code generates Unix-style command
chaining (&&) even on Windows systems using PowerShell or cmd.exe.
Changes:
- Add getCommandChainOperator() to detect the user shell and return
the appropriate command chaining syntax:
- Unix shells (bash, zsh, etc.): &&
- PowerShell: ;
- cmd.exe: &
- Update getRulesSection() to use shell-specific chaining in examples
- Add informative note for non-Unix shells about different syntaxes
- Add comprehensive tests for shell detection and command chaining
* feat: add Unix utility guidance for Windows shells
Addresses feedback from issue #10352 about sed and other Unix-specific
utilities being suggested on Windows. The system prompt now includes
guidance for PowerShell and cmd.exe users to use native alternatives:
PowerShell:
- Select-String instead of grep
- Get-Content instead of cat
- Remove-Item instead of rm
- Copy-Item instead of cp
- Move-Item instead of mv
- -replace operator or [regex] instead of sed
cmd.exe:
- type instead of cat
- del instead of rm
- copy instead of cp
- move instead of mv
- find/findstr instead of grep
* Apply suggestion from @roomote[bot]
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
* fix: use && for cmd.exe to preserve conditional execution semantics
- Update getCommandChainOperator() to return && for cmd.exe (already done)
- Update getCommandChainNote() to document && instead of & for cmd.exe
- Update JSDoc to reflect cmd.exe uses && for conditional execution
- Update tests to expect && for cmd.exe
cmd.exe supports && for conditional execution (run next command only if
previous succeeds), which provides the same semantics as Unix shells.
* fix: update PowerShell note to use && for cmd.exe reference
---------
Co-authored-by: Roo Code
Co-authored-by: Hannes Rudolph
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
---
src/core/prompts/__tests__/sections.spec.ts | 117 +++++++++++++++++++-
src/core/prompts/sections/rules.ts | 53 ++++++++-
2 files changed, 168 insertions(+), 2 deletions(-)
diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts
index d8a002d8f5..011b279698 100644
--- a/src/core/prompts/__tests__/sections.spec.ts
+++ b/src/core/prompts/__tests__/sections.spec.ts
@@ -1,7 +1,8 @@
import { addCustomInstructions } from "../sections/custom-instructions"
import { getCapabilitiesSection } from "../sections/capabilities"
-import { getRulesSection } from "../sections/rules"
+import { getRulesSection, getCommandChainOperator } from "../sections/rules"
import { McpHub } from "../../../services/mcp/McpHub"
+import * as shellUtils from "../../../utils/shell"
describe("addCustomInstructions", () => {
it("adds vscode language to custom instructions", async () => {
@@ -114,3 +115,117 @@ describe("getRulesSection", () => {
expect(result).not.toContain("Never reveal the vendor or company")
})
})
+
+describe("getCommandChainOperator", () => {
+ it("returns && for bash shell", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash")
+ expect(getCommandChainOperator()).toBe("&&")
+ })
+
+ it("returns && for zsh shell", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh")
+ expect(getCommandChainOperator()).toBe("&&")
+ })
+
+ it("returns ; for PowerShell", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue(
+ "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
+ )
+ expect(getCommandChainOperator()).toBe(";")
+ })
+
+ it("returns ; for PowerShell Core (pwsh)", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
+ expect(getCommandChainOperator()).toBe(";")
+ })
+
+ it("returns && for cmd.exe", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe")
+ expect(getCommandChainOperator()).toBe("&&")
+ })
+
+ it("returns && for Git Bash on Windows", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Program Files\\Git\\bin\\bash.exe")
+ expect(getCommandChainOperator()).toBe("&&")
+ })
+
+ it("returns && for WSL bash", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash")
+ expect(getCommandChainOperator()).toBe("&&")
+ })
+})
+
+describe("getRulesSection shell-aware command chaining", () => {
+ const cwd = "/test/path"
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it("uses && for Unix shells in command chaining example", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash")
+ const result = getRulesSection(cwd)
+
+ expect(result).toContain("cd (path to project) && (command")
+ expect(result).not.toContain("cd (path to project) ; (command")
+ expect(result).not.toContain("cd (path to project) & (command")
+ })
+
+ it("uses ; for PowerShell in command chaining example", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue(
+ "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
+ )
+ const result = getRulesSection(cwd)
+
+ expect(result).toContain("cd (path to project) ; (command")
+ expect(result).toContain("Note: Using `;` for PowerShell command chaining")
+ })
+
+ it("uses && for cmd.exe in command chaining example", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe")
+ const result = getRulesSection(cwd)
+
+ expect(result).toContain("cd (path to project) && (command")
+ expect(result).toContain("Note: Using `&&` for cmd.exe command chaining")
+ })
+
+ it("includes Unix utility guidance for PowerShell", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue(
+ "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
+ )
+ const result = getRulesSection(cwd)
+
+ expect(result).toContain("IMPORTANT: When using PowerShell, avoid Unix-specific utilities")
+ expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`")
+ expect(result).toContain("`Select-String` for grep")
+ expect(result).toContain("`Get-Content` for cat")
+ expect(result).toContain("PowerShell's `-replace` operator")
+ })
+
+ it("includes Unix utility guidance for cmd.exe", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe")
+ const result = getRulesSection(cwd)
+
+ expect(result).toContain("IMPORTANT: When using cmd.exe, avoid Unix-specific utilities")
+ expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`")
+ expect(result).toContain("`type` for cat")
+ expect(result).toContain("`del` for rm")
+ expect(result).toContain("`find`/`findstr` for grep")
+ })
+
+ it("does not include Unix utility guidance for Unix shells", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash")
+ const result = getRulesSection(cwd)
+
+ expect(result).not.toContain("IMPORTANT: When using PowerShell")
+ expect(result).not.toContain("IMPORTANT: When using cmd.exe")
+ expect(result).not.toContain("`Select-String` for grep")
+ })
+
+ it("does not include note for Unix shells", () => {
+ vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh")
+ const result = getRulesSection(cwd)
+
+ expect(result).not.toContain("Note: Using")
+ })
+})
diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts
index 20f0897022..800fb430ef 100644
--- a/src/core/prompts/sections/rules.ts
+++ b/src/core/prompts/sections/rules.ts
@@ -1,6 +1,53 @@
import type { SystemPromptSettings } from "../types"
import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types"
+import { getShell } from "../../../utils/shell"
+
+/**
+ * Returns the appropriate command chaining operator based on the user's shell.
+ * - Unix shells (bash, zsh, etc.): `&&` (run next command only if previous succeeds)
+ * - PowerShell: `;` (semicolon for command separation)
+ * - cmd.exe: `&&` (conditional execution, same as Unix)
+ * @internal Exported for testing purposes
+ */
+export function getCommandChainOperator(): string {
+ const shell = getShell().toLowerCase()
+
+ // Check for PowerShell (both Windows PowerShell and PowerShell Core)
+ if (shell.includes("powershell") || shell.includes("pwsh")) {
+ return ";"
+ }
+
+ // Check for cmd.exe
+ if (shell.includes("cmd.exe")) {
+ return "&&"
+ }
+
+ // Default to Unix-style && for bash, zsh, sh, and other shells
+ // This also covers Git Bash, WSL, and other Unix-like environments on Windows
+ return "&&"
+}
+
+/**
+ * Returns a shell-specific note about command chaining syntax and platform-specific utilities.
+ */
+function getCommandChainNote(): string {
+ const shell = getShell().toLowerCase()
+
+ // Check for PowerShell
+ if (shell.includes("powershell") || shell.includes("pwsh")) {
+ return "Note: Using `;` for PowerShell command chaining. For bash/zsh use `&&`, for cmd.exe use `&&`. IMPORTANT: When using PowerShell, avoid Unix-specific utilities like `sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`. Instead use PowerShell equivalents: `Select-String` for grep, `Get-Content` for cat, `Remove-Item` for rm, `Copy-Item` for cp, `Move-Item` for mv, and PowerShell's `-replace` operator or `[regex]` for sed."
+ }
+
+ // Check for cmd.exe
+ if (shell.includes("cmd.exe")) {
+ return "Note: Using `&&` for cmd.exe command chaining (conditional execution). For bash/zsh use `&&`, for PowerShell use `;`. IMPORTANT: When using cmd.exe, avoid Unix-specific utilities like `sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`. Use built-in commands like `type` for cat, `del` for rm, `copy` for cp, `move` for mv, `find`/`findstr` for grep, or consider using PowerShell commands instead."
+ }
+
+ // Unix shells
+ return ""
+}
+
function getVendorConfidentialitySection(): string {
return `
@@ -20,6 +67,10 @@ export function getRulesSection(cwd: string, settings?: SystemPromptSettings): s
// Determine whether to use XML tool references based on protocol
const effectiveProtocol = getEffectiveProtocol(settings?.toolProtocol)
+ // Get shell-appropriate command chaining operator
+ const chainOp = getCommandChainOperator()
+ const chainNote = getCommandChainNote()
+
return `====
RULES
@@ -28,7 +79,7 @@ RULES
- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to ${isNativeProtocol(effectiveProtocol) ? "execute_command" : ""}.
- You cannot \`cd\` into a different directory to complete a task. You are stuck operating from '${cwd.toPosix()}', so be sure to pass in the correct 'path' parameter when using tools that require a path.
- Do not use the ~ character or $HOME to refer to the home directory.
-- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory && then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) && (command, in this case npm install)\`.
+- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory ${chainOp} then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) ${chainOp} (command, in this case npm install)\`.${chainNote ? ` ${chainNote}` : ""}
- Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode.
- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.
* For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\\.md$"
From 710e7dd3585cc574e6ecb33d45815cbb81e174ed Mon Sep 17 00:00:00 2001
From: Seb Duerr
Date: Wed, 7 Jan 2026 18:29:54 -0800
Subject: [PATCH 16/21] feat(types): add zai-glm-4.7 to Cerebras models
(#10500)
Co-authored-by: Matt Rubens
---
packages/types/src/providers/cerebras.ts | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/packages/types/src/providers/cerebras.ts b/packages/types/src/providers/cerebras.ts
index 8e0c2f9413..37c063e83b 100644
--- a/packages/types/src/providers/cerebras.ts
+++ b/packages/types/src/providers/cerebras.ts
@@ -15,7 +15,19 @@ export const cerebrasModels = {
defaultToolProtocol: "native",
inputPrice: 0,
outputPrice: 0,
- description: "Highly intelligent general purpose model with up to 1,000 tokens/s",
+ description: "Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.",
+ },
+ "zai-glm-4.7": {
+ maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront)
+ contextWindow: 131072,
+ supportsImages: false,
+ supportsPromptCache: false,
+ supportsNativeTools: true,
+ defaultToolProtocol: "native",
+ inputPrice: 0,
+ outputPrice: 0,
+ description:
+ "Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.",
},
"qwen-3-235b-a22b-instruct-2507": {
maxTokens: 16384, // Conservative default to avoid premature rate limiting
From b2941d54df5b2a3d0bf0abbd70bb8d17235397b1 Mon Sep 17 00:00:00 2001
From: Danny Ricciotti
Date: Wed, 7 Jan 2026 22:34:17 -0500
Subject: [PATCH 17/21] =?UTF-8?q?=F0=9F=90=9B=20Fix=20glitchy=20kangaroo?=
=?UTF-8?q?=20bounce=20animation=20on=20welcome=20screen=20(#10035)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The kangaroo logo on the welcome screen had a visual glitch where it would instantly jump to the top position when hovering, instead of smoothly starting the bounce from its resting position.
Changes:
- Added custom smooth-bounce keyframe animation in index.css that explicitly starts from translateY(0)
- Updated RooHero component to use hover state tracking with the new animation
- Removed Tailwind's animate-bounce class which was causing the glitch
The animation now smoothly bounces from the resting position without any jarring visual jumps.
---
webview-ui/src/components/welcome/RooHero.tsx | 9 ++++++--
webview-ui/src/index.css | 23 +++++++++++++++++++
2 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/webview-ui/src/components/welcome/RooHero.tsx b/webview-ui/src/components/welcome/RooHero.tsx
index 4a8fd17958..5e6ef9899a 100644
--- a/webview-ui/src/components/welcome/RooHero.tsx
+++ b/webview-ui/src/components/welcome/RooHero.tsx
@@ -5,9 +5,13 @@ const RooHero = () => {
const w = window as any
return w.IMAGES_BASE_URI || ""
})
+ const [isHovered, setIsHovered] = useState(false)
return (
-
+
setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}>
{
maskImage: `url('${imagesBaseUri}/roo-logo.svg')`,
maskRepeat: "no-repeat",
maskSize: "contain",
+ animation: isHovered ? "smooth-bounce 1s ease-in-out infinite" : "none",
}}
- className="z-5 mr-auto group-hover:animate-bounce translate-y-0 transition-transform duration-500">
+ className="z-5 mr-auto translate-y-0 transition-transform duration-500">
Date: Thu, 8 Jan 2026 01:52:53 -0500
Subject: [PATCH 18/21] Release v3.39.0 (#10537)
---
.changeset/v3.39.0.md | 30 ++++++++++++++++
releases/3.39.0-release.png | Bin 0 -> 2545230 bytes
src/core/webview/ClineProvider.ts | 2 +-
.../src/components/chat/Announcement.tsx | 32 ++----------------
.../src/components/chat/FollowUpSuggest.tsx | 2 +-
webview-ui/src/i18n/locales/ca/chat.json | 5 +--
webview-ui/src/i18n/locales/de/chat.json | 5 +--
webview-ui/src/i18n/locales/en/chat.json | 5 +--
webview-ui/src/i18n/locales/es/chat.json | 5 +--
webview-ui/src/i18n/locales/fr/chat.json | 5 +--
webview-ui/src/i18n/locales/hi/chat.json | 7 ++--
webview-ui/src/i18n/locales/id/chat.json | 5 +--
webview-ui/src/i18n/locales/it/chat.json | 5 +--
webview-ui/src/i18n/locales/ja/chat.json | 5 +--
webview-ui/src/i18n/locales/ko/chat.json | 5 +--
webview-ui/src/i18n/locales/nl/chat.json | 5 +--
webview-ui/src/i18n/locales/pl/chat.json | 5 +--
webview-ui/src/i18n/locales/pt-BR/chat.json | 5 +--
webview-ui/src/i18n/locales/ru/chat.json | 5 +--
webview-ui/src/i18n/locales/tr/chat.json | 5 +--
webview-ui/src/i18n/locales/vi/chat.json | 5 +--
webview-ui/src/i18n/locales/zh-CN/chat.json | 5 +--
webview-ui/src/i18n/locales/zh-TW/chat.json | 5 +--
23 files changed, 90 insertions(+), 68 deletions(-)
create mode 100644 .changeset/v3.39.0.md
create mode 100644 releases/3.39.0-release.png
diff --git a/.changeset/v3.39.0.md b/.changeset/v3.39.0.md
new file mode 100644
index 0000000000..7ba429ce27
--- /dev/null
+++ b/.changeset/v3.39.0.md
@@ -0,0 +1,30 @@
+---
+"roo-cline": minor
+---
+
+
+
+- Implement sticky provider profile for task-level API config persistence (#8010 by @hannesrudolph, PR #10018 by @hannesrudolph)
+- Add support for image file @mentions (PR #10189 by @hannesrudolph)
+- Rename YOLO to BRRR (#8574 by @mojomast, PR #10507 by @roomote)
+- Add debug-mode proxy routing for debugging API calls (#7042 by @SleeperSmith, PR #10467 by @hannesrudolph)
+- Add Kimi K2 thinking model to Fireworks AI provider (#9201 by @kavehsfv, PR #9202 by @roomote)
+- Add xhigh reasoning effort to OpenAI compatible endpoints (#10060 by @Soorma718, PR #10061 by @roomote)
+- Filter @ mention file search results using .rooignore (#10169 by @jerrill-johnson-bitwerx, PR #10174 by @roomote)
+- Add image support documentation to read_file native tool description (#10440 by @nabilfreeman, PR #10442 by @roomote)
+- Add zai-glm-4.7 to Cerebras models (PR #10500 by @sebastiand-cerebras)
+- VSCode shim and basic CLI for running Roo Code headlessly (PR #10452 by @cte)
+- Add CLI installer for headless Roo Code (PR #10474 by @cte)
+- Add option to use CLI for evals (PR #10456 by @cte)
+- Remember last Roo model selection in web-evals and add evals skill (PR #10470 by @hannesrudolph)
+- Tweak the style of follow up suggestion modes (PR #9260 by @mrubens)
+- Fix: Handle PowerShell ENOENT error in os-name on Windows (#9859 by @Yang-strive, PR #9897 by @roomote)
+- Fix: Make command chaining examples shell-aware for Windows compatibility (#10352 by @AlexNek, PR #10434 by @roomote)
+- Fix: Preserve tool_use blocks for all tool_results in kept messages during condensation (PR #10471 by @daniel-lxs)
+- Fix: Add additionalProperties: false to MCP tool schemas for OpenAI Responses API (PR #10472 by @daniel-lxs)
+- Fix: Prevent duplicate tool_result blocks causing API errors (PR #10497 by @daniel-lxs)
+- Fix: Add explicit deduplication for duplicate tool_result blocks (#10465 by @nabilfreeman, PR #10466 by @roomote)
+- Fix: Use task stored API config as fallback for rate limit (PR #10266 by @roomote)
+- Fix: Remove legacy Claude 2 series models from Bedrock provider (#9220 by @KevinZhao, PR #10501 by @roomote)
+- Fix: Add missing description fields for debugProxy configuration (PR #10505 by @roomote)
+- Fix: Glitchy kangaroo bounce animation on welcome screen (PR #10035 by @objectiveSee)
diff --git a/releases/3.39.0-release.png b/releases/3.39.0-release.png
new file mode 100644
index 0000000000000000000000000000000000000000..4f71720928d57607476c1c0eef7b8e109385548d
GIT binary patch
literal 2545230
zcmeFY1yo$iwl>-{?ykYzX}oa=5FCQLG|;#<5
wGY?;0O~s{$U}qsUk5K|OL#Ul{MXuw&kX{=2jB$*0L1`snmgjJ40z$}
z?^+7~sO9|kT3#?0(fgXBf>uI&mLL%!E|7?&4VMjwAIc>xASB2oU}a?q5)u%$;X!o7Dk_!*DZhMwKhEuOTxc)NeQC3?-G-c@OPKm
zz8;=XXIdc;h@X#_4aD+GAwt^UrT@_7G0YXNiHE(5?LXB58G;B=P=7rw5aIV@BO;(8
zJ_H~+qEx_0CC^o)p8a_7sjQnQV<@Rcmajwm^kb0}QlE5@1uHL#>S!2S=9Y51edQ2u
z23BG@XVgH)$>#a`o7QdO+)#u?P$8*7
zP}}Pt(irZg1rvSsUvB-t8TEV#Z^|*XSNZ5_@k|)x^hYlU2uKznGZ6D1X%Ip25zw7<8^u7U)^Zgs<;&tP)w}Nol@^f3k
zJVB)Q+R`H7fpFlD@y~}sKmdh=-j~oI`Gb5xPqMwTJ;L4p5DK&RhIm5ZGOS!(JfSY0
zTtZ+l1R^BN&jo^ixcDGoAub457{UbtgDtJV!u*25APBdIrz;F%3;m_76BOcR@4;>5
z>da#)U}Gr+5&-iFLanR>t@y2=LY6#MFekWnP9Sm=^gqTK5$P}bA!2~||13g6V#ZBmSwo=KT*4v{VJ@&G7{X-Y&fgmR8x&3nt)EzDcKS(i4hXeY;44=Bn5iGvK5FqlP=cNzU59PScB;1VblI1j(9{sCel
znX7y{1#dcouTRrB@1k!=(5d$&|Ee2E_TNM5Zz!huWod8}ll&FMTCT31w12@ZNDu^u
z167Dm2##BR&|i}O9aDuj2=7mHB6EZw0q7Ae0Z0uDYuZFPNnZ<5?cHbOnu#jj=q45*
z5_t@PCx!|FnjF{%4vQ6Q@~kO_BiCORHwBdk?2Sqz8bZ#^%c4toN#%D*w(IT^xhKu!
zAM&eZV?MHAa=8`fAP;ULLY9-vl=X!}7YzalP3I2&`G!P0L+CT&V
z$odb2!I5bI(gSh+kp=<7Y5xGnA9(zQI2inG{DHLp6vw}&o8J>ey2(tWw(N${WXK%i
zH}a=Q?Ce~{f05{@^O1*_n0-F{Jeeg1Ce`=%aEhdPDPl_Ecbfeuhagiq?Rn1L%TB1bzPG
zD*`)LNeyxDpT?H6w_N&xLwUAvH>Ts^$@k9Q)5BB8)e-9A5%SK;8{%Y$`VAC{`V|Eo
zK4*uYVSB85l8K1zX279I-`C=!gL+HR6ENsb0%qSixmeF
zp?S-5BN1ygSi)M(iG46`?8s=B$Z+{Ud_r(>V3437eBLqwp}@NeM!*1~xk;>mhvam>
zWQQVW!(rPGzchYB7f?MpNkeCAM%W?)DvoFoTU~0R{u^|){sZW8!%gRx17`XwbY)#(
zP+A>(XDFApC&bz9-tGO>1A|0BU|xO!u%O^Ql6m1t5fCptDPRQB{`Y(m*xPJ9X
z$oxm2e{@It&+cTrJndXz_MX19IwJoYPx}S+2U;{qX^dSCqkd|B^IcU}bvx3P15f+u
z(B`tS;_L}$6~jSNufMKur7XR&xP1%4?yB^JZnsdmejWyscnut`08q%yzc||e%8!aw
ziHp!r6+~`#XcyITaI*v(^rHXGkABmA`^v&o3~|NbgB%Ln
z@*nw8lKY8-={`8Xy0??x0R-A#ZRQ`Yh!;>(RJN&0o@F-JY4Cpa5ZC#s@o;gzp|+TI
zIu}J&6m%}}GtU!P?ALLhY)_X;^&4CuNOb7o^PHFZg80ivn+|sm9wDAx@ZbunI-oku
zID*n0c3CCLD?Av#NihV^0)(BG%;1JR0>AWr8?!th%mqdW?6%I!wC4A|dW~Lhefn3$
z{=58;z~B7Pe+Odz3rCb2cz)E1#!e$|$h4$&Swy>dJIDQ6o^D11WAZbi`+_Yyr=d$n
zIwK#9-nS7LXWm#appaGAE#N^IWY*0jx5Y~zBQV)Oa!b)!!oS@^$H|~^hp@WT`ScL%
z*bOUVQo+a#xdw$?{O4|n@RxD?FP8ED3D@&)A@{GYN2UiQno2nUA#rL=EyOdx!gR>(
zp|LXlGpoD&7xM{>Ga*CoFBLS`y}2X2?O6>Ty8}UPm<3!D)M?#YcxeFq4eirCrra$~
zhmAw0tS86@zLd_(i%mySQY?h(4NVkUk_=qDp$GwR-|qvTyFm`1{{~wE68+mb|F4cK
zJ0uUjzWKw&p~L;!-~1Se^3O74Uflcj(_h^V&!448y!1%4+3p8D#TC1SO7#*WKXLr#
z3-PmPI^J+39QjZc>KwOGf`tDf9mvDW3StH^g5V89^GoCa(UZOf#l^BFVjO*X0Rq?h03e$r)dAzf`Uya<~!Pl(E
z@b&6G1_B^E6mbK7`~6p>4qrBb;BiwZ;um<Si| |