mirror of
https://github.com/cline/cline.git
synced 2026-09-17 17:45:33 +08:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5594ad499d | ||
|
|
28bd0b2881 | ||
|
|
43215a2037 | ||
|
|
57d154bce0 | ||
|
|
ca483cb657 | ||
|
|
9c8f6fa29c | ||
|
|
29ae2c286d | ||
|
|
82aee44a9a | ||
|
|
031604ddf6 | ||
|
|
f2101e375f | ||
|
|
8a65f0c68b | ||
|
|
32b8fa44cb | ||
|
|
0d067f7470 | ||
|
|
de6166392c | ||
|
|
5f21a9162a | ||
|
|
4de991f1b0 | ||
|
|
6e5d4a3f9e | ||
|
|
95af95badf | ||
|
|
61dcbd390c | ||
|
|
6255ac0a51 | ||
|
|
616800fcb9 | ||
|
|
df3826a59f | ||
|
|
873917810d | ||
|
|
e3c966f4e9 | ||
|
|
7eeb43ab41 | ||
|
|
7620f177ac | ||
|
|
67bab94911 | ||
|
|
eb91bfd738 | ||
|
|
b8227c19c3 | ||
|
|
8fee09f09e | ||
|
|
1c026c26d2 | ||
|
|
5bc4e5a4a0 | ||
|
|
2cfce5734e | ||
|
|
e2045bf5c3 | ||
|
|
0d933e804f | ||
|
|
16f73532f4 | ||
|
|
88bea8eeb4 | ||
|
|
1a570e98ba | ||
|
|
d86b7dd036 | ||
|
|
0178c3fa90 | ||
|
|
a107f45c6a | ||
|
|
3dd2ed9161 | ||
|
|
9a6603fdfb | ||
|
|
6d5c3e6aa4 | ||
|
|
24b9e821bb | ||
|
|
9960a3c57c | ||
|
|
88947592f0 | ||
|
|
ef4d11df19 | ||
|
|
23dec509bc | ||
|
|
07ab6b19b8 | ||
|
|
0ddef94d1f | ||
|
|
b9ae83b1cd | ||
|
|
e4eaf34827 | ||
|
|
6d3ed43c74 | ||
|
|
cbb67b48f2 | ||
|
|
a5f6a97be8 | ||
|
|
f309b062e7 | ||
|
|
768df130ab | ||
|
|
9980cb0938 | ||
|
|
aca4f842fa | ||
|
|
3fc91e2afe | ||
|
|
5f4700ce95 | ||
|
|
dbaf5e3ee3 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat: Added Claude Opus 4.1 to Bedrock
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Do not ignore `pkg` folder
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Enabling quickwins and adding a button to show that you can do a walkthrough
|
||||
+12
-15
@@ -96,16 +96,15 @@ jobs:
|
||||
- name: Build Tests and Extension
|
||||
run: npm run pretest
|
||||
|
||||
# Unit Tests disabled due to module system conflicts between backend and webview-ui
|
||||
# - name: Unit Tests
|
||||
# run: npm run test:unit
|
||||
- name: Unit Tests
|
||||
run: npm run test:unit
|
||||
|
||||
# Run extension tests with coverage
|
||||
- name: Extension Tests with Coverage
|
||||
- name: Extension Integration Tests with Coverage
|
||||
id: extension_coverage
|
||||
continue-on-error: true
|
||||
run: |
|
||||
node ./scripts/test-ci.js > extension_coverage.txt 2>&1
|
||||
node ./scripts/test-ci.js 2>&1 | tee extension_coverage.txt
|
||||
# Default the encoding to UTF-8 - It's not the default on Windows
|
||||
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
|
||||
|
||||
@@ -117,7 +116,7 @@ jobs:
|
||||
cd webview-ui
|
||||
# Ensure coverage dependency is installed
|
||||
npm install --no-save @vitest/coverage-v8
|
||||
npm run test:coverage > webview_coverage.txt 2>&1
|
||||
npm run test:coverage 2>&1 | tee webview_coverage.txt
|
||||
cd ..
|
||||
# Default the encoding to UTF-8 - It's not the default on Windows
|
||||
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
|
||||
@@ -132,21 +131,19 @@ jobs:
|
||||
path: |
|
||||
extension_coverage.txt
|
||||
webview-ui/webview_coverage.txt
|
||||
retention-period: workflow # Artifacts are automatically deleted when the workflow completes
|
||||
|
||||
# Set the check as failed if any of the tests failed
|
||||
- name: Print test results and check for failures
|
||||
- name: Check for test failures
|
||||
run: |
|
||||
echo "Extension Tests Result: ${{ steps.extension_coverage.outcome }}"
|
||||
cat extension_coverage.txt
|
||||
|
||||
echo "Webview Tests Result: ${{ steps.webview_coverage.outcome }}"
|
||||
cat webview-ui/webview_coverage.txt
|
||||
|
||||
# Check if any of the test steps failed
|
||||
# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#steps-context
|
||||
if [ "${{ steps.extension_coverage.outcome }}" != "success" ]; then
|
||||
echo "Extension Integration Tests failed, see previous step for test output."
|
||||
fi
|
||||
if [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
|
||||
echo "Webview Tests failed, see previous step for test output."
|
||||
fi
|
||||
if [ "${{ steps.extension_coverage.outcome }}" != "success" ] || [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
|
||||
echo "Tests failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## [3.20.11]
|
||||
|
||||
Add gpt-oss-120b as a Cerebras model
|
||||
Add Opus 4.1 through Claude Code
|
||||
|
||||
## [3.20.10]
|
||||
|
||||
- Add OpenAI's new open-source models (GPT-OSS-120B and GPT-OSS-20B) to Hugging Face and Groq providers
|
||||
|
||||
## [3.20.9]
|
||||
|
||||
- Add support for Claude Opus 4.1 model in Anthropic provider
|
||||
- Add Baseten as a new API provider with support for DeepSeek, Llama, and Kimi K2 models (Thanks @AlexKer!)
|
||||
- Fix error messages not clearing from UI when retrying failed tasks
|
||||
- Fix chat input box positioning issues
|
||||
|
||||
## [3.20.8]
|
||||
|
||||
- Add navbar tooltips on hover
|
||||
|
||||
## [3.20.7]
|
||||
|
||||
- Fix circular dependency that affect the github workflow Tests / test (pull_request)
|
||||
|
||||
## [3.20.6]
|
||||
|
||||
- Fix login check on extension restart
|
||||
|
||||
## [3.20.5]
|
||||
|
||||
- Fix authentication persistence issues that could cause users to be logged out unexpectedly
|
||||
|
||||
## [3.20.4]
|
||||
|
||||
- Add new Cerebras models
|
||||
- Update rate limits for existing Cerebras models
|
||||
- Fix for delete task dialog
|
||||
|
||||
## [3.20.3]
|
||||
|
||||
- Add Huawei Cloud MaaS Provider (Thanks @ddling!)
|
||||
|
||||
@@ -34,25 +34,30 @@ directApiRuleTester.run("no-direct-vscode-api", noDirectVscodeApiRule, {
|
||||
},
|
||||
// Should allow other vscode API calls
|
||||
{
|
||||
code: `vscode.window.showInformationMessage("Hello")`,
|
||||
filename: "test.ts",
|
||||
code: `vscode.commands.registerCommand("Hello")`,
|
||||
filename: "/foo/bar.ts",
|
||||
},
|
||||
// Should allow postMessage calls on other objects
|
||||
{
|
||||
code: `window.postMessage({ type: "test" }, "*")`,
|
||||
filename: "test.ts",
|
||||
filename: "/foo/bar.ts",
|
||||
},
|
||||
// Should allow variables named vscode but not calling postMessage
|
||||
{
|
||||
code: `const vscode = { other: "method" }; vscode.other()`,
|
||||
filename: "test.ts",
|
||||
filename: "/foo/bar.ts",
|
||||
},
|
||||
// Should allow vscode.postMessage in test files
|
||||
{
|
||||
code: `vscode.postMessage({ type: "newTask", text: message.text })`,
|
||||
filename: "/foo/bar.test.ts",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// Should disallow vscode.postMessage in regular files
|
||||
{
|
||||
code: `vscode.postMessage({ type: "test", data: {} })`,
|
||||
filename: "test.ts",
|
||||
filename: "/foo/bar.ts",
|
||||
errors: [
|
||||
{
|
||||
messageId: "useGrpcClient",
|
||||
@@ -69,23 +74,13 @@ directApiRuleTester.run("no-direct-vscode-api", noDirectVscodeApiRule, {
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should disallow vscode.postMessage in test files
|
||||
{
|
||||
code: `vscode.postMessage({ type: "newTask", text: message.text })`,
|
||||
filename: "test.test.ts",
|
||||
errors: [
|
||||
{
|
||||
messageId: "useGrpcClient",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Should disallow property access for disallowed APIs
|
||||
{
|
||||
code: `const folders = vscode.workspace.workspaceFolders;`,
|
||||
filename: "workspace.ts",
|
||||
errors: [
|
||||
{
|
||||
messageId: "useHostBridge",
|
||||
messageId: "useHostBridgeWorkspace",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -35,18 +35,21 @@ const disallowedApis = {
|
||||
"vscode.env.openExternal": {
|
||||
messageId: "useUtils",
|
||||
},
|
||||
// "vscode.window.showWarningMessage": {
|
||||
// messageId: "useHostBridgeShowMessage",
|
||||
// },
|
||||
"vscode.window.showWarningMessage": {
|
||||
messageId: "useHostBridgeShowMessage",
|
||||
},
|
||||
"vscode.window.showOpenDialog": {
|
||||
messageId: "useHostBridgeShowMessage",
|
||||
},
|
||||
"vscode.window.showErrorMessage": {
|
||||
messageId: "useHostBridgeShowMessage",
|
||||
},
|
||||
// "vscode.window.showInformationMessage": {
|
||||
// messageId: "useHostBridgeShowMessage",
|
||||
// },
|
||||
"vscode.window.showInformationMessage": {
|
||||
messageId: "useHostBridgeShowMessage",
|
||||
},
|
||||
"vscode.window.showInputBox": {
|
||||
messageId: "useHostBridge",
|
||||
},
|
||||
"vscode.workspace.findFiles": {
|
||||
messageId: "useNative",
|
||||
},
|
||||
@@ -194,7 +197,7 @@ module.exports = createRule({
|
||||
if (filename.includes("/standalone/runtime-files/")) {
|
||||
return true
|
||||
}
|
||||
// Skip unit tests
|
||||
// Skip checking test files
|
||||
if (filename.endsWith(".test.ts")) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface RunDiffEvalOptions {
|
||||
parsingFunction: string
|
||||
diffEditFunction: string
|
||||
thinkingBudget: number
|
||||
provider: string
|
||||
parallel: boolean
|
||||
verbose: boolean
|
||||
testPath: string
|
||||
@@ -39,6 +40,8 @@ export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
|
||||
options.parsingFunction,
|
||||
"--diff-edit-function",
|
||||
options.diffEditFunction,
|
||||
"--provider",
|
||||
options.provider,
|
||||
]
|
||||
|
||||
// Conditionally add the optional arguments
|
||||
|
||||
@@ -92,6 +92,7 @@ program
|
||||
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
|
||||
.option("--diff-edit-function <name>", "The diff editing function to use", "constructNewFileContentV2")
|
||||
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
|
||||
.option("--provider <provider>", "API provider to use (openrouter, openai)", "openrouter")
|
||||
.option("--parallel", "Run tests in parallel", false)
|
||||
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
|
||||
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OpenRouterHandler } from "../../src/api/providers/openrouter"
|
||||
import { OpenAiNativeHandler } from "../../src/api/providers/openai-native"
|
||||
import { ApiHandlerOptions } from "../../src/shared/api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
@@ -54,7 +55,7 @@ interface StreamResult {
|
||||
* Process the stream and return full response with timing data
|
||||
*/
|
||||
async function processStream(
|
||||
handler: OpenRouterHandler,
|
||||
handler: OpenRouterHandler | OpenAiNativeHandler,
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): Promise<StreamResult> {
|
||||
@@ -190,19 +191,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
}
|
||||
}
|
||||
|
||||
const options: ApiHandlerOptions = {
|
||||
openRouterApiKey: apiKey,
|
||||
openRouterModelId: modelId,
|
||||
thinkingBudgetTokens: thinkingBudgetTokens,
|
||||
openRouterModelInfo: {
|
||||
maxTokens: 10_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true, // may need to turn this on
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
}
|
||||
const provider = input.provider || "openrouter"
|
||||
|
||||
// Get the output of streaming output of this llm call
|
||||
let streamResult: StreamResult
|
||||
@@ -214,10 +203,34 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
usage: { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, totalCost: 0 },
|
||||
}
|
||||
} else {
|
||||
// Live mode: existing API call logic
|
||||
// Live mode: provider-specific API call logic
|
||||
try {
|
||||
const openRouterHandler = new OpenRouterHandler(options)
|
||||
streamResult = await processStream(openRouterHandler, systemPrompt, messages)
|
||||
let handler: OpenRouterHandler | OpenAiNativeHandler
|
||||
|
||||
if (provider === "openai") {
|
||||
const openAiOptions = {
|
||||
openAiNativeApiKey: apiKey,
|
||||
apiModelId: modelId,
|
||||
}
|
||||
handler = new OpenAiNativeHandler(openAiOptions)
|
||||
} else {
|
||||
const openRouterOptions = {
|
||||
openRouterApiKey: apiKey,
|
||||
openRouterModelId: modelId,
|
||||
thinkingBudgetTokens: thinkingBudgetTokens,
|
||||
openRouterModelInfo: {
|
||||
maxTokens: 10_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
}
|
||||
handler = new OpenRouterHandler(openRouterOptions)
|
||||
}
|
||||
|
||||
streamResult = await processStream(handler, systemPrompt, messages)
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -49,16 +49,25 @@ type TestResultSet = { [test_id: string]: (TestResult & { test_id?: string })[]
|
||||
|
||||
class NodeTestRunner {
|
||||
private apiKey: string | undefined
|
||||
private provider: string
|
||||
private currentRunId: string | null = null
|
||||
private systemPromptHash: string | null = null
|
||||
private processingFunctionsHash: string | null = null
|
||||
private caseIdMap: Map<string, string> = new Map() // test_id -> case_id mapping
|
||||
|
||||
constructor(isReplay: boolean) {
|
||||
constructor(isReplay: boolean, provider: string = "openrouter") {
|
||||
this.provider = provider
|
||||
if (!isReplay) {
|
||||
this.apiKey = process.env.OPENROUTER_API_KEY
|
||||
if (!this.apiKey) {
|
||||
throw new Error("OPENROUTER_API_KEY environment variable not set for a non-replay run.")
|
||||
if (provider === "openai") {
|
||||
this.apiKey = process.env.OPENAI_API_KEY
|
||||
if (!this.apiKey) {
|
||||
throw new Error("OPENAI_API_KEY environment variable not set for a non-replay run with OpenAI provider.")
|
||||
}
|
||||
} else {
|
||||
this.apiKey = process.env.OPENROUTER_API_KEY
|
||||
if (!this.apiKey) {
|
||||
throw new Error("OPENROUTER_API_KEY environment variable not set for a non-replay run with OpenRouter provider.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -635,6 +644,7 @@ class NodeTestRunner {
|
||||
thinkingBudgetTokens: testConfig.thinking_tokens_budget,
|
||||
originalDiffEditToolCallMessage: testConfig.replay ? testCase.original_diff_edit_tool_call_message : undefined,
|
||||
diffApplyFile: testConfig.diff_apply_file,
|
||||
provider: this.provider,
|
||||
isVerbose: isVerbose,
|
||||
}
|
||||
|
||||
@@ -927,6 +937,7 @@ async function main() {
|
||||
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
|
||||
.option("--diff-edit-function <name>", "The diff editing function to use", "diff-06-26-25")
|
||||
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
|
||||
.option("--provider <provider>", "API provider to use (openrouter, openai)", "openrouter")
|
||||
.option("--parallel", "Run tests in parallel", false)
|
||||
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
|
||||
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
|
||||
@@ -959,7 +970,7 @@ async function main() {
|
||||
? parseInt(options.maxAttemptsPerCase, 10)
|
||||
: validAttemptsPerCase * 10;
|
||||
|
||||
const runner = new NodeTestRunner(options.replay || !!options.replayRunId)
|
||||
const runner = new NodeTestRunner(options.replay || !!options.replayRunId, options.provider)
|
||||
|
||||
if (options.replayRunId) {
|
||||
if (!options.diffApplyFile) {
|
||||
@@ -979,7 +990,7 @@ async function main() {
|
||||
log(isVerbose, "Warning: Could not load OpenRouter model data. Context window filtering might be affected for OpenRouter models.");
|
||||
}
|
||||
|
||||
const runner = new NodeTestRunner(options.replay)
|
||||
const runner = new NodeTestRunner(options.replay, options.provider)
|
||||
let allLoadedTestCases = runner.loadTestCases(testPath, isVerbose) // Pass isVerbose
|
||||
|
||||
const allProcessedTestCasesGlobal: ProcessedTestCase[] = allLoadedTestCases.map((tc) => ({
|
||||
|
||||
@@ -331,6 +331,42 @@ def get_performance_grade(success_rate):
|
||||
else:
|
||||
return "C", "poor"
|
||||
|
||||
def get_error_description(error_enum, error_string=None):
|
||||
"""Map error enum values to user-friendly descriptions"""
|
||||
error_map = {
|
||||
1: "No tool calls - Model didn't use the replace_in_file tool",
|
||||
2: "Multiple tool calls - Model called multiple tools instead of one",
|
||||
3: "Wrong tool call - Model used wrong tool (not replace_in_file)",
|
||||
4: "Missing parameters - Tool call missing required path or diff",
|
||||
5: "Wrong file edited - Model edited different file than expected",
|
||||
6: "Wrong tool call - Model used wrong tool type",
|
||||
7: "Wrong file edited - Model targeted incorrect file path",
|
||||
8: "API/Stream error - Problem with model API connection",
|
||||
9: "Configuration error - Invalid evaluation parameters",
|
||||
10: "Function error - Invalid parsing/diff functions",
|
||||
11: "Other error - Unexpected failure"
|
||||
}
|
||||
|
||||
base_description = error_map.get(error_enum, f"Unknown error (code: {error_enum})")
|
||||
|
||||
if error_string:
|
||||
return f"{base_description}: {error_string}"
|
||||
return base_description
|
||||
|
||||
def get_error_guidance(error_enum):
|
||||
"""Provide specific guidance based on error type"""
|
||||
guidance_map = {
|
||||
1: "💡 The model provided a response but didn't use the replace_in_file tool. Check the raw output to see what the model actually said.",
|
||||
2: "💡 The model called multiple tools when it should only call replace_in_file once. Check the parsed tool call section.",
|
||||
3: "💡 The model used a different tool instead of replace_in_file. This might indicate confusion about the task.",
|
||||
4: "💡 The model called replace_in_file but didn't provide the required 'path' or 'diff' parameters.",
|
||||
5: "💡 The model tried to edit a different file than expected. Check the parsed tool call to see which file it targeted.",
|
||||
6: "💡 The model used the wrong tool type. Check the raw output to see what tool it attempted to use.",
|
||||
7: "💡 The model tried to edit a different file path than expected. This could indicate path confusion or hallucination.",
|
||||
}
|
||||
|
||||
return guidance_map.get(error_enum, "")
|
||||
|
||||
def render_hero_section(current_run, model_performance):
|
||||
"""Render the hero section with key metrics"""
|
||||
run_title = current_run['description'] if current_run['description'] else f"Run {current_run['run_id'][:8]}..."
|
||||
@@ -570,12 +606,16 @@ def render_result_detail(result):
|
||||
"""Render detailed view of a single result"""
|
||||
st.markdown("### 🔬 Result Deep Dive")
|
||||
|
||||
# Check if this is a valid result
|
||||
is_valid = (result['error_enum'] not in [1, 6, 7]) if not pd.isna(result['error_enum']) else True
|
||||
# Check if this is a valid result (only invalid if no tool calls or wrong file)
|
||||
is_valid = True
|
||||
if not pd.isna(result['error_enum']):
|
||||
# Only these specific errors make a result "invalid" for the benchmark:
|
||||
# 1 = no_tool_calls, 5 = wrong_file_edited, 7 = wrong_file_edited
|
||||
is_valid = result['error_enum'] not in [1, 5, 7]
|
||||
|
||||
# Show validity warning if needed
|
||||
if not is_valid:
|
||||
st.warning("⚠️ **This is an invalid result** - The model didn't properly call the diff edit tool or edited the wrong file. This result is excluded from success rate calculations.")
|
||||
st.warning("⚠️ **This is an invalid result** - The model didn't call the replace_in_file tool or edited the wrong file. This result is excluded from success rate calculations.")
|
||||
|
||||
# Result metadata
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
@@ -591,7 +631,10 @@ def render_result_detail(result):
|
||||
st.markdown(f"**Round Trip:** {result['time_round_trip_ms']:.0f}ms")
|
||||
|
||||
with col4:
|
||||
st.markdown(f"**Cost:** ${result['cost_usd']:.4f}")
|
||||
if pd.notna(result['cost_usd']) and result['cost_usd'] is not None:
|
||||
st.markdown(f"**Cost:** ${result['cost_usd']:.4f}")
|
||||
else:
|
||||
st.markdown(f"**Cost:** Free")
|
||||
|
||||
# Tabbed interface for different views
|
||||
tab1, tab2, tab3, tab4 = st.tabs(["📄 File & Edits", "🤖 Raw Output", "🔧 Parsed Tool Call", "📊 Metrics"])
|
||||
@@ -693,8 +736,46 @@ def render_file_and_edits_view(result):
|
||||
# Show error information
|
||||
st.error("❌ **Edit Failed**")
|
||||
|
||||
# Show detailed error reason
|
||||
if not pd.isna(result['error_enum']):
|
||||
st.markdown(f"**Error Code:** {result['error_enum']}")
|
||||
error_description = get_error_description(
|
||||
result['error_enum'],
|
||||
result.get('error_string')
|
||||
)
|
||||
st.markdown(f"**Reason:** {error_description}")
|
||||
|
||||
# Show specific guidance based on error type
|
||||
guidance = get_error_guidance(result['error_enum'])
|
||||
if guidance:
|
||||
st.info(guidance)
|
||||
|
||||
# For valid results that failed, check for diff application failures
|
||||
elif not result['succeeded']:
|
||||
# This is a valid result that failed - likely due to diff application issues
|
||||
raw_output = result.get('raw_model_output', '')
|
||||
|
||||
# Check if we have specific error information in the raw output
|
||||
if 'does not match anything in the file' in str(raw_output).lower():
|
||||
st.warning("⚠️ **Diff Application Failed**")
|
||||
st.info("💡 The SEARCH block in the diff didn't match any content in the original file. This usually means the model hallucinated code that doesn't exist.")
|
||||
elif 'malformatted' in str(raw_output).lower() or 'malformed' in str(raw_output).lower():
|
||||
st.warning("⚠️ **Diff Format Error**")
|
||||
st.info("💡 The diff format was incorrect. Check the raw tool call to see the formatting issues.")
|
||||
elif 'error:' in str(raw_output).lower():
|
||||
# Try to extract the specific error message
|
||||
lines = str(raw_output).split('\n')
|
||||
error_lines = [line for line in lines if 'error:' in line.lower()]
|
||||
if error_lines:
|
||||
error_msg = error_lines[0].strip()
|
||||
st.warning("⚠️ **Diff Application Failed**")
|
||||
st.info(f"💡 {error_msg}")
|
||||
else:
|
||||
st.warning("⚠️ **Diff Application Failed**")
|
||||
st.info("💡 The diff couldn't be applied to the original file. Check the raw output and parsed tool call for more details.")
|
||||
else:
|
||||
# Generic diff application failure
|
||||
st.warning("⚠️ **Diff Application Failed**")
|
||||
st.info("💡 The model made a valid tool call but the diff couldn't be applied to the original file. This usually indicates a mismatch between the expected and actual file content.")
|
||||
else:
|
||||
# Show successful edit information
|
||||
st.success("✅ **Edit Successful**")
|
||||
@@ -725,8 +806,25 @@ def render_file_and_edits_view(result):
|
||||
if len(edited_lines) > 50:
|
||||
st.text(f"... ({len(edited_lines) - 50} more lines)")
|
||||
|
||||
# Show parsed tool call if available
|
||||
# Show raw and parsed tool calls if available
|
||||
if not pd.isna(result['parsed_tool_call_json']):
|
||||
with st.expander("View Raw Tool Call"):
|
||||
# Extract the raw tool call text from the model output
|
||||
raw_output = result['raw_model_output'] if not pd.isna(result['raw_model_output']) else ""
|
||||
|
||||
# Try to extract just the tool call portion
|
||||
if raw_output and '<replace_in_file>' in raw_output:
|
||||
# Find the tool call block
|
||||
start_idx = raw_output.find('<replace_in_file>')
|
||||
end_idx = raw_output.find('</replace_in_file>') + len('</replace_in_file>')
|
||||
if start_idx != -1 and end_idx != -1:
|
||||
raw_tool_call = raw_output[start_idx:end_idx]
|
||||
st.code(raw_tool_call, language='xml')
|
||||
else:
|
||||
st.text("Tool call not found in raw output")
|
||||
else:
|
||||
st.text("No raw tool call available")
|
||||
|
||||
with st.expander("View Parsed Tool Call"):
|
||||
try:
|
||||
parsed_call = json.loads(result['parsed_tool_call_json'])
|
||||
@@ -795,8 +893,10 @@ def render_metrics_view(result):
|
||||
if not pd.isna(result['completion_tokens']):
|
||||
st.metric("Completion Tokens", int(result['completion_tokens']))
|
||||
|
||||
if not pd.isna(result['cost_usd']):
|
||||
if pd.notna(result['cost_usd']) and result['cost_usd'] is not None:
|
||||
st.metric("Cost", f"${result['cost_usd']:.4f}")
|
||||
else:
|
||||
st.metric("Cost", "Free")
|
||||
|
||||
if not pd.isna(result['tokens_in_context']):
|
||||
st.metric("Context Tokens", int(result['tokens_in_context']))
|
||||
|
||||
@@ -104,5 +104,6 @@ export interface TestInput {
|
||||
thinkingBudgetTokens: number
|
||||
originalDiffEditToolCallMessage?: string
|
||||
diffApplyFile?: string
|
||||
provider?: string
|
||||
isVerbose: boolean
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.20.3",
|
||||
"version": "3.20.11",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.20.3",
|
||||
"version": "3.20.11",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.20.3",
|
||||
"version": "3.20.11",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -119,7 +119,8 @@
|
||||
{
|
||||
"type": "webview",
|
||||
"id": "claude-dev.SidebarProvider",
|
||||
"name": ""
|
||||
"name": "",
|
||||
"icon": "assets/icons/icon.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -26,7 +26,6 @@ export default defineConfig({
|
||||
{
|
||||
name: "cleanup test environment",
|
||||
testMatch: /global\.teardown\.ts/,
|
||||
dependencies: ["e2e tests"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -27,6 +27,8 @@ service ModelsService {
|
||||
rpc updateApiConfigurationProto(UpdateApiConfigurationRequest) returns (Empty);
|
||||
// Refreshes and returns Groq models
|
||||
rpc refreshGroqModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns Baseten models
|
||||
rpc refreshBasetenModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
}
|
||||
|
||||
// List of VS Code LM models
|
||||
@@ -130,6 +132,7 @@ enum ApiProvider {
|
||||
MOONSHOT = 27;
|
||||
HUGGINGFACE = 28;
|
||||
HUAWEI_CLOUD_MAAS = 29;
|
||||
BASETEN = 30;
|
||||
}
|
||||
|
||||
// Model info for OpenAI-compatible models
|
||||
@@ -231,6 +234,7 @@ message ModelsApiConfiguration {
|
||||
optional string groq_api_key = 59;
|
||||
optional string hugging_face_api_key = 60;
|
||||
optional string huawei_cloud_maas_api_key = 61;
|
||||
optional string baseten_api_key = 62;
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
@@ -259,6 +263,8 @@ message ModelsApiConfiguration {
|
||||
optional OpenRouterModelInfo plan_mode_hugging_face_model_info = 123;
|
||||
optional string plan_mode_huawei_cloud_maas_model_id = 124;
|
||||
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 125;
|
||||
optional string plan_mode_baseten_model_id = 126;
|
||||
optional OpenRouterModelInfo plan_mode_baseten_model_info = 127;
|
||||
|
||||
// Act mode configurations
|
||||
optional ApiProvider act_mode_api_provider = 200;
|
||||
@@ -287,6 +293,8 @@ message ModelsApiConfiguration {
|
||||
optional OpenRouterModelInfo act_mode_hugging_face_model_info = 223;
|
||||
optional string act_mode_huawei_cloud_maas_model_id = 224;
|
||||
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 225;
|
||||
optional string act_mode_baseten_model_id = 226;
|
||||
optional OpenRouterModelInfo act_mode_baseten_model_info = 227;
|
||||
|
||||
repeated string favorited_model_ids = 300;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ message UpdateSettingsRequest {
|
||||
optional PlanActMode mode = 13;
|
||||
optional string preferred_language = 14;
|
||||
optional string openai_reasoning_effort = 15;
|
||||
optional bool strict_plan_mode_enabled = 16;
|
||||
}
|
||||
|
||||
// Complete API Configuration message
|
||||
|
||||
@@ -267,4 +267,7 @@ service UiService {
|
||||
|
||||
// Opens a URL in the default browser
|
||||
rpc openUrl(StringRequest) returns (Empty);
|
||||
|
||||
// Opens the Cline walkthrough
|
||||
rpc openWalkthrough(EmptyRequest) returns (Empty);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ message ShowSaveDialogRequest {
|
||||
|
||||
message ShowSaveDialogOptions {
|
||||
optional string default_path = 1;
|
||||
// A map of file types to extensions, e.g
|
||||
// "Text Files": { "extensions": ["txt", "md"] }
|
||||
map<string, FileExtensionList> filters = 2;
|
||||
}
|
||||
|
||||
@@ -92,6 +94,7 @@ message FileExtensionList {
|
||||
}
|
||||
|
||||
message ShowSaveDialogResponse {
|
||||
// If the user cancelled the dialog, this will be empty.
|
||||
optional string selected_path = 1;
|
||||
}
|
||||
|
||||
@@ -129,4 +132,4 @@ message GetVisibleTabsRequest {
|
||||
|
||||
message GetVisibleTabsResponse {
|
||||
repeated string paths = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ package host;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "cline/common.proto";
|
||||
|
||||
// Provides methods for working with workspaces/projects.
|
||||
service WorkspaceService {
|
||||
// Returns a list of the top level directories of the workspace.
|
||||
rpc getWorkspacePaths(GetWorkspacePathsRequest) returns (GetWorkspacePathsResponse);
|
||||
// Saves an open document if it's dirty
|
||||
rpc saveOpenDocumentIfDirty(SaveOpenDocumentIfDirtyRequest) returns (cline.Empty);
|
||||
// Saves an open document if it's open in the editor and has unsaved changes.
|
||||
// Returns true if the document was saved, returns false if the document was not found, or did not
|
||||
// need to be saved.
|
||||
rpc saveOpenDocumentIfDirty(SaveOpenDocumentIfDirtyRequest) returns (SaveOpenDocumentIfDirtyResponse);
|
||||
}
|
||||
|
||||
message GetWorkspacePathsRequest {
|
||||
@@ -28,6 +28,9 @@ message GetWorkspacePathsResponse {
|
||||
}
|
||||
|
||||
message SaveOpenDocumentIfDirtyRequest {
|
||||
cline.Metadata metadata = 1;
|
||||
string file_path = 2;
|
||||
optional string file_path = 2;
|
||||
}
|
||||
message SaveOpenDocumentIfDirtyResponse {
|
||||
// Returns true if the document was saved.
|
||||
optional bool was_saved = 1;
|
||||
}
|
||||
|
||||
Regular → Executable
+1
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
const { execSync } = require("child_process")
|
||||
const esbuild = require("esbuild")
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ async function generateWebviewProtobusClients(protobusServices) {
|
||||
}
|
||||
if (!rpc.responseStream) {
|
||||
rpcs.push(` static async ${rpcName}(request: ${requestType}): Promise<${responseType}> {
|
||||
return this.makeRequest("${rpcName}", request)
|
||||
return this.makeUnaryRequest("${rpcName}", request, ${requestType}.toJSON, ${responseType}.fromJSON)
|
||||
}`)
|
||||
} else {
|
||||
rpcs.push(` static ${rpcName}(request: ${requestType}, callbacks: Callbacks<${responseType}>): ()=>void {
|
||||
return this.makeStreamingRequest("${rpcName}", request, callbacks)
|
||||
return this.makeStreamingRequest("${rpcName}", request, ${requestType}.toJSON, ${responseType}.fromJSON, callbacks)
|
||||
}`)
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
@@ -32,6 +32,7 @@ import { GroqHandler } from "./providers/groq"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { HuggingFaceHandler } from "./providers/huggingface"
|
||||
import { HuaweiCloudMaaSHandler } from "./providers/huawei-cloud-maas"
|
||||
import { BasetenHandler } from "./providers/baseten"
|
||||
|
||||
export interface ApiHandler {
|
||||
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
|
||||
@@ -257,6 +258,13 @@ function createHandlerForProvider(
|
||||
groqModelInfo: mode === "plan" ? options.planModeGroqModelInfo : options.actModeGroqModelInfo,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "baseten":
|
||||
return new BasetenHandler({
|
||||
basetenApiKey: options.basetenApiKey,
|
||||
basetenModelId: mode === "plan" ? options.planModeBasetenModelId : options.actModeBasetenModelId,
|
||||
basetenModelInfo: mode === "plan" ? options.planModeBasetenModelInfo : options.actModeBasetenModelInfo,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "sapaicore":
|
||||
return new SapAiCoreHandler({
|
||||
sapAiCoreClientId: options.sapAiCoreClientId,
|
||||
|
||||
@@ -612,101 +612,102 @@ describe("AwsBedrockHandler", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModelId", () => {
|
||||
it("should return raw model ID for custom models", async () => {
|
||||
const customOptions: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
actModeAwsBedrockCustomSelected: true,
|
||||
actModeApiModelId:
|
||||
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
}
|
||||
const customHandler = new AwsBedrockHandler(customOptions)
|
||||
// TODO: Re-enable or remove these tests.
|
||||
// describe("getModelId", () => {
|
||||
// it("should return raw model ID for custom models", async () => {
|
||||
// const customOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// actModeAwsBedrockCustomSelected: true,
|
||||
// actModeApiModelId:
|
||||
// "arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
// }
|
||||
// const customHandler = new AwsBedrockHandler(customOptions)
|
||||
|
||||
const modelId = await customHandler.getModelId()
|
||||
modelId.should.equal(
|
||||
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
)
|
||||
})
|
||||
// const modelId = await customHandler.getModelId()
|
||||
// modelId.should.equal(
|
||||
// "arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
// )
|
||||
// })
|
||||
|
||||
it("should not encode custom model IDs with slashes", async () => {
|
||||
const customOptions: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
actModeAwsBedrockCustomSelected: true,
|
||||
actModeApiModelId: "my-namespace/my-custom-model",
|
||||
}
|
||||
const customHandler = new AwsBedrockHandler(customOptions)
|
||||
// it("should not encode custom model IDs with slashes", async () => {
|
||||
// const customOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// actModeAwsBedrockCustomSelected: true,
|
||||
// actModeApiModelId: "my-namespace/my-custom-model",
|
||||
// }
|
||||
// const customHandler = new AwsBedrockHandler(customOptions)
|
||||
|
||||
const modelId = await customHandler.getModelId()
|
||||
modelId.should.equal("my-namespace/my-custom-model")
|
||||
modelId.should.not.match(/%2F/)
|
||||
})
|
||||
// const modelId = await customHandler.getModelId()
|
||||
// modelId.should.equal("my-namespace/my-custom-model")
|
||||
// modelId.should.not.match(/%2F/)
|
||||
// })
|
||||
|
||||
it("should apply cross-region prefix for non-custom models when enabled", async () => {
|
||||
const crossRegionOptions: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
awsRegion: "us-west-2",
|
||||
}
|
||||
const crossRegionHandler = new AwsBedrockHandler(crossRegionOptions)
|
||||
// it("should apply cross-region prefix for non-custom models when enabled", async () => {
|
||||
// const crossRegionOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// awsUseCrossRegionInference: true,
|
||||
// awsRegion: "us-west-2",
|
||||
// }
|
||||
// const crossRegionHandler = new AwsBedrockHandler(crossRegionOptions)
|
||||
|
||||
const modelId = await crossRegionHandler.getModelId()
|
||||
modelId.should.equal("us.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
})
|
||||
// const modelId = await crossRegionHandler.getModelId()
|
||||
// modelId.should.equal("us.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
// })
|
||||
|
||||
it("should apply EU cross-region prefix", async () => {
|
||||
const euOptions: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
awsRegion: "eu-central-1",
|
||||
}
|
||||
const euHandler = new AwsBedrockHandler(euOptions)
|
||||
// it("should apply EU cross-region prefix", async () => {
|
||||
// const euOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// awsUseCrossRegionInference: true,
|
||||
// awsRegion: "eu-central-1",
|
||||
// }
|
||||
// const euHandler = new AwsBedrockHandler(euOptions)
|
||||
|
||||
const modelId = await euHandler.getModelId()
|
||||
modelId.should.equal("eu.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
})
|
||||
// const modelId = await euHandler.getModelId()
|
||||
// modelId.should.equal("eu.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
// })
|
||||
|
||||
it("should apply APAC cross-region prefix", async () => {
|
||||
const apacOptions: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
awsRegion: "ap-northeast-1",
|
||||
}
|
||||
const apacHandler = new AwsBedrockHandler(apacOptions)
|
||||
// it("should apply APAC cross-region prefix", async () => {
|
||||
// const apacOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// awsUseCrossRegionInference: true,
|
||||
// awsRegion: "ap-northeast-1",
|
||||
// }
|
||||
// const apacHandler = new AwsBedrockHandler(apacOptions)
|
||||
|
||||
const modelId = await apacHandler.getModelId()
|
||||
modelId.should.equal("apac.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
})
|
||||
// const modelId = await apacHandler.getModelId()
|
||||
// modelId.should.equal("apac.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
// })
|
||||
|
||||
it("should not apply cross-region prefix for custom models even when enabled", async () => {
|
||||
const customCrossRegionOptions: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
actModeAwsBedrockCustomSelected: true,
|
||||
actModeApiModelId: "arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model",
|
||||
awsUseCrossRegionInference: true,
|
||||
}
|
||||
const customCrossRegionHandler = new AwsBedrockHandler(customCrossRegionOptions)
|
||||
// it("should not apply cross-region prefix for custom models even when enabled", async () => {
|
||||
// const customCrossRegionOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// actModeAwsBedrockCustomSelected: true,
|
||||
// actModeApiModelId: "arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model",
|
||||
// awsUseCrossRegionInference: true,
|
||||
// }
|
||||
// const customCrossRegionHandler = new AwsBedrockHandler(customCrossRegionOptions)
|
||||
|
||||
const modelId = await customCrossRegionHandler.getModelId()
|
||||
modelId.should.equal("arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model")
|
||||
})
|
||||
// const modelId = await customCrossRegionHandler.getModelId()
|
||||
// modelId.should.equal("arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model")
|
||||
// })
|
||||
|
||||
it("should handle UltraThink model ARN correctly", async () => {
|
||||
const ultraThinkOptions: ApiHandlerOptions = {
|
||||
...mockOptions,
|
||||
actModeAwsBedrockCustomSelected: true,
|
||||
actModeApiModelId:
|
||||
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
actModeAwsBedrockCustomModelBaseId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
}
|
||||
const ultraThinkHandler = new AwsBedrockHandler(ultraThinkOptions)
|
||||
// it("should handle UltraThink model ARN correctly", async () => {
|
||||
// const ultraThinkOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// actModeAwsBedrockCustomSelected: true,
|
||||
// actModeApiModelId:
|
||||
// "arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
// actModeAwsBedrockCustomModelBaseId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
// }
|
||||
// const ultraThinkHandler = new AwsBedrockHandler(ultraThinkOptions)
|
||||
|
||||
const modelId = await ultraThinkHandler.getModelId()
|
||||
// Should return the raw ARN without any encoding
|
||||
modelId.should.equal(
|
||||
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
)
|
||||
modelId.should.not.match(/%2F/)
|
||||
modelId.should.not.match(/%3A/)
|
||||
})
|
||||
})
|
||||
// const modelId = await ultraThinkHandler.getModelId()
|
||||
// // Should return the raw ARN without any encoding
|
||||
// modelId.should.equal(
|
||||
// "arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
// )
|
||||
// modelId.should.not.match(/%2F/)
|
||||
// modelId.should.not.match(/%3A/)
|
||||
// })
|
||||
// })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { BasetenModelId, ModelInfo, basetenDefaultModelId, basetenModels } from "@shared/api"
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
interface BasetenHandlerOptions {
|
||||
basetenApiKey?: string
|
||||
basetenModelId?: string
|
||||
basetenModelInfo?: ModelInfo
|
||||
apiModelId?: string // For backward compatibility
|
||||
}
|
||||
|
||||
export class BasetenHandler implements ApiHandler {
|
||||
private options: BasetenHandlerOptions
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(options: BasetenHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.basetenApiKey) {
|
||||
throw new Error("Baseten API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://inference.baseten.co/v1",
|
||||
apiKey: this.options.basetenApiKey,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Baseten client: ${error.message}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the optimal max_tokens based on model capabilities
|
||||
*/
|
||||
private getOptimalMaxTokens(model: { id: BasetenModelId; info: ModelInfo }): number {
|
||||
// Use model-specific max tokens if available
|
||||
if (model.info.maxTokens && model.info.maxTokens > 0) {
|
||||
return model.info.maxTokens
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 8192
|
||||
}
|
||||
|
||||
getModel(): { id: BasetenModelId; info: ModelInfo } {
|
||||
// First priority: basetenModelId and basetenModelInfo
|
||||
const basetenModelId = this.options.basetenModelId
|
||||
const basetenModelInfo = this.options.basetenModelInfo
|
||||
if (basetenModelId && basetenModelInfo) {
|
||||
return { id: basetenModelId as BasetenModelId, info: basetenModelInfo }
|
||||
}
|
||||
|
||||
// Second priority: basetenModelId with static model info
|
||||
if (basetenModelId && basetenModelId in basetenModels) {
|
||||
const id = basetenModelId as BasetenModelId
|
||||
return { id, info: basetenModels[id] }
|
||||
}
|
||||
|
||||
// Third priority: apiModelId (for backward compatibility)
|
||||
const apiModelId = this.options.apiModelId
|
||||
if (apiModelId && apiModelId in basetenModels) {
|
||||
const id = apiModelId as BasetenModelId
|
||||
return { id, info: basetenModels[id] }
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return {
|
||||
id: basetenDefaultModelId,
|
||||
info: basetenModels[basetenDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
private async *yieldUsage(modelInfo: ModelInfo, usage: any): ApiStream {
|
||||
if (usage.prompt_tokens || usage.completion_tokens) {
|
||||
const cost = calculateApiCostOpenAI(modelInfo, usage.prompt_tokens || 0, usage.completion_tokens || 0)
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalCost: cost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
const maxTokens = this.getOptimalMaxTokens(model)
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_tokens: maxTokens,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
temperature: 0,
|
||||
})
|
||||
|
||||
let didOutputUsage = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
// Handle reasoning field if present (for reasoning models with parsed output)
|
||||
if ((delta as any)?.reasoning) {
|
||||
const reasoningContent = (delta as any).reasoning as string
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: reasoningContent,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle content field
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle usage information - only output once
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current model supports vision/images
|
||||
*/
|
||||
supportsImages(): boolean {
|
||||
const model = this.getModel()
|
||||
return model.info.supportsImages === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current model supports tools
|
||||
*/
|
||||
supportsTools(): boolean {
|
||||
const model = this.getModel()
|
||||
// Baseten models support tools via OpenAI-compatible API
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ export class CerebrasHandler implements ApiHandler {
|
||||
messages: cerebrasMessages,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
})
|
||||
|
||||
// Handle streaming response
|
||||
@@ -175,9 +176,15 @@ export class CerebrasHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in cerebrasModels) {
|
||||
const id = modelId as CerebrasModelId
|
||||
const originalModelId = this.options.apiModelId
|
||||
let apiModelId = originalModelId
|
||||
if (originalModelId === "qwen-3-coder-480b-free") {
|
||||
apiModelId = "qwen-3-coder-480b"
|
||||
return { id: apiModelId, info: cerebrasModels[originalModelId as CerebrasModelId] }
|
||||
}
|
||||
|
||||
if (originalModelId && originalModelId in cerebrasModels) {
|
||||
const id = originalModelId as CerebrasModelId
|
||||
return { id, info: cerebrasModels[id] }
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Mock for @google/genai module to avoid ESM compatibility issues in tests
|
||||
|
||||
export class GoogleGenAI {
|
||||
constructor(options: any) {
|
||||
// Mock constructor
|
||||
}
|
||||
|
||||
models = {
|
||||
generateContentStream: async (params: any) => {
|
||||
// Mock implementation that returns an async iterator
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
text: "Mock response",
|
||||
candidates: [],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
thoughtsTokenCount: 0,
|
||||
cachedContentTokenCount: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
countTokens: async (params: any) => {
|
||||
// Mock token counting
|
||||
return {
|
||||
totalTokens: 100,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Export mock types
|
||||
export interface GenerateContentConfig {
|
||||
httpOptions?: any
|
||||
systemInstruction?: string
|
||||
temperature?: number
|
||||
thinkingConfig?: any
|
||||
}
|
||||
|
||||
export interface GenerateContentResponseUsageMetadata {
|
||||
promptTokenCount?: number
|
||||
candidatesTokenCount?: number
|
||||
thoughtsTokenCount?: number
|
||||
cachedContentTokenCount?: number
|
||||
}
|
||||
|
||||
export interface Part {
|
||||
thought?: boolean
|
||||
text?: string
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, geminiDefaultModelId, GeminiModelId, geminiModels, ModelInfo } from "@shared/api"
|
||||
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
|
||||
// Define a default TTL for the cache (e.g., 15 minutes in seconds)
|
||||
const DEFAULT_CACHE_TTL_SECONDS = 900
|
||||
|
||||
@@ -252,77 +252,19 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
}
|
||||
|
||||
private async countTokens(text: string | vscode.LanguageModelChatMessage): Promise<number> {
|
||||
// For Claude models, use character-to-token ratio instead of VSCode LM's inaccurate counting
|
||||
if (this.isClaudeModel()) {
|
||||
const textContent = typeof text === "string" ? text : this.extractTextFromMessage(text)
|
||||
// Use 4 character-to-token ratio for Claude models
|
||||
return Math.ceil(textContent.length / 4)
|
||||
}
|
||||
|
||||
// Check for required dependencies
|
||||
if (!this.client) {
|
||||
console.warn("Cline <Language Model API>: No client available for token counting")
|
||||
return 0
|
||||
}
|
||||
|
||||
if (!this.currentRequestCancellation) {
|
||||
console.warn("Cline <Language Model API>: No cancellation token available for token counting")
|
||||
return 0
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!text) {
|
||||
console.debug("Cline <Language Model API>: Empty text provided for token counting")
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle different input types
|
||||
let tokenCount: number
|
||||
|
||||
if (typeof text === "string") {
|
||||
tokenCount = await this.client.countTokens(text, this.currentRequestCancellation.token)
|
||||
} else if (text instanceof vscode.LanguageModelChatMessage) {
|
||||
// For chat messages, ensure we have content
|
||||
if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) {
|
||||
console.debug("Cline <Language Model API>: Empty chat message content")
|
||||
return 0
|
||||
}
|
||||
tokenCount = await this.client.countTokens(text, this.currentRequestCancellation.token)
|
||||
} else {
|
||||
console.warn("Cline <Language Model API>: Invalid input type for token counting")
|
||||
return 0
|
||||
}
|
||||
|
||||
// Validate the result
|
||||
if (typeof tokenCount !== "number") {
|
||||
console.warn("Cline <Language Model API>: Non-numeric token count received:", tokenCount)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (tokenCount < 0) {
|
||||
console.warn("Cline <Language Model API>: Negative token count received:", tokenCount)
|
||||
return 0
|
||||
}
|
||||
|
||||
return tokenCount
|
||||
} catch (error) {
|
||||
// Handle specific error types
|
||||
if (error instanceof vscode.CancellationError) {
|
||||
console.debug("Cline <Language Model API>: Token counting cancelled by user")
|
||||
return 0
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.warn("Cline <Language Model API>: Token counting failed:", errorMessage)
|
||||
|
||||
// Log additional error details if available
|
||||
if (error instanceof Error && error.stack) {
|
||||
console.debug("Token counting error stack:", error.stack)
|
||||
}
|
||||
|
||||
return 0 // Fallback to prevent stream interruption
|
||||
}
|
||||
/**
|
||||
* NOTE (intentional trade-off):
|
||||
* We use a coarse chars/4 heuristic here instead of a real tokenizer (e.g., js-tiktoken with o200k_base).
|
||||
* Rationale:
|
||||
* - Avoid pulling multi‑MB rank files and increasing the extension install/download size.
|
||||
* - Eliminate encoder lifecycle/memory concerns in long-running sessions.
|
||||
* Consequences:
|
||||
* - This is not model-accurate and can under/over-estimate tokens, especially with tool/function calls.
|
||||
* - It is “good enough” for budgeting/context checks, and we accept the inaccuracy by design.
|
||||
* If precise accounting becomes a requirement, reintroduce a tokenizer behind a feature flag or backend-only path.
|
||||
*/
|
||||
const textContent = typeof text === "string" ? text : this.extractTextFromMessage(text)
|
||||
return Math.ceil((textContent || "").length / 4)
|
||||
}
|
||||
|
||||
private async calculateTotalInputTokens(vsCodeLmMessages: vscode.LanguageModelChatMessage[]): Promise<number> {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
|
||||
import * as diskModule from "@core/storage/disk"
|
||||
import { expect } from "chai"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import * as path from "path"
|
||||
import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import type { FileMetadataEntry, TaskMetadata } from "./ContextTrackerTypes"
|
||||
import { FileContextTracker } from "./FileContextTracker"
|
||||
import * as diskModule from "@core/storage/disk"
|
||||
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
import type { DiffViewProviderCreator, WebviewProviderCreator } from "@/hosts/host-provider"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
|
||||
|
||||
describe("FileContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
@@ -54,14 +53,7 @@ describe("FileContextTracker", () => {
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
|
||||
// Reset HostProvider before initializing to avoid "already initialized" errors
|
||||
HostProvider.reset()
|
||||
HostProvider.initialize(
|
||||
((_) => {}) as WebviewProviderCreator,
|
||||
(() => {}) as DiffViewProviderCreator,
|
||||
vscodeHostBridgeClient,
|
||||
(_) => {},
|
||||
)
|
||||
setVscodeHostProviderMock()
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel, getRequestRegistry } from "./grpc-handler"
|
||||
import { Controller } from "@core/controller"
|
||||
import { GrpcRequest, GrpcCancel } from "@shared/WebviewMessage"
|
||||
import { serviceHandlers } from "@generated/hosts/vscode/protobus-services"
|
||||
|
||||
describe("grpc-handler", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockController: sinon.SinonStubbedInstance<Controller>
|
||||
|
||||
let mockUnaryHandler: sinon.SinonStub
|
||||
let mockUnaryFailingHandler: sinon.SinonStub
|
||||
let mockStreamingHandler: sinon.SinonStub
|
||||
let mockStreamingFailingHandler: sinon.SinonStub
|
||||
|
||||
const serviceName = "cline.TestService"
|
||||
const mockResponse = { result: "result-1234" }
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Create a mock controller
|
||||
mockController = {
|
||||
postMessageToWebview: sandbox.stub().resolves(),
|
||||
} as any
|
||||
|
||||
// Create mock service handlers
|
||||
mockUnaryHandler = sandbox.stub().resolves(mockResponse)
|
||||
mockStreamingHandler = sandbox.stub().resolves()
|
||||
mockUnaryFailingHandler = sandbox.stub().rejects(new Error("Test error unary"))
|
||||
mockStreamingFailingHandler = sandbox.stub().rejects(new Error("Stream error"))
|
||||
serviceHandlers[serviceName] = {
|
||||
testUnary: mockUnaryHandler,
|
||||
testUnaryFailing: mockUnaryFailingHandler,
|
||||
testStreaming: mockStreamingHandler,
|
||||
testStreamingFailing: mockStreamingFailingHandler,
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("handleGrpcRequest", () => {
|
||||
describe("Unary requests", () => {
|
||||
it("should handle successful unary requests", async () => {
|
||||
const request: GrpcRequest = {
|
||||
service: serviceName,
|
||||
method: "testUnary",
|
||||
message: { input: "test" },
|
||||
request_id: "test-123",
|
||||
is_streaming: false,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
|
||||
// Verify the handler was called
|
||||
expect(mockUnaryHandler.calledOnce).to.be.true
|
||||
expect(mockUnaryHandler.firstCall.args[0]).to.equal(mockController)
|
||||
expect(mockUnaryHandler.firstCall.args[1]).to.deep.equal({ input: "test" })
|
||||
|
||||
// Verify the response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: mockResponse,
|
||||
request_id: "test-123",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle errors in unary requests", async () => {
|
||||
const request: GrpcRequest = {
|
||||
service: serviceName,
|
||||
method: "testUnaryFailing",
|
||||
message: { input: "test" },
|
||||
request_id: "test-456",
|
||||
is_streaming: false,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
|
||||
// Verify the error response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
error: "Test error unary",
|
||||
request_id: "test-456",
|
||||
is_streaming: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle unknown service errors", async () => {
|
||||
const request: GrpcRequest = {
|
||||
service: "UnknownService",
|
||||
method: "someMethod",
|
||||
message: {},
|
||||
request_id: "test-789",
|
||||
is_streaming: false,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
|
||||
// Verify the error response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage.type).to.equal("grpc_response")
|
||||
expect(sentMessage.grpc_response?.error).to.include("Unknown service: UnknownService")
|
||||
expect(sentMessage.grpc_response?.request_id).to.equal("test-789")
|
||||
})
|
||||
|
||||
it("should handle unknown method errors", async () => {
|
||||
const request: GrpcRequest = {
|
||||
service: serviceName,
|
||||
method: "unknownMethod",
|
||||
message: {},
|
||||
request_id: "test-999",
|
||||
is_streaming: false,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
|
||||
// Verify the error response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage.type).to.equal("grpc_response")
|
||||
expect(sentMessage.grpc_response?.error).to.include("Unknown rpc: cline.TestService.unknownMethod")
|
||||
expect(sentMessage.grpc_response?.request_id).to.equal("test-999")
|
||||
})
|
||||
})
|
||||
describe("Streaming requests", () => {
|
||||
it("should handle successful streaming requests", async () => {
|
||||
// Set up a streaming handler that sends multiple responses
|
||||
const request: GrpcRequest = {
|
||||
service: serviceName,
|
||||
method: "testStreaming",
|
||||
message: { input: "stream" },
|
||||
request_id: "stream-123",
|
||||
is_streaming: true,
|
||||
}
|
||||
|
||||
// Reset the mock and set up the handler using callsFake
|
||||
mockStreamingHandler.reset()
|
||||
mockStreamingHandler.callsFake(async (controller: any, message: any, responseStream: any, requestId: string) => {
|
||||
// Simulate streaming multiple messages
|
||||
await responseStream({ value: 1 }, false, 0)
|
||||
await responseStream({ value: 2 }, false, 1)
|
||||
await responseStream({ value: 3 }, true, 2) // Last message
|
||||
})
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
|
||||
// Verify the handler was called
|
||||
expect(mockStreamingHandler.calledOnce).to.be.true
|
||||
expect(mockStreamingHandler.firstCall.args[0]).to.equal(mockController)
|
||||
expect(mockStreamingHandler.firstCall.args[1]).to.deep.equal({ input: "stream" })
|
||||
expect(mockStreamingHandler.firstCall.args[3]).to.equal("stream-123")
|
||||
|
||||
// Verify all streaming responses were sent
|
||||
expect(mockController.postMessageToWebview.callCount).to.equal(3)
|
||||
|
||||
// Check all responses
|
||||
expect(mockController.postMessageToWebview.firstCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: 1 },
|
||||
request_id: "stream-123",
|
||||
is_streaming: true,
|
||||
sequence_number: 0,
|
||||
},
|
||||
})
|
||||
expect(mockController.postMessageToWebview.secondCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: 2 },
|
||||
request_id: "stream-123",
|
||||
is_streaming: true,
|
||||
sequence_number: 1,
|
||||
},
|
||||
})
|
||||
expect(mockController.postMessageToWebview.thirdCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: 3 },
|
||||
request_id: "stream-123",
|
||||
is_streaming: false, // Last message has is_streaming: false
|
||||
sequence_number: 2,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle errors in streaming requests", async () => {
|
||||
const request: GrpcRequest = {
|
||||
service: serviceName,
|
||||
method: "testStreamingFailing",
|
||||
message: { input: "stream" },
|
||||
request_id: "stream-456",
|
||||
is_streaming: true,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
|
||||
// Verify the error response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
error: "Stream error",
|
||||
request_id: "stream-456",
|
||||
is_streaming: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle streaming with message, error, then another message", async () => {
|
||||
// This test simulates a scenario where:
|
||||
// 1. First message is sent successfully
|
||||
// 2. An error occurs
|
||||
// 3. Another message is attempted (which should not be sent after error)
|
||||
|
||||
const request: GrpcRequest = {
|
||||
service: serviceName,
|
||||
method: "testStreaming",
|
||||
message: { input: "stream-with-error" },
|
||||
request_id: "stream-error-mid",
|
||||
is_streaming: true,
|
||||
}
|
||||
|
||||
// Reset the mock and set up the handler to throw an error after being called
|
||||
mockStreamingHandler.reset()
|
||||
mockStreamingHandler.callsFake(async (controller: any, message: any, responseStream: any, requestId: string) => {
|
||||
// Send first message successfully
|
||||
await responseStream({ value: "first" }, false, 0)
|
||||
// Throw an error
|
||||
throw new Error("Mid-stream error")
|
||||
})
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
|
||||
// Verify the handler was called
|
||||
expect(mockStreamingHandler.calledOnce).to.be.true
|
||||
|
||||
// Verify that we got the first message and then the error
|
||||
expect(mockController.postMessageToWebview.callCount).to.equal(2)
|
||||
|
||||
// Check first message was sent successfully
|
||||
expect(mockController.postMessageToWebview.firstCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: "first" },
|
||||
request_id: "stream-error-mid",
|
||||
is_streaming: true,
|
||||
sequence_number: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Check error response was sent
|
||||
expect(mockController.postMessageToWebview.secondCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
error: "Mid-stream error",
|
||||
request_id: "stream-error-mid",
|
||||
is_streaming: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Try to send another message after the error (simulating what might happen
|
||||
// if the handler tried to continue after an error)
|
||||
const responseStream = mockStreamingHandler.firstCall.args[2]
|
||||
|
||||
// This should still work as the responseStream function is still valid
|
||||
await responseStream({ value: "after-error" }, false, 1)
|
||||
|
||||
// Verify we now have 3 total calls (first message, error, after-error message)
|
||||
expect(mockController.postMessageToWebview.callCount).to.equal(3)
|
||||
|
||||
// Verify the message after error was still sent
|
||||
// (In a real scenario, the handler would have stopped due to the error,
|
||||
// but this tests that the responseStream function itself still works)
|
||||
expect(mockController.postMessageToWebview.thirdCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: "after-error" },
|
||||
request_id: "stream-error-mid",
|
||||
is_streaming: true,
|
||||
sequence_number: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleGrpcRequestCancel", () => {
|
||||
it("should cancel an active request", async () => {
|
||||
// Register a request in the registry
|
||||
const registry = getRequestRegistry()
|
||||
const cleanupStub = sandbox.stub()
|
||||
registry.registerRequest("cancel-123", cleanupStub)
|
||||
|
||||
const cancelRequest: GrpcCancel = {
|
||||
request_id: "cancel-123",
|
||||
}
|
||||
|
||||
await handleGrpcRequestCancel(mockController as any, cancelRequest)
|
||||
|
||||
// Verify the cleanup was called
|
||||
expect(cleanupStub.calledOnce).to.be.true
|
||||
|
||||
// Verify the cancellation confirmation was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { cancelled: true },
|
||||
request_id: "cancel-123",
|
||||
is_streaming: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the request was removed from the registry
|
||||
expect(registry.hasRequest("cancel-123")).to.be.false
|
||||
})
|
||||
|
||||
it("should handle cancellation of non-existent request", async () => {
|
||||
const cancelRequest: GrpcCancel = {
|
||||
request_id: "non-existent",
|
||||
}
|
||||
|
||||
await handleGrpcRequestCancel(mockController as any, cancelRequest)
|
||||
|
||||
// Verify no message was sent (request not found)
|
||||
expect(mockController.postMessageToWebview.called).to.be.false
|
||||
})
|
||||
|
||||
it("should handle cleanup errors gracefully", async () => {
|
||||
// Register a request with a failing cleanup
|
||||
const registry = getRequestRegistry()
|
||||
const cleanupStub = sandbox.stub().throws(new Error("Cleanup failed"))
|
||||
registry.registerRequest("cancel-error", cleanupStub)
|
||||
|
||||
const cancelRequest: GrpcCancel = {
|
||||
request_id: "cancel-error",
|
||||
}
|
||||
|
||||
// Should not throw
|
||||
await handleGrpcRequestCancel(mockController as any, cancelRequest)
|
||||
|
||||
// Verify the cleanup was attempted
|
||||
expect(cleanupStub.calledOnce).to.be.true
|
||||
|
||||
// Verify the cancellation confirmation was still sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
|
||||
// Verify the request was removed despite the error
|
||||
expect(registry.hasRequest("cancel-error")).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe("Concurrent requests", () => {
|
||||
it("should handle concurrent requests", async () => {
|
||||
// Set up handlers
|
||||
mockUnaryHandler.resolves({ result: "unary" })
|
||||
mockStreamingHandler.callsFake(async (controller: any, message: any, responseStream: any) => {
|
||||
await responseStream({ value: "stream1" }, false, 0)
|
||||
await responseStream({ value: "stream2" }, true, 1)
|
||||
})
|
||||
|
||||
// Send multiple requests concurrently
|
||||
const requests = [
|
||||
handleGrpcRequest(mockController as any, {
|
||||
service: serviceName,
|
||||
method: "testUnary",
|
||||
message: { id: 1 },
|
||||
request_id: "concurrent-1",
|
||||
is_streaming: false,
|
||||
}),
|
||||
handleGrpcRequest(mockController as any, {
|
||||
service: serviceName,
|
||||
method: "testStreaming",
|
||||
message: { id: 2 },
|
||||
request_id: "concurrent-2",
|
||||
is_streaming: true,
|
||||
}),
|
||||
handleGrpcRequest(mockController as any, {
|
||||
service: serviceName,
|
||||
method: "testUnary",
|
||||
message: { id: 3 },
|
||||
request_id: "concurrent-3",
|
||||
is_streaming: false,
|
||||
}),
|
||||
]
|
||||
|
||||
await Promise.all(requests)
|
||||
|
||||
// Verify all handlers were called
|
||||
expect(mockUnaryHandler.callCount).to.equal(2)
|
||||
expect(mockStreamingHandler.callCount).to.equal(1)
|
||||
|
||||
// Verify all responses were sent (2 unary + 2 streaming)
|
||||
expect(mockController.postMessageToWebview.callCount).to.equal(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller } from "./index"
|
||||
import { serviceHandlers } from "@generated/hosts/vscode/protobus-services"
|
||||
import { GrpcRequestRegistry } from "./grpc-request-registry"
|
||||
import { GrpcCancel, GrpcRequest } from "@/shared/WebviewMessage"
|
||||
|
||||
/**
|
||||
* Type definition for a streaming response handler
|
||||
@@ -12,153 +13,34 @@ export type StreamingResponseHandler<TResponse> = (
|
||||
) => Promise<void>
|
||||
|
||||
/**
|
||||
* Handles gRPC requests from the webview
|
||||
* Handles a gRPC request from the webview.
|
||||
*/
|
||||
export class GrpcHandler {
|
||||
constructor(private controller: Controller) {}
|
||||
|
||||
/**
|
||||
* Handle a gRPC request from the webview
|
||||
* @param service The service name
|
||||
* @param method The method name
|
||||
* @param message The request message
|
||||
* @param requestId The request ID for response correlation
|
||||
* @param isStreaming Whether this is a streaming request
|
||||
* @returns The response message or error for unary requests, void for streaming requests
|
||||
*/
|
||||
async handleRequest(
|
||||
service: string,
|
||||
method: string,
|
||||
message: any,
|
||||
requestId: string,
|
||||
isStreaming: boolean = false,
|
||||
): Promise<{
|
||||
message?: any
|
||||
error?: string
|
||||
request_id: string
|
||||
} | void> {
|
||||
try {
|
||||
// If this is a streaming request, use the streaming handler
|
||||
if (isStreaming) {
|
||||
await this.handleStreamingRequest(service, method, message, requestId)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the service handler from the config
|
||||
const handler = getHandler(service, method)
|
||||
|
||||
// Handle unary request
|
||||
return {
|
||||
message: await handler(this.controller, message),
|
||||
request_id: requestId,
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Protobus error:", error)
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
request_id: requestId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a streaming gRPC request
|
||||
* @param service The service name
|
||||
* @param method The method name
|
||||
* @param message The request message
|
||||
* @param requestId The request ID for response correlation
|
||||
*/
|
||||
private async handleStreamingRequest(service: string, method: string, message: any, requestId: string): Promise<void> {
|
||||
// Create a response stream function
|
||||
const responseStream: StreamingResponseHandler<any> = async (
|
||||
response: any,
|
||||
isLast: boolean = false,
|
||||
sequenceNumber?: number,
|
||||
) => {
|
||||
await this.controller.postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: response,
|
||||
request_id: requestId,
|
||||
is_streaming: !isLast,
|
||||
sequence_number: sequenceNumber,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the service handler from the config
|
||||
const handler = getHandler(service, method)
|
||||
|
||||
// Handle streaming request and pass the requestId to all streaming handlers
|
||||
await handler(this.controller, message, responseStream, requestId)
|
||||
|
||||
// Don't send a final message here - the stream should stay open for future updates
|
||||
// The stream will be closed when the client disconnects or when the service explicitly ends it
|
||||
} catch (error) {
|
||||
// Send error response
|
||||
console.log("Protobus error:", error)
|
||||
await this.controller.postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
request_id: requestId,
|
||||
is_streaming: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
export async function handleGrpcRequest(controller: Controller, request: GrpcRequest): Promise<void> {
|
||||
if (request.is_streaming) {
|
||||
await handleStreamingRequest(controller, request)
|
||||
} else {
|
||||
await handleUnaryRequest(controller, request)
|
||||
}
|
||||
}
|
||||
|
||||
// Registry to track active gRPC requests and their cleanup functions
|
||||
const requestRegistry = new GrpcRequestRegistry()
|
||||
|
||||
/**
|
||||
* Handle a gRPC request from the webview
|
||||
* @param controller The controller instance
|
||||
* @param request The gRPC request
|
||||
* Handles a gRPC unary request from the webview.
|
||||
*
|
||||
* Calls the handler using the service and method name, and then posts the result back to the webview.
|
||||
*/
|
||||
export async function handleGrpcRequest(
|
||||
controller: Controller,
|
||||
request: {
|
||||
service: string
|
||||
method: string
|
||||
message: any
|
||||
request_id: string
|
||||
is_streaming?: boolean
|
||||
},
|
||||
) {
|
||||
async function handleUnaryRequest(controller: Controller, request: GrpcRequest): Promise<void> {
|
||||
try {
|
||||
const grpcHandler = new GrpcHandler(controller)
|
||||
|
||||
// For streaming requests, handleRequest handles sending responses directly
|
||||
if (request.is_streaming) {
|
||||
try {
|
||||
await grpcHandler.handleRequest(request.service, request.method, request.message, request.request_id, true)
|
||||
} finally {
|
||||
// Note: We don't automatically clean up here anymore
|
||||
// The request will be cleaned up when it completes or is cancelled
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For unary requests, we get a response and send it back
|
||||
const response = (await grpcHandler.handleRequest(
|
||||
request.service,
|
||||
request.method,
|
||||
request.message,
|
||||
request.request_id,
|
||||
false,
|
||||
)) as {
|
||||
message?: any
|
||||
error?: string
|
||||
request_id: string
|
||||
}
|
||||
|
||||
// Send the response back to the webview
|
||||
// Get the service handler from the config
|
||||
const handler = getHandler(request.service, request.method)
|
||||
// Handle unary request
|
||||
const response = await handler(controller, request.message)
|
||||
// Send response to the webview
|
||||
await controller.postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: response,
|
||||
grpc_response: {
|
||||
message: response,
|
||||
request_id: request.request_id,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// Send error response
|
||||
@@ -168,22 +50,65 @@ export async function handleGrpcRequest(
|
||||
grpc_response: {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
request_id: request.request_id,
|
||||
is_streaming: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a gRPC request cancellation from the webview
|
||||
* Handle a streaming gRPC request from the webview.
|
||||
*
|
||||
* Calls the handler using the service and method name, and creates a streaming response handler
|
||||
* which posts results back to the webview.
|
||||
*/
|
||||
async function handleStreamingRequest(controller: Controller, request: GrpcRequest): Promise<void> {
|
||||
// Create a response stream function
|
||||
const responseStream: StreamingResponseHandler<any> = async (
|
||||
response: any,
|
||||
isLast: boolean = false,
|
||||
sequenceNumber?: number,
|
||||
) => {
|
||||
await controller.postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: response,
|
||||
request_id: request.request_id,
|
||||
is_streaming: !isLast,
|
||||
sequence_number: sequenceNumber,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the service handler from the config
|
||||
const handler = getHandler(request.service, request.method)
|
||||
|
||||
// Handle streaming request and pass the requestId to all streaming handlers
|
||||
await handler(controller, request.message, responseStream, request.request_id)
|
||||
|
||||
// Don't send a final message here - the stream should stay open for future updates
|
||||
// The stream will be closed when the client disconnects or when the service explicitly ends it
|
||||
} catch (error) {
|
||||
// Send error response
|
||||
console.log("Protobus error:", error)
|
||||
await controller.postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
request_id: request.request_id,
|
||||
is_streaming: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a gRPC request cancellation from the webview.
|
||||
* @param controller The controller instance
|
||||
* @param request The cancellation request
|
||||
*/
|
||||
export async function handleGrpcRequestCancel(
|
||||
controller: Controller,
|
||||
request: {
|
||||
request_id: string
|
||||
},
|
||||
) {
|
||||
export async function handleGrpcRequestCancel(controller: Controller, request: GrpcCancel) {
|
||||
const cancelled = requestRegistry.cancelRequest(request.request_id)
|
||||
|
||||
if (cancelled) {
|
||||
@@ -201,6 +126,17 @@ export async function handleGrpcRequestCancel(
|
||||
}
|
||||
}
|
||||
|
||||
// Registry to track active gRPC requests and their cleanup functions
|
||||
const requestRegistry = new GrpcRequestRegistry()
|
||||
|
||||
/**
|
||||
* Get the request registry instance
|
||||
* This allows other parts of the code to access the registry
|
||||
*/
|
||||
export function getRequestRegistry(): GrpcRequestRegistry {
|
||||
return requestRegistry
|
||||
}
|
||||
|
||||
function getHandler(serviceName: string, methodName: string): any {
|
||||
// Get the service handler from the config
|
||||
const serviceConfig = serviceHandlers[serviceName]
|
||||
@@ -213,11 +149,3 @@ function getHandler(serviceName: string, methodName: string): any {
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the request registry instance
|
||||
* This allows other parts of the code to access the registry
|
||||
*/
|
||||
export function getRequestRegistry(): GrpcRequestRegistry {
|
||||
return requestRegistry
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clineEnvConfig } from "@/config"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { PostHogClientProvider, telemetryService } from "@/services/posthog/PostHogClientProvider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
@@ -30,13 +30,13 @@ import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
|
||||
import { getAllExtensionState, getGlobalState, getWorkspaceState, storeSecret, updateGlobalState } from "../storage/state"
|
||||
import { CacheService, PersistenceErrorEvent } from "../storage/CacheService"
|
||||
import { Task } from "../task"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { sendStateUpdate } from "./state/subscribeToState"
|
||||
import { sendAddToInputEvent } from "./ui/subscribeToAddToInput"
|
||||
import { getLatestAnnouncementId } from "@/extension"
|
||||
|
||||
import { getLatestAnnouncementId } from "@/utils/announcements"
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
|
||||
@@ -53,7 +53,7 @@ export class Controller {
|
||||
workspaceTracker: WorkspaceTracker
|
||||
mcpHub: McpHub
|
||||
accountService: ClineAccountService
|
||||
authService: AuthService
|
||||
readonly cacheService: CacheService
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
@@ -64,17 +64,45 @@ export class Controller {
|
||||
|
||||
HostProvider.get().logToChannel("ClineProvider instantiated")
|
||||
this.postMessage = postMessage
|
||||
this.accountService = ClineAccountService.getInstance()
|
||||
this.cacheService = new CacheService(context)
|
||||
const authService = AuthService.getInstance(this)
|
||||
|
||||
// Initialize cache service asynchronously - critical for extension functionality
|
||||
this.cacheService
|
||||
.initialize()
|
||||
.then(() => {
|
||||
authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("CRITICAL: Failed to initialize CacheService - extension may not function properly:", error)
|
||||
})
|
||||
|
||||
// Set up persistence error recovery
|
||||
this.cacheService.onPersistenceError = async ({ error }: PersistenceErrorEvent) => {
|
||||
console.error("Cache persistence failed, recovering:", error)
|
||||
try {
|
||||
await this.cacheService.reInitialize()
|
||||
await this.postStateToWebview()
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.WARNING,
|
||||
message: "Saving settings to storage failed.",
|
||||
})
|
||||
} catch (recoveryError) {
|
||||
console.error("Cache recovery failed:", recoveryError)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to save settings. Please restart the extension.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.workspaceTracker = new WorkspaceTracker()
|
||||
this.mcpHub = new McpHub(
|
||||
() => ensureMcpServersDirectoryExists(),
|
||||
() => ensureSettingsDirectoryExists(this.context),
|
||||
(msg) => this.postMessageToWebview(msg),
|
||||
this.context.extension?.packageJSON?.version ?? "1.0.0",
|
||||
)
|
||||
this.accountService = ClineAccountService.getInstance()
|
||||
this.authService = AuthService.getInstance(context)
|
||||
this.authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
|
||||
// Clean up legacy checkpoints
|
||||
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath).catch((error) => {
|
||||
@@ -109,12 +137,18 @@ export class Controller {
|
||||
async handleSignOut() {
|
||||
try {
|
||||
// TODO: update to clineAccountId and then move clineApiKey to a clear function.
|
||||
await storeSecret(this.context, "clineAccountId", undefined)
|
||||
this.cacheService.setSecret("clineAccountId", undefined)
|
||||
await updateGlobalState(this.context, "userInfo", undefined)
|
||||
await Promise.all([
|
||||
updateGlobalState(this.context, "planModeApiProvider", "openrouter"),
|
||||
updateGlobalState(this.context, "actModeApiProvider", "openrouter"),
|
||||
])
|
||||
|
||||
// Update API providers through cache service
|
||||
const apiConfiguration = this.cacheService.getApiConfiguration()
|
||||
const updatedConfig = {
|
||||
...apiConfiguration,
|
||||
planModeApiProvider: "openrouter" as ApiProvider,
|
||||
actModeApiProvider: "openrouter" as ApiProvider,
|
||||
}
|
||||
this.cacheService.setApiConfiguration(updatedConfig)
|
||||
|
||||
await this.postStateToWebview()
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
@@ -134,8 +168,11 @@ export class Controller {
|
||||
|
||||
async initTask(task?: string, images?: string[], files?: string[], historyItem?: HistoryItem) {
|
||||
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
|
||||
|
||||
// Get API configuration from cache for immediate access
|
||||
const apiConfiguration = this.cacheService.getApiConfiguration()
|
||||
|
||||
const {
|
||||
apiConfiguration,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
preferredLanguage,
|
||||
@@ -148,6 +185,7 @@ export class Controller {
|
||||
enableCheckpointsSetting,
|
||||
isNewUser,
|
||||
taskHistory,
|
||||
strictPlanModeEnabled,
|
||||
} = await getAllExtensionState(this.context)
|
||||
|
||||
const NEW_USER_TASK_COUNT_THRESHOLD = 10
|
||||
@@ -179,12 +217,14 @@ export class Controller {
|
||||
preferredLanguage,
|
||||
openaiReasoningEffort,
|
||||
mode,
|
||||
strictPlanModeEnabled ?? false,
|
||||
shellIntegrationTimeout,
|
||||
terminalReuseEnabled ?? true,
|
||||
terminalOutputLineLimit ?? 500,
|
||||
defaultTerminalProfile ?? "default",
|
||||
enableCheckpointsSetting ?? true,
|
||||
await getCwd(getDesktopDir()),
|
||||
this.cacheService,
|
||||
task,
|
||||
images,
|
||||
files,
|
||||
@@ -212,10 +252,6 @@ export class Controller {
|
||||
*/
|
||||
async handleWebviewMessage(message: WebviewMessage) {
|
||||
switch (message.type) {
|
||||
case "fetchMcpMarketplace": {
|
||||
await this.fetchMcpMarketplace(message.bool)
|
||||
break
|
||||
}
|
||||
case "grpc_request": {
|
||||
if (message.grpc_request) {
|
||||
await handleGrpcRequest(this, message.grpc_request)
|
||||
@@ -228,9 +264,9 @@ export class Controller {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Add more switch case statements here as more webview message commands
|
||||
// are created within the webview context (i.e. inside media/main.js)
|
||||
default: {
|
||||
console.error("Received unhandled WebviewMessage type:", JSON.stringify(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,14 +288,14 @@ export class Controller {
|
||||
|
||||
// Update API handler with new mode (buildApiHandler now selects provider based on mode)
|
||||
if (this.task) {
|
||||
const { apiConfiguration } = await getAllExtensionState(this.context)
|
||||
const apiConfiguration = this.cacheService.getApiConfiguration()
|
||||
this.task.api = buildApiHandler({ ...apiConfiguration, taskId: this.task.taskId }, modeToSwitchTo)
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
|
||||
if (this.task) {
|
||||
this.task.mode = modeToSwitchTo
|
||||
this.task.updateMode(modeToSwitchTo)
|
||||
if (this.task.taskState.isAwaitingPlanResponse && didSwitchToActMode) {
|
||||
this.task.taskState.didRespondToPlanAskBySwitchingMode = true
|
||||
// Use chatContent if provided, otherwise use default message
|
||||
@@ -311,7 +347,7 @@ export class Controller {
|
||||
|
||||
async handleAuthCallback(customToken: string, provider: string | null = null) {
|
||||
try {
|
||||
await this.authService.handleAuthCallback(customToken, provider ? provider : "google")
|
||||
await AuthService.getInstance(this).handleAuthCallback(customToken, provider ? provider : "google")
|
||||
|
||||
const clineProvider: ApiProvider = "cline"
|
||||
|
||||
@@ -319,27 +355,26 @@ export class Controller {
|
||||
const { planActSeparateModelsSetting } = await getAllExtensionState(this.context)
|
||||
const currentMode = await this.getCurrentMode()
|
||||
|
||||
// Get current API configuration from cache
|
||||
const currentApiConfiguration = this.cacheService.getApiConfiguration()
|
||||
|
||||
const updatedConfig = { ...currentApiConfiguration }
|
||||
|
||||
if (planActSeparateModelsSetting) {
|
||||
// Only update the current mode's provider
|
||||
if (currentMode === "plan") {
|
||||
await updateGlobalState(this.context, "planModeApiProvider", clineProvider)
|
||||
updatedConfig.planModeApiProvider = clineProvider
|
||||
} else {
|
||||
await updateGlobalState(this.context, "actModeApiProvider", clineProvider)
|
||||
updatedConfig.actModeApiProvider = clineProvider
|
||||
}
|
||||
} else {
|
||||
// Update both modes to keep them in sync
|
||||
await Promise.all([
|
||||
updateGlobalState(this.context, "planModeApiProvider", clineProvider),
|
||||
updateGlobalState(this.context, "actModeApiProvider", clineProvider),
|
||||
])
|
||||
updatedConfig.planModeApiProvider = clineProvider
|
||||
updatedConfig.actModeApiProvider = clineProvider
|
||||
}
|
||||
|
||||
// Get the updated API configuration (now includes the updated providers)
|
||||
const { apiConfiguration } = await getAllExtensionState(this.context)
|
||||
const updatedConfig = {
|
||||
...apiConfiguration,
|
||||
apiProvider: clineProvider,
|
||||
}
|
||||
// Update the API configuration through cache service
|
||||
this.cacheService.setApiConfiguration(updatedConfig)
|
||||
|
||||
// Mark welcome view as completed since user has successfully logged in
|
||||
await updateGlobalState(this.context, "welcomeViewCompleted", true)
|
||||
@@ -458,31 +493,6 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMcpMarketplace(forceRefresh: boolean = false) {
|
||||
try {
|
||||
// Check if we have cached data
|
||||
const cachedCatalog = (await getGlobalState(this.context, "mcpMarketplaceCatalog")) as
|
||||
| McpMarketplaceCatalog
|
||||
| undefined
|
||||
if (!forceRefresh && cachedCatalog?.items) {
|
||||
await sendMcpMarketplaceCatalogEvent(cachedCatalog)
|
||||
return
|
||||
}
|
||||
|
||||
const catalog = await this.fetchMcpMarketplaceFromApi(false)
|
||||
if (catalog) {
|
||||
await sendMcpMarketplaceCatalogEvent(catalog)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to handle cached MCP marketplace:", error)
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to handle cached MCP marketplace"
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter
|
||||
|
||||
async handleOpenRouterCallback(code: string) {
|
||||
@@ -501,21 +511,20 @@ export class Controller {
|
||||
|
||||
const openrouter: ApiProvider = "openrouter"
|
||||
const currentMode = await this.getCurrentMode()
|
||||
await Promise.all([
|
||||
updateGlobalState(this.context, "planModeApiProvider", openrouter),
|
||||
updateGlobalState(this.context, "actModeApiProvider", openrouter),
|
||||
])
|
||||
await storeSecret(this.context, "openRouterApiKey", apiKey)
|
||||
|
||||
// Update API configuration through cache service
|
||||
const currentApiConfiguration = this.cacheService.getApiConfiguration()
|
||||
const updatedConfig = {
|
||||
...currentApiConfiguration,
|
||||
planModeApiProvider: openrouter,
|
||||
actModeApiProvider: openrouter,
|
||||
openRouterApiKey: apiKey,
|
||||
}
|
||||
this.cacheService.setApiConfiguration(updatedConfig)
|
||||
|
||||
await this.postStateToWebview()
|
||||
if (this.task) {
|
||||
// Get the updated API configuration (now includes the updated providers)
|
||||
const { apiConfiguration } = await getAllExtensionState(this.context)
|
||||
const updatedConfig = {
|
||||
...apiConfiguration,
|
||||
openRouterApiKey: apiKey,
|
||||
taskId: this.task.taskId,
|
||||
}
|
||||
this.task.api = buildApiHandler(updatedConfig, currentMode)
|
||||
this.task.api = buildApiHandler({ ...updatedConfig, taskId: this.task.taskId }, currentMode)
|
||||
}
|
||||
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
|
||||
}
|
||||
@@ -689,8 +698,10 @@ export class Controller {
|
||||
}
|
||||
|
||||
async getStateToPostToWebview(): Promise<ExtensionState> {
|
||||
// Get API configuration from cache for immediate access
|
||||
const apiConfiguration = this.cacheService.getApiConfiguration()
|
||||
|
||||
const {
|
||||
apiConfiguration,
|
||||
lastShownAnnouncementId,
|
||||
taskHistory,
|
||||
autoApprovalSettings,
|
||||
@@ -698,6 +709,7 @@ export class Controller {
|
||||
preferredLanguage,
|
||||
openaiReasoningEffort,
|
||||
mode,
|
||||
strictPlanModeEnabled,
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
mcpDisplayMode,
|
||||
@@ -731,7 +743,7 @@ export class Controller {
|
||||
const latestAnnouncementId = getLatestAnnouncementId(this.context)
|
||||
const shouldShowAnnouncement = lastShownAnnouncementId !== latestAnnouncementId
|
||||
const platform = process.platform as Platform
|
||||
const distinctId = telemetryService.distinctId
|
||||
const distinctId = PostHogClientProvider.getInstance().distinctId
|
||||
const version = this.context.extension?.packageJSON?.version ?? ""
|
||||
const uriScheme = vscode.env.uriScheme
|
||||
|
||||
@@ -750,6 +762,7 @@ export class Controller {
|
||||
preferredLanguage,
|
||||
openaiReasoningEffort,
|
||||
mode,
|
||||
strictPlanModeEnabled,
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
mcpDisplayMode,
|
||||
@@ -831,18 +844,4 @@ export class Controller {
|
||||
await updateGlobalState(this.context, "taskHistory", history)
|
||||
return history
|
||||
}
|
||||
|
||||
// private async clearState() {
|
||||
// this.context.workspaceState.keys().forEach((key) => {
|
||||
// this.context.workspaceState.update(key, undefined)
|
||||
// })
|
||||
// this.context.globalState.keys().forEach((key) => {
|
||||
// this.context.globalState.update(key, undefined)
|
||||
// })
|
||||
// this.context.secrets.delete("apiKey")
|
||||
// }
|
||||
|
||||
// secrets
|
||||
|
||||
// dev
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Controller } from ".."
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "@shared/proto/cline/models"
|
||||
import { getAllExtensionState } from "../../storage/state"
|
||||
import { basetenModels } from "../../../shared/api"
|
||||
import axios from "axios"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { GlobalFileNames } from "@core/storage/disk"
|
||||
|
||||
/**
|
||||
* Refreshes the Baseten models and returns the updated model list
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request object
|
||||
* @returns Response containing the Baseten models
|
||||
*/
|
||||
export async function refreshBasetenModels(
|
||||
controller: Controller,
|
||||
request: EmptyRequest,
|
||||
): Promise<OpenRouterCompatibleModelInfo> {
|
||||
console.log("=== refreshBasetenModels called ===")
|
||||
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.basetenModels)
|
||||
|
||||
// Get the Baseten API key from the controller's state
|
||||
const { apiConfiguration } = await getAllExtensionState(controller.context)
|
||||
const basetenApiKey = apiConfiguration?.basetenApiKey
|
||||
|
||||
let models: Record<string, Partial<OpenRouterModelInfo>> = {}
|
||||
try {
|
||||
if (!basetenApiKey) {
|
||||
console.log("No Baseten API key found, using static models as fallback")
|
||||
// Don't throw an error, just use static models
|
||||
for (const [modelId, modelInfo] of Object.entries(basetenModels)) {
|
||||
models[modelId] = {
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
supportsImages: modelInfo.supportsImages,
|
||||
supportsPromptCache: modelInfo.supportsPromptCache,
|
||||
inputPrice: modelInfo.inputPrice,
|
||||
outputPrice: modelInfo.outputPrice,
|
||||
cacheWritesPrice: (modelInfo as any).cacheWritesPrice || 0,
|
||||
cacheReadsPrice: (modelInfo as any).cacheReadsPrice || 0,
|
||||
description: (modelInfo as any).description || `${modelId} model`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ensure the API key is properly formatted
|
||||
const cleanApiKey = basetenApiKey.trim()
|
||||
if (!cleanApiKey) {
|
||||
throw new Error("Invalid Baseten API key format")
|
||||
}
|
||||
|
||||
console.log("Fetching Baseten models with API key:", cleanApiKey.substring(0, 10) + "...")
|
||||
|
||||
const response = await axios.get("https://inference.baseten.co/v1/models", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cleanApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Cline-VSCode-Extension",
|
||||
},
|
||||
timeout: 10000, // 10 second timeout
|
||||
})
|
||||
|
||||
if (response.data?.data) {
|
||||
const rawModels = response.data.data
|
||||
|
||||
for (const rawModel of rawModels) {
|
||||
// Filter out non-chat models and validate model capabilities
|
||||
if (!isValidChatModel(rawModel)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only include models that are listed in the static basetenModels
|
||||
if (!(rawModel.id in basetenModels)) {
|
||||
console.log(`Skipping model ${rawModel.id} - not in static basetenModels list`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we have static pricing information for this model
|
||||
const staticModelInfo = basetenModels[rawModel.id as keyof typeof basetenModels]
|
||||
|
||||
const modelInfo: Partial<OpenRouterModelInfo> = {
|
||||
maxTokens: staticModelInfo?.maxTokens || 8192,
|
||||
contextWindow: staticModelInfo?.contextWindow || 8192,
|
||||
supportsImages: staticModelInfo?.supportsImages || false,
|
||||
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
|
||||
inputPrice: staticModelInfo?.inputPrice || 0,
|
||||
outputPrice: staticModelInfo?.outputPrice || 0,
|
||||
cacheWritesPrice: staticModelInfo?.cacheWritesPrice || 0,
|
||||
cacheReadsPrice: staticModelInfo?.cacheReadsPrice || 0,
|
||||
description: generateModelDescription(rawModel, staticModelInfo),
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
}
|
||||
} else {
|
||||
console.error("Invalid response from Baseten API")
|
||||
}
|
||||
await fs.writeFile(basetenModelsFilePath, JSON.stringify(models))
|
||||
console.log("Baseten models fetched and saved:", Object.keys(models))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching Baseten models:", error)
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = "Unknown error occurred"
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 401) {
|
||||
errorMessage = "Invalid Baseten API key. Please check your API key in settings."
|
||||
} else if (error.response?.status === 403) {
|
||||
errorMessage = "Access forbidden. Please verify your Baseten API key has the correct permissions."
|
||||
} else if (error.response?.status === 429) {
|
||||
errorMessage = "Rate limit exceeded. Please try again later."
|
||||
} else if (error.code === "ECONNABORTED") {
|
||||
errorMessage = "Request timeout. Please check your internet connection."
|
||||
} else {
|
||||
errorMessage = `API request failed: ${error.response?.status || error.code || "Unknown error"}`
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
console.error("Baseten API Error:", errorMessage)
|
||||
|
||||
// If we failed to fetch models, try to read cached models first
|
||||
const cachedModels = await readBasetenModels(controller)
|
||||
if (cachedModels && Object.keys(cachedModels).length > 0) {
|
||||
console.log("Using cached Baseten models")
|
||||
// Filter cached models to only include those in static basetenModels
|
||||
for (const [modelId, modelInfo] of Object.entries(cachedModels)) {
|
||||
if (modelId in basetenModels) {
|
||||
models[modelId] = modelInfo
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to static models from shared/api.ts
|
||||
console.log("Using static Baseten models as fallback")
|
||||
for (const [modelId, modelInfo] of Object.entries(basetenModels)) {
|
||||
models[modelId] = {
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
supportsImages: modelInfo.supportsImages,
|
||||
supportsPromptCache: modelInfo.supportsPromptCache,
|
||||
inputPrice: modelInfo.inputPrice,
|
||||
outputPrice: modelInfo.outputPrice,
|
||||
cacheWritesPrice: (modelInfo as any).cacheWritesPrice || 0,
|
||||
cacheReadsPrice: (modelInfo as any).cacheReadsPrice || 0,
|
||||
description: (modelInfo as any).description || `${modelId} model`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the Record<string, Partial<OpenRouterModelInfo>> to Record<string, OpenRouterModelInfo>
|
||||
// by filling in any missing required fields with defaults
|
||||
const typedModels: Record<string, OpenRouterModelInfo> = {}
|
||||
for (const [key, model] of Object.entries(models)) {
|
||||
typedModels[key] = {
|
||||
maxTokens: model.maxTokens ?? 8192,
|
||||
contextWindow: model.contextWindow ?? 8192,
|
||||
supportsImages: model.supportsImages ?? false,
|
||||
supportsPromptCache: model.supportsPromptCache ?? false,
|
||||
inputPrice: model.inputPrice ?? 0,
|
||||
outputPrice: model.outputPrice ?? 0,
|
||||
cacheWritesPrice: model.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: model.cacheReadsPrice ?? 0,
|
||||
description: model.description ?? "",
|
||||
tiers: model.tiers ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
return OpenRouterCompatibleModelInfo.create({ models: typedModels })
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the cache directory exists and returns its path
|
||||
*/
|
||||
async function ensureCacheDirectoryExists(controller: Controller): Promise<string> {
|
||||
const cacheDir = path.join(controller.context.globalStorageUri.fsPath, "cache")
|
||||
await fs.mkdir(cacheDir, { recursive: true })
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads cached Baseten models from disk
|
||||
*/
|
||||
async function readBasetenModels(controller: Controller): Promise<Record<string, Partial<OpenRouterModelInfo>> | undefined> {
|
||||
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.basetenModels)
|
||||
const fileExists = await fileExistsAtPath(basetenModelsFilePath)
|
||||
if (fileExists) {
|
||||
try {
|
||||
const fileContents = await fs.readFile(basetenModelsFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
} catch (error) {
|
||||
console.error("Error reading cached Baseten models:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a model is suitable for chat completions
|
||||
*/
|
||||
function isValidChatModel(rawModel: any): boolean {
|
||||
// Filter out non-chat models (whisper, TTS, guard models, etc.)
|
||||
if (rawModel.id.includes("whisper") || rawModel.id.includes("tts") || rawModel.id.includes("embedding")) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if model supports chat completions
|
||||
if (rawModel.object === "model" && rawModel.id) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a descriptive name for the model
|
||||
*/
|
||||
function generateModelDescription(rawModel: any, staticModelInfo?: any): string {
|
||||
// Use static description if available
|
||||
if (staticModelInfo?.description) {
|
||||
return staticModelInfo.description
|
||||
}
|
||||
|
||||
// Generate description based on model characteristics
|
||||
const modelId = rawModel.id
|
||||
const ownedBy = rawModel.owned_by || "Unknown"
|
||||
|
||||
return `${ownedBy} model: ${modelId}`
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { GlobalFileNames } from "@core/storage/disk"
|
||||
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
|
||||
|
||||
/**
|
||||
* Refreshes the Groq models and returns the updated model list
|
||||
@@ -111,7 +112,12 @@ export async function refreshGroqModels(controller: Controller, request: EmptyRe
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
console.error("Groq API Error:", errorMessage)
|
||||
telemetryService.captureProviderApiError({
|
||||
taskId: controller.task?.taskId || "",
|
||||
errorMessage,
|
||||
errorStatus: error.status,
|
||||
model: "groq",
|
||||
})
|
||||
|
||||
// If we failed to fetch models, try to read cached models first
|
||||
const cachedModels = await readGroqModels(controller)
|
||||
@@ -181,7 +187,7 @@ async function readGroqModels(controller: Controller): Promise<Record<string, Pa
|
||||
*/
|
||||
function isValidChatModel(rawModel: any): boolean {
|
||||
// Check if model is active (if the property exists)
|
||||
if (rawModel.hasOwnProperty("active") && !rawModel.active) {
|
||||
if (Object.hasOwn(rawModel, "active") && !rawModel.active) {
|
||||
return false
|
||||
}
|
||||
// Filter out non-chat models (whisper, TTS, guard models, etc.)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Controller } from "../index"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
|
||||
import { updateApiConfiguration } from "../../storage/state"
|
||||
import { buildApiHandler } from "@api/index"
|
||||
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
|
||||
@@ -25,7 +24,7 @@ export async function updateApiConfigurationProto(
|
||||
const appApiConfiguration = convertProtoToApiConfiguration(request.apiConfiguration)
|
||||
|
||||
// Update the API configuration in storage
|
||||
await updateApiConfiguration(controller.context, appApiConfiguration)
|
||||
controller.cacheService.setApiConfiguration(appApiConfiguration)
|
||||
|
||||
// Update the task's API handler if there's an active task
|
||||
if (controller.task) {
|
||||
|
||||
@@ -19,13 +19,13 @@ export async function resetState(controller: Controller, request: ResetStateRequ
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Resetting global state...",
|
||||
})
|
||||
await resetGlobalState(controller.context)
|
||||
await resetGlobalState(controller)
|
||||
} else {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Resetting workspace state...",
|
||||
})
|
||||
await resetWorkspaceState(controller.context)
|
||||
await resetWorkspaceState(controller)
|
||||
}
|
||||
|
||||
if (controller.task) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
import { Controller } from ".."
|
||||
import { Empty, StringRequest } from "@shared/proto/cline/common"
|
||||
import { updateGlobalState } from "@/core/storage/state"
|
||||
@@ -16,11 +16,7 @@ export async function toggleFavoriteModel(controller: Controller, request: Strin
|
||||
}
|
||||
|
||||
const modelId = request.value
|
||||
const { apiConfiguration } = await controller.getStateToPostToWebview()
|
||||
|
||||
if (!apiConfiguration) {
|
||||
throw new Error("API configuration not found")
|
||||
}
|
||||
const apiConfiguration = controller.cacheService.getApiConfiguration()
|
||||
|
||||
const favoritedModelIds = apiConfiguration.favoritedModelIds || []
|
||||
|
||||
@@ -29,7 +25,12 @@ export async function toggleFavoriteModel(controller: Controller, request: Strin
|
||||
? favoritedModelIds.filter((id) => id !== modelId)
|
||||
: [...favoritedModelIds, modelId]
|
||||
|
||||
await updateGlobalState(controller.context, "favoritedModelIds", updatedFavorites)
|
||||
// Update the complete API configuration through cache service
|
||||
const updatedApiConfiguration = {
|
||||
...apiConfiguration,
|
||||
favoritedModelIds: updatedFavorites,
|
||||
}
|
||||
controller.cacheService.setApiConfiguration(updatedApiConfiguration)
|
||||
|
||||
// Capture telemetry for model favorite toggle
|
||||
const isFavorited = !favoritedModelIds.includes(modelId)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Controller } from ".."
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { updateApiConfiguration } from "../../storage/state"
|
||||
import { buildApiHandler } from "../../../api"
|
||||
import { convertProtoApiConfigurationToApiConfiguration } from "@shared/proto-conversions/state/settings-conversion"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { OpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { convertProtoApiConfigurationToApiConfiguration } from "../../../shared/proto-conversions/state/settings-conversion"
|
||||
import { TelemetrySetting } from "@/shared/TelemetrySetting"
|
||||
import { OpenaiReasoningEffort } from "@/shared/storage/types"
|
||||
|
||||
/**
|
||||
* Updates multiple extension settings in a single request
|
||||
@@ -18,7 +17,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
// Update API configuration
|
||||
if (request.apiConfiguration) {
|
||||
const apiConfiguration = convertProtoApiConfigurationToApiConfiguration(request.apiConfiguration)
|
||||
await updateApiConfiguration(controller.context, apiConfiguration)
|
||||
controller.cacheService.setApiConfiguration(apiConfiguration)
|
||||
|
||||
if (controller.task) {
|
||||
const currentMode = await controller.getCurrentMode()
|
||||
@@ -59,7 +58,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
if (request.mode !== undefined) {
|
||||
const mode = request.mode === PlanActMode.PLAN ? "plan" : "act"
|
||||
if (controller.task) {
|
||||
controller.task.mode = mode
|
||||
controller.task.updateMode(mode)
|
||||
}
|
||||
await controller.context.globalState.update("mode", request.mode)
|
||||
}
|
||||
@@ -93,6 +92,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
await controller.context.globalState.update("terminalOutputLineLimit", Number(request.terminalOutputLineLimit))
|
||||
}
|
||||
|
||||
// Update strict plan mode setting
|
||||
if (request.strictPlanModeEnabled !== undefined) {
|
||||
if (controller.task) {
|
||||
controller.task.updateStrictPlanMode(request.strictPlanModeEnabled)
|
||||
}
|
||||
await controller.context.globalState.update("strictPlanModeEnabled", request.strictPlanModeEnabled)
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
await controller.postStateToWebview()
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function deleteTasksWithIds(controller: Controller, request: String
|
||||
options: { modal: true, items: ["Delete"] },
|
||||
})
|
||||
|
||||
if (userChoice === undefined) {
|
||||
if (userChoice.selectedOption !== "Delete") {
|
||||
return Empty.create()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Controller } from ".."
|
||||
import { Empty, StringRequest } from "@shared/proto/cline/common"
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
|
||||
/**
|
||||
* Handles task feedback submission (thumbs up/down)
|
||||
|
||||
@@ -4,11 +4,12 @@ import { EmptyRequest, Empty } from "@shared/proto/cline/common"
|
||||
import { getAllExtensionState, getGlobalState, updateGlobalState } from "../../storage/state"
|
||||
import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "../mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
|
||||
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
|
||||
import { McpMarketplaceCatalog } from "@shared/mcp"
|
||||
import { refreshOpenRouterModels } from "../models/refreshOpenRouterModels"
|
||||
import { refreshGroqModels } from "../models/refreshGroqModels"
|
||||
import { refreshBasetenModels } from "../models/refreshBasetenModels"
|
||||
|
||||
/**
|
||||
* Initialize webview when it launches
|
||||
@@ -32,7 +33,8 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
|
||||
refreshOpenRouterModels(controller, EmptyRequest.create()).then(async (response) => {
|
||||
if (response && response.models) {
|
||||
// Update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
const { apiConfiguration, planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
|
||||
const apiConfiguration = controller.cacheService.getApiConfiguration()
|
||||
const { planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
|
||||
const currentMode = await controller.getCurrentMode()
|
||||
|
||||
if (planActSeparateModelsSetting) {
|
||||
@@ -42,26 +44,32 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
|
||||
const modelId = apiConfiguration[modelIdField]
|
||||
|
||||
if (modelId && response.models[modelId]) {
|
||||
await updateGlobalState(controller.context, modelInfoField, response.models[modelId])
|
||||
const updatedConfig = {
|
||||
...apiConfiguration,
|
||||
[modelInfoField]: response.models[modelId],
|
||||
}
|
||||
controller.cacheService.setApiConfiguration(updatedConfig)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} else {
|
||||
// Shared models: update both plan and act modes
|
||||
const planModelId = apiConfiguration.planModeOpenRouterModelId
|
||||
const actModelId = apiConfiguration.actModeOpenRouterModelId
|
||||
const updatedConfig = { ...apiConfiguration }
|
||||
|
||||
// Update plan mode model info if we have a model ID
|
||||
if (planModelId && response.models[planModelId]) {
|
||||
await updateGlobalState(controller.context, "planModeOpenRouterModelInfo", response.models[planModelId])
|
||||
updatedConfig.planModeOpenRouterModelInfo = response.models[planModelId]
|
||||
}
|
||||
|
||||
// Update act mode model info if we have a model ID
|
||||
if (actModelId && response.models[actModelId]) {
|
||||
await updateGlobalState(controller.context, "actModeOpenRouterModelInfo", response.models[actModelId])
|
||||
updatedConfig.actModeOpenRouterModelInfo = response.models[actModelId]
|
||||
}
|
||||
|
||||
// Post state update if we updated any model info
|
||||
if ((planModelId && response.models[planModelId]) || (actModelId && response.models[actModelId])) {
|
||||
controller.cacheService.setApiConfiguration(updatedConfig)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
}
|
||||
@@ -71,7 +79,8 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
|
||||
refreshGroqModels(controller, EmptyRequest.create()).then(async (response) => {
|
||||
if (response && response.models) {
|
||||
// Update model info in state for Groq (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
const { apiConfiguration, planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
|
||||
const apiConfiguration = controller.cacheService.getApiConfiguration()
|
||||
const { planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
|
||||
const currentMode = await controller.getCurrentMode()
|
||||
|
||||
if (planActSeparateModelsSetting) {
|
||||
@@ -81,22 +90,67 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
|
||||
const modelId = apiConfiguration[modelIdField]
|
||||
|
||||
if (modelId && response.models[modelId]) {
|
||||
await updateGlobalState(controller.context, modelInfoField, response.models[modelId])
|
||||
const updatedConfig = {
|
||||
...apiConfiguration,
|
||||
[modelInfoField]: response.models[modelId],
|
||||
}
|
||||
controller.cacheService.setApiConfiguration(updatedConfig)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} else {
|
||||
// Shared models: update both plan and act modes
|
||||
const planModelId = apiConfiguration.planModeGroqModelId
|
||||
const actModelId = apiConfiguration.actModeGroqModelId
|
||||
const updatedConfig = { ...apiConfiguration }
|
||||
|
||||
// Update plan mode model info if we have a model ID
|
||||
if (planModelId && response.models[planModelId]) {
|
||||
await updateGlobalState(controller.context, "planModeGroqModelInfo", response.models[planModelId])
|
||||
updatedConfig.planModeGroqModelInfo = response.models[planModelId]
|
||||
}
|
||||
|
||||
// Update act mode model info if we have a model ID
|
||||
if (actModelId && response.models[actModelId]) {
|
||||
await updateGlobalState(controller.context, "actModeGroqModelInfo", response.models[actModelId])
|
||||
updatedConfig.actModeGroqModelInfo = response.models[actModelId]
|
||||
}
|
||||
|
||||
// Post state update if we updated any model info
|
||||
if ((planModelId && response.models[planModelId]) || (actModelId && response.models[actModelId])) {
|
||||
controller.cacheService.setApiConfiguration(updatedConfig)
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
refreshBasetenModels(controller, EmptyRequest.create()).then(async (response) => {
|
||||
if (response && response.models) {
|
||||
// Update model info in state for Baseten (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
const { apiConfiguration, planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
|
||||
const currentMode = await controller.getCurrentMode()
|
||||
|
||||
if (planActSeparateModelsSetting) {
|
||||
// Separate models: update only current mode
|
||||
const modelIdField = currentMode === "plan" ? "planModeBasetenModelId" : "actModeBasetenModelId"
|
||||
const modelInfoField = currentMode === "plan" ? "planModeBasetenModelInfo" : "actModeBasetenModelInfo"
|
||||
const modelId = apiConfiguration[modelIdField]
|
||||
|
||||
if (modelId && response.models[modelId]) {
|
||||
await updateGlobalState(controller.context, modelInfoField, response.models[modelId])
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} else {
|
||||
// Shared models: update both plan and act modes
|
||||
const planModelId = apiConfiguration.planModeBasetenModelId
|
||||
const actModelId = apiConfiguration.actModeBasetenModelId
|
||||
|
||||
// Update plan mode model info if we have a model ID
|
||||
if (planModelId && response.models[planModelId]) {
|
||||
await updateGlobalState(controller.context, "planModeBasetenModelInfo", response.models[planModelId])
|
||||
}
|
||||
|
||||
// Update act mode model info if we have a model ID
|
||||
if (actModelId && response.models[actModelId]) {
|
||||
await updateGlobalState(controller.context, "actModeBasetenModelInfo", response.models[actModelId])
|
||||
}
|
||||
|
||||
// Post state update if we updated any model info
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Boolean } from "@shared/proto/cline/common"
|
||||
import type { Controller } from "../index"
|
||||
import { updateGlobalState } from "../../storage/state"
|
||||
import { getLatestAnnouncementId } from "@/extension"
|
||||
import { getLatestAnnouncementId } from "@/utils/announcements"
|
||||
|
||||
/**
|
||||
* Marks the current announcement as shown
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as vscode from "vscode"
|
||||
import type { Controller } from "../index"
|
||||
import type { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { telemetryService } from "../../../services/posthog/telemetry/TelemetryService"
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
|
||||
/**
|
||||
* Opens the Cline walkthrough in VSCode
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
|
||||
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import * as extractTextModule from "@integrations/misc/extract-text"
|
||||
import * as terminalModule from "@integrations/terminal/get-latest-output"
|
||||
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
|
||||
import * as gitModule from "@utils/git"
|
||||
import { expect } from "chai"
|
||||
import * as fs from "fs"
|
||||
import * as isBinaryFileModule from "isbinaryfile"
|
||||
import * as path from "path"
|
||||
import * as sinon from "sinon"
|
||||
import { parseMentions } from "../index"
|
||||
|
||||
describe("parseMentions", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let urlContentFetcherStub: sinon.SinonStubbedInstance<UrlContentFetcher>
|
||||
let fileContextTrackerStub: sinon.SinonStubbedInstance<FileContextTracker>
|
||||
let fsStatStub: sinon.SinonStub
|
||||
let fsReaddirStub: sinon.SinonStub
|
||||
let extractTextStub: sinon.SinonStub
|
||||
let isBinaryFileStub: sinon.SinonStub
|
||||
let getLatestTerminalOutputStub: sinon.SinonStub
|
||||
let getWorkingStateStub: sinon.SinonStub
|
||||
let getCommitInfoStub: sinon.SinonStub
|
||||
let showMessageStub: sinon.SinonStub
|
||||
|
||||
const cwd = "/test/project"
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
setVscodeHostProviderMock()
|
||||
// Create stubs for dependencies
|
||||
urlContentFetcherStub = {
|
||||
launchBrowser: sandbox.stub().resolves(),
|
||||
closeBrowser: sandbox.stub().resolves(),
|
||||
urlToMarkdown: sandbox.stub().resolves("# Example Website\n\nContent here"),
|
||||
} as any
|
||||
|
||||
fileContextTrackerStub = {
|
||||
trackFileContext: sandbox.stub().resolves(),
|
||||
} as any
|
||||
|
||||
// Stub file system operations using fs.promises
|
||||
fsStatStub = sandbox.stub(fs.promises, "stat")
|
||||
fsReaddirStub = sandbox.stub(fs.promises, "readdir")
|
||||
|
||||
// Stub other modules
|
||||
extractTextStub = sandbox.stub(extractTextModule, "extractTextFromFile")
|
||||
isBinaryFileStub = sandbox.stub(isBinaryFileModule, "isBinaryFile")
|
||||
getLatestTerminalOutputStub = sandbox.stub(terminalModule, "getLatestTerminalOutput")
|
||||
getWorkingStateStub = sandbox.stub(gitModule, "getWorkingState")
|
||||
getCommitInfoStub = sandbox.stub(gitModule, "getCommitInfo")
|
||||
showMessageStub = sandbox.stub(HostProvider.window, "showMessage")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("File mentions", () => {
|
||||
it("should handle simple file mention", async () => {
|
||||
const text = "Check @/src/index.ts for details"
|
||||
|
||||
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
|
||||
isBinaryFileStub.resolves(false)
|
||||
extractTextStub.resolves("console.log('Hello World');")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub, fileContextTrackerStub)
|
||||
|
||||
const expectedOutput = `Check 'src/index.ts' (see below for file content) for details
|
||||
|
||||
<file_content path="src/index.ts">
|
||||
console.log('Hello World');
|
||||
</file_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
expect(fileContextTrackerStub.trackFileContext.calledWith("src/index.ts", "file_mentioned")).to.be.true
|
||||
})
|
||||
|
||||
it("should handle quoted file paths with spaces", async () => {
|
||||
const text = 'Open @"/path with spaces/file.txt"'
|
||||
|
||||
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
|
||||
isBinaryFileStub.resolves(false)
|
||||
extractTextStub.resolves("console.log('Hello World');")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Open 'path with spaces/file.txt' (see below for file content)
|
||||
|
||||
<file_content path="path with spaces/file.txt">
|
||||
console.log('Hello World');
|
||||
</file_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
|
||||
it("should handle binary files", async () => {
|
||||
const text = "Check @/image.png"
|
||||
|
||||
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
|
||||
isBinaryFileStub.resolves(true)
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Check 'image.png' (see below for file content)
|
||||
|
||||
<file_content path="image.png">
|
||||
(Binary file, unable to display content)
|
||||
</file_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
|
||||
it("should handle file read errors", async () => {
|
||||
const text = "Check @/missing.txt"
|
||||
|
||||
fsStatStub.rejects(new Error("ENOENT: no such file or directory"))
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Check 'missing.txt' (see below for file content)
|
||||
|
||||
<file_content path="missing.txt">
|
||||
Error fetching content: Failed to access path "missing.txt": ENOENT: no such file or directory
|
||||
</file_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Folder mentions", () => {
|
||||
it("should handle folder mention", async () => {
|
||||
const text = "Look in @/src/ folder"
|
||||
|
||||
fsStatStub.resolves({ isFile: () => false, isDirectory: () => true })
|
||||
fsReaddirStub.resolves([
|
||||
{ name: "index.ts", isFile: () => true, isDirectory: () => false },
|
||||
{ name: "utils", isFile: () => false, isDirectory: () => true },
|
||||
{ name: "README.md", isFile: () => true, isDirectory: () => false },
|
||||
])
|
||||
|
||||
// Set up file content stubs
|
||||
isBinaryFileStub.resolves(false)
|
||||
extractTextStub.withArgs(path.resolve(cwd, "src/index.ts")).resolves("export const main = () => {};")
|
||||
extractTextStub.withArgs(path.resolve(cwd, "src/README.md")).resolves("# Source Code")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Look in 'src/' (see below for folder content) folder
|
||||
|
||||
<folder_content path="src/">
|
||||
├── index.ts
|
||||
├── utils/
|
||||
└── README.md
|
||||
|
||||
<file_content path="src/index.ts">
|
||||
export const main = () => {};
|
||||
</file_content>
|
||||
|
||||
<file_content path="src/README.md">
|
||||
# Source Code
|
||||
</file_content>
|
||||
</folder_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
})
|
||||
|
||||
describe("URL mentions", () => {
|
||||
it("should handle URL mention", async () => {
|
||||
const text = "Visit @https://example.com for info"
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Visit 'https://example.com' (see below for site content) for info
|
||||
|
||||
<url_content url="https://example.com">
|
||||
# Example Website
|
||||
|
||||
Content here
|
||||
</url_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
expect(urlContentFetcherStub.launchBrowser.called).to.be.true
|
||||
expect(urlContentFetcherStub.urlToMarkdown.calledWith("https://example.com")).to.be.true
|
||||
expect(urlContentFetcherStub.closeBrowser.called).to.be.true
|
||||
})
|
||||
|
||||
it("should handle browser launch errors", async () => {
|
||||
const text = "Visit @https://example.com"
|
||||
|
||||
urlContentFetcherStub.launchBrowser.rejects(new Error("Browser launch failed"))
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Visit 'https://example.com' (see below for site content)
|
||||
|
||||
<url_content url="https://example.com">
|
||||
Error fetching content: Browser launch failed
|
||||
</url_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
expect(showMessageStub.called).to.be.true
|
||||
})
|
||||
|
||||
it("should handle URL fetch errors", async () => {
|
||||
const text = "Visit @https://example.com"
|
||||
|
||||
urlContentFetcherStub.urlToMarkdown.rejects(new Error("Network error"))
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Visit 'https://example.com' (see below for site content)
|
||||
|
||||
<url_content url="https://example.com">
|
||||
Error fetching content: Network error
|
||||
</url_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
expect(showMessageStub.called).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe("Special mentions", () => {
|
||||
it("should handle @terminal mention", async () => {
|
||||
const text = "See @terminal output"
|
||||
|
||||
getLatestTerminalOutputStub.resolves("$ npm test\nAll tests passed!")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `See Terminal Output (see below for output) output
|
||||
|
||||
<terminal_output>
|
||||
$ npm test
|
||||
All tests passed!
|
||||
</terminal_output>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
|
||||
it("should handle @git-changes mention", async () => {
|
||||
const text = "Review @git-changes"
|
||||
|
||||
getWorkingStateStub.resolves("M src/index.ts\nA src/new-file.ts")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Review Working directory changes (see below for details)
|
||||
|
||||
<git_working_state>
|
||||
M src/index.ts
|
||||
A src/new-file.ts
|
||||
</git_working_state>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
|
||||
it("should handle git commit hash mention", async () => {
|
||||
const text = "See commit @abcdef1234567890"
|
||||
|
||||
getCommitInfoStub.resolves("commit abcdef1234567890\nAuthor: Test\nDate: 2024-01-01\n\nInitial commit")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `See commit Git commit 'abcdef1234567890' (see below for commit info)
|
||||
|
||||
<git_commit hash="abcdef1234567890">
|
||||
commit abcdef1234567890
|
||||
Author: Test
|
||||
Date: 2024-01-01
|
||||
|
||||
Initial commit
|
||||
</git_commit>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multiple mentions", () => {
|
||||
it("should handle multiple mentions in order", async () => {
|
||||
const text = "Check @/file1.txt and @/file2.txt"
|
||||
|
||||
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
|
||||
isBinaryFileStub.resolves(false)
|
||||
extractTextStub.withArgs(path.resolve(cwd, "file1.txt")).resolves("Content 1")
|
||||
extractTextStub.withArgs(path.resolve(cwd, "file2.txt")).resolves("Content 2")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Check 'file1.txt' (see below for file content) and 'file2.txt' (see below for file content)
|
||||
|
||||
<file_content path="file1.txt">
|
||||
Content 1
|
||||
</file_content>
|
||||
|
||||
<file_content path="file2.txt">
|
||||
Content 2
|
||||
</file_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
|
||||
it("should handle duplicate mentions only once", async () => {
|
||||
const text = "Check @/file.txt and again @/file.txt"
|
||||
|
||||
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
|
||||
isBinaryFileStub.resolves(false)
|
||||
extractTextStub.resolves("Content")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Check 'file.txt' (see below for file content) and again 'file.txt' (see below for file content)
|
||||
|
||||
<file_content path="file.txt">
|
||||
Content
|
||||
</file_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
|
||||
it("should handle mixed mention types", async () => {
|
||||
const text = "Check @/file.txt, and @https://example.com"
|
||||
|
||||
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
|
||||
isBinaryFileStub.resolves(false)
|
||||
extractTextStub.resolves("File content")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Check 'file.txt' (see below for file content), and 'https://example.com' (see below for site content)
|
||||
|
||||
<file_content path="file.txt">
|
||||
File content
|
||||
</file_content>
|
||||
|
||||
<url_content url="https://example.com">
|
||||
# Example Website
|
||||
|
||||
Content here
|
||||
</url_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should handle errors for each mention type gracefully", async () => {
|
||||
const text = "@/error.txt @terminal @git-changes @abc1234567"
|
||||
|
||||
fsStatStub.rejects(new Error("File error"))
|
||||
getLatestTerminalOutputStub.rejects(new Error("Terminal error"))
|
||||
getWorkingStateStub.rejects(new Error("Git state error"))
|
||||
getCommitInfoStub.rejects(new Error("Commit error"))
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `'error.txt' (see below for file content) Terminal Output (see below for output) Working directory changes (see below for details) Git commit 'abc1234567' (see below for commit info)
|
||||
|
||||
<file_content path="error.txt">
|
||||
Error fetching content: Failed to access path "error.txt": File error
|
||||
</file_content>
|
||||
|
||||
<terminal_output>
|
||||
Error fetching terminal output: Terminal error
|
||||
</terminal_output>
|
||||
|
||||
<git_working_state>
|
||||
Error fetching working state: Git state error
|
||||
</git_working_state>
|
||||
|
||||
<git_commit hash="abc1234567">
|
||||
Error fetching commit info: Commit error
|
||||
</git_commit>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle text with no mentions", async () => {
|
||||
const text = "This is plain text without any mentions"
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
expect(result).to.equal(text)
|
||||
})
|
||||
|
||||
it("should handle empty text", async () => {
|
||||
const result = await parseMentions("", cwd, urlContentFetcherStub)
|
||||
|
||||
expect(result).to.equal("")
|
||||
})
|
||||
|
||||
it("should handle mentions with trailing punctuation", async () => {
|
||||
const text = "Check @/file.txt!"
|
||||
|
||||
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
|
||||
isBinaryFileStub.resolves(false)
|
||||
extractTextStub.resolves("Content")
|
||||
|
||||
const result = await parseMentions(text, cwd, urlContentFetcherStub)
|
||||
|
||||
const expectedOutput = `Check 'file.txt' (see below for file content)!
|
||||
|
||||
<file_content path="file.txt">
|
||||
Content
|
||||
</file_content>`
|
||||
|
||||
expect(result).to.equal(expectedOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import { FileContextTracker } from "../context/context-tracking/FileContextTrack
|
||||
import { getCwd } from "@/utils/path"
|
||||
import { openExternal } from "@utils/env"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
|
||||
export async function openMention(mention?: string): Promise<void> {
|
||||
if (!mention) {
|
||||
@@ -26,8 +26,8 @@ export async function openMention(mention?: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (mention.startsWith("/")) {
|
||||
const relPath = mention.slice(1)
|
||||
if (isFileMention(mention)) {
|
||||
const relPath = getFilePathFromMention(mention)
|
||||
const absPath = path.resolve(cwd, relPath)
|
||||
if (mention.endsWith("/")) {
|
||||
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
|
||||
@@ -54,8 +54,8 @@ export async function parseMentions(
|
||||
mentions.add(mention)
|
||||
if (mention.startsWith("http")) {
|
||||
return `'${mention}' (see below for site content)`
|
||||
} else if (mention.startsWith("/")) {
|
||||
const mentionPath = mention.slice(1) // Remove the leading '/'
|
||||
} else if (isFileMention(mention)) {
|
||||
const mentionPath = getFilePathFromMention(mention)
|
||||
return mentionPath.endsWith("/")
|
||||
? `'${mentionPath}' (see below for folder content)`
|
||||
: `'${mentionPath}' (see below for file content)`
|
||||
@@ -106,8 +106,8 @@ export async function parseMentions(
|
||||
}
|
||||
}
|
||||
parsedText += `\n\n<url_content url="${mention}">\n${result}\n</url_content>`
|
||||
} else if (mention.startsWith("/")) {
|
||||
const mentionPath = mention.slice(1)
|
||||
} else if (isFileMention(mention)) {
|
||||
const mentionPath = getFilePathFromMention(mention)
|
||||
try {
|
||||
const content = await getFileOrFolderContent(mentionPath, cwd)
|
||||
if (mention.endsWith("/")) {
|
||||
@@ -232,3 +232,15 @@ async function getWorkspaceProblems(): Promise<string> {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function isFileMention(mention: string): boolean {
|
||||
return mention.startsWith("/") || mention.startsWith('"/')
|
||||
}
|
||||
|
||||
function getFilePathFromMention(mention: string): string {
|
||||
// Remove quotes
|
||||
const match = mention.match(/^"(.*)"$/)
|
||||
const filePath = match ? match[1] : mention
|
||||
// Remove leading slash
|
||||
return filePath.slice(1)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { McpHub } from "@services/mcp/McpHub"
|
||||
import { BrowserSettings } from "@shared/BrowserSettings"
|
||||
import { SYSTEM_PROMPT_CLAUDE4_EXPERIMENTAL } from "@core/prompts/model_prompts/claude4-experimental"
|
||||
import { SYSTEM_PROMPT_CLAUDE4 } from "@core/prompts/model_prompts/claude4"
|
||||
import { USE_EXPERIMENTAL_CLAUDE4_FEATURES } from "@core/task/index";
|
||||
import { USE_EXPERIMENTAL_CLAUDE4_FEATURES } from "@core/task/index"
|
||||
|
||||
export const SYSTEM_PROMPT = async (
|
||||
cwd: string,
|
||||
@@ -14,14 +14,13 @@ export const SYSTEM_PROMPT = async (
|
||||
browserSettings: BrowserSettings,
|
||||
isNextGenModel: boolean = false,
|
||||
) => {
|
||||
|
||||
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
|
||||
return SYSTEM_PROMPT_CLAUDE4_EXPERIMENTAL(cwd, supportsBrowserUse, mcpHub, browserSettings)
|
||||
}
|
||||
|
||||
if (isNextGenModel) {
|
||||
return SYSTEM_PROMPT_CLAUDE4(cwd, supportsBrowserUse, mcpHub, browserSettings)
|
||||
}
|
||||
if (isNextGenModel) {
|
||||
return SYSTEM_PROMPT_CLAUDE4(cwd, supportsBrowserUse, mcpHub, browserSettings)
|
||||
}
|
||||
|
||||
return `You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
|
||||
|
||||
@@ -650,7 +649,6 @@ You accomplish a given task iteratively, breaking it down into clear steps and w
|
||||
5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.`
|
||||
}
|
||||
|
||||
|
||||
export function addUserInstructions(
|
||||
globalClineRulesFileInstructions?: string,
|
||||
localClineRulesFileInstructions?: string,
|
||||
|
||||
@@ -0,0 +1,960 @@
|
||||
import { ApiConfiguration } from "@shared/api"
|
||||
import { updateGlobalState, updateWorkspaceState, getAllExtensionState, storeSecret } from "./state"
|
||||
import { SecretKey, GlobalStateKey, LocalStateKey } from "./state-keys"
|
||||
import { CACHE_SERVICE_NOT_INITIALIZED } from "./error-messages"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
/**
|
||||
* Interface for persistence error event data
|
||||
*/
|
||||
export interface PersistenceErrorEvent {
|
||||
error: Error
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cache service for fast state access
|
||||
* Provides immediate reads/writes with async disk persistence
|
||||
*/
|
||||
export class CacheService {
|
||||
private globalStateCache: Map<GlobalStateKey, any> = new Map()
|
||||
private secretsCache: Map<SecretKey, string | undefined> = new Map()
|
||||
private workspaceStateCache: Map<LocalStateKey, any> = new Map()
|
||||
private context: ExtensionContext
|
||||
private isInitialized = false
|
||||
|
||||
// Debounced persistence state
|
||||
private pendingGlobalState = new Set<GlobalStateKey>()
|
||||
private pendingSecrets = new Set<SecretKey>()
|
||||
private pendingWorkspaceState = new Set<LocalStateKey>()
|
||||
private persistenceTimeout: NodeJS.Timeout | null = null
|
||||
private readonly PERSISTENCE_DELAY_MS = 500
|
||||
|
||||
// Callback for persistence errors
|
||||
onPersistenceError?: (event: PersistenceErrorEvent) => void
|
||||
|
||||
constructor(context: ExtensionContext) {
|
||||
this.context = context
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cache by loading data from disk
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// Load API configuration and populate cache with component keys
|
||||
const { apiConfiguration } = await getAllExtensionState(this.context)
|
||||
if (apiConfiguration) {
|
||||
// Populate the caches with the API configuration component keys
|
||||
// Use populate method to avoid triggering persistence during initialization
|
||||
this.populateApiConfigurationCache(apiConfiguration)
|
||||
}
|
||||
|
||||
this.isInitialized = true
|
||||
console.log("CacheService initialized successfully")
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize CacheService:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set method for global state keys - updates cache immediately and schedules debounced persistence
|
||||
*/
|
||||
setGlobalState<T>(key: GlobalStateKey, value: T): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
|
||||
// Update cache immediately for instant access
|
||||
this.globalStateCache.set(key, value)
|
||||
|
||||
// Add to pending persistence set and schedule debounced write
|
||||
this.pendingGlobalState.add(key)
|
||||
this.scheduleDebouncedPersistence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set method for global state keys - updates cache immediately and schedules debounced persistence
|
||||
*/
|
||||
setGlobalStateBatch(updates: Partial<Record<GlobalStateKey, any>>): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
|
||||
// Update cache immediately for all keys
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
this.globalStateCache.set(key as GlobalStateKey, value)
|
||||
this.pendingGlobalState.add(key as GlobalStateKey)
|
||||
})
|
||||
|
||||
// Schedule debounced persistence
|
||||
this.scheduleDebouncedPersistence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set method for secret keys - updates cache immediately and schedules debounced persistence
|
||||
*/
|
||||
setSecret(key: SecretKey, value: string | undefined): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
|
||||
// Update cache immediately for instant access
|
||||
this.secretsCache.set(key, value)
|
||||
|
||||
// Add to pending persistence set and schedule debounced write
|
||||
this.pendingSecrets.add(key)
|
||||
this.scheduleDebouncedPersistence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set method for secret keys - updates cache immediately and schedules debounced persistence
|
||||
*/
|
||||
setSecretsBatch(updates: Partial<Record<SecretKey, string | undefined>>): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
|
||||
// Update cache immediately for all keys
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
this.secretsCache.set(key as SecretKey, value)
|
||||
this.pendingSecrets.add(key as SecretKey)
|
||||
})
|
||||
|
||||
// Schedule debounced persistence
|
||||
this.scheduleDebouncedPersistence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set method for workspace state keys - updates cache immediately and schedules debounced persistence
|
||||
*/
|
||||
setWorkspaceState<T>(key: LocalStateKey, value: T): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
|
||||
// Update cache immediately for instant access
|
||||
this.workspaceStateCache.set(key, value)
|
||||
|
||||
// Add to pending persistence set and schedule debounced write
|
||||
this.pendingWorkspaceState.add(key)
|
||||
this.scheduleDebouncedPersistence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set method for workspace state keys - updates cache immediately and schedules debounced persistence
|
||||
*/
|
||||
setWorkspaceStateBatch(updates: Partial<Record<LocalStateKey, any>>): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
|
||||
// Update cache immediately for all keys
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
this.workspaceStateCache.set(key as LocalStateKey, value)
|
||||
this.pendingWorkspaceState.add(key as LocalStateKey)
|
||||
})
|
||||
|
||||
// Schedule debounced persistence
|
||||
this.scheduleDebouncedPersistence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for getting API configuration
|
||||
* Ensures cache is initialized if not already done
|
||||
*/
|
||||
getApiConfiguration(): ApiConfiguration {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
|
||||
// Construct API configuration from cached component keys
|
||||
return this.constructApiConfigurationFromCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for setting API configuration
|
||||
*/
|
||||
setApiConfiguration(apiConfiguration: ApiConfiguration): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsBedrockApiKey,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
awsAuthentication,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiHeaders,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
geminiBaseUrl,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
togetherApiKey,
|
||||
qwenApiKey,
|
||||
doubaoApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterProviderSorting,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmApiKey,
|
||||
liteLlmUsePromptCache,
|
||||
qwenApiLine,
|
||||
moonshotApiLine,
|
||||
asksageApiKey,
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
clineAccountId,
|
||||
sambanovaApiKey,
|
||||
cerebrasApiKey,
|
||||
groqApiKey,
|
||||
moonshotApiKey,
|
||||
nebiusApiKey,
|
||||
favoritedModelIds,
|
||||
fireworksApiKey,
|
||||
fireworksModelMaxCompletionTokens,
|
||||
fireworksModelMaxTokens,
|
||||
sapAiCoreClientId,
|
||||
sapAiCoreClientSecret,
|
||||
sapAiCoreBaseUrl,
|
||||
sapAiCoreTokenUrl,
|
||||
sapAiResourceGroup,
|
||||
claudeCodePath,
|
||||
basetenApiKey,
|
||||
huggingFaceApiKey,
|
||||
requestTimeoutMs,
|
||||
// Plan mode configurations
|
||||
planModeApiProvider,
|
||||
planModeApiModelId,
|
||||
planModeThinkingBudgetTokens,
|
||||
planModeReasoningEffort,
|
||||
planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId,
|
||||
planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo,
|
||||
planModeOpenAiModelId,
|
||||
planModeOpenAiModelInfo,
|
||||
planModeOllamaModelId,
|
||||
planModeLmStudioModelId,
|
||||
planModeLiteLlmModelId,
|
||||
planModeLiteLlmModelInfo,
|
||||
planModeRequestyModelId,
|
||||
planModeRequestyModelInfo,
|
||||
planModeTogetherModelId,
|
||||
planModeFireworksModelId,
|
||||
planModeSapAiCoreModelId,
|
||||
planModeGroqModelId,
|
||||
planModeGroqModelInfo,
|
||||
planModeBasetenModelId,
|
||||
planModeBasetenModelInfo,
|
||||
planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo,
|
||||
// Act mode configurations
|
||||
actModeApiProvider,
|
||||
actModeApiModelId,
|
||||
actModeThinkingBudgetTokens,
|
||||
actModeReasoningEffort,
|
||||
actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId,
|
||||
actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo,
|
||||
actModeOpenAiModelId,
|
||||
actModeOpenAiModelInfo,
|
||||
actModeOllamaModelId,
|
||||
actModeLmStudioModelId,
|
||||
actModeLiteLlmModelId,
|
||||
actModeLiteLlmModelInfo,
|
||||
actModeRequestyModelId,
|
||||
actModeRequestyModelInfo,
|
||||
actModeTogetherModelId,
|
||||
actModeFireworksModelId,
|
||||
actModeSapAiCoreModelId,
|
||||
actModeGroqModelId,
|
||||
actModeGroqModelInfo,
|
||||
actModeBasetenModelId,
|
||||
actModeBasetenModelInfo,
|
||||
actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo,
|
||||
} = apiConfiguration
|
||||
|
||||
// Batch update global state keys
|
||||
this.setGlobalStateBatch({
|
||||
// Plan mode configuration updates
|
||||
planModeApiProvider,
|
||||
planModeApiModelId,
|
||||
planModeThinkingBudgetTokens,
|
||||
planModeReasoningEffort,
|
||||
planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId,
|
||||
planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo,
|
||||
planModeOpenAiModelId,
|
||||
planModeOpenAiModelInfo,
|
||||
planModeOllamaModelId,
|
||||
planModeLmStudioModelId,
|
||||
planModeLiteLlmModelId,
|
||||
planModeLiteLlmModelInfo,
|
||||
planModeRequestyModelId,
|
||||
planModeRequestyModelInfo,
|
||||
planModeTogetherModelId,
|
||||
planModeFireworksModelId,
|
||||
planModeSapAiCoreModelId,
|
||||
planModeGroqModelId,
|
||||
planModeGroqModelInfo,
|
||||
planModeBasetenModelId,
|
||||
planModeBasetenModelInfo,
|
||||
planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo,
|
||||
|
||||
// Act mode configuration updates
|
||||
actModeApiProvider,
|
||||
actModeApiModelId,
|
||||
actModeThinkingBudgetTokens,
|
||||
actModeReasoningEffort,
|
||||
actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId,
|
||||
actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo,
|
||||
actModeOpenAiModelId,
|
||||
actModeOpenAiModelInfo,
|
||||
actModeOllamaModelId,
|
||||
actModeLmStudioModelId,
|
||||
actModeLiteLlmModelId,
|
||||
actModeLiteLlmModelInfo,
|
||||
actModeRequestyModelId,
|
||||
actModeRequestyModelInfo,
|
||||
actModeTogetherModelId,
|
||||
actModeFireworksModelId,
|
||||
actModeSapAiCoreModelId,
|
||||
actModeGroqModelId,
|
||||
actModeGroqModelInfo,
|
||||
actModeBasetenModelId,
|
||||
actModeBasetenModelInfo,
|
||||
actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo,
|
||||
|
||||
// Global state updates
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
awsAuthentication,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiHeaders,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiBaseUrl,
|
||||
azureApiVersion,
|
||||
openRouterProviderSorting,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmUsePromptCache,
|
||||
qwenApiLine,
|
||||
moonshotApiLine,
|
||||
asksageApiUrl,
|
||||
favoritedModelIds,
|
||||
requestTimeoutMs,
|
||||
fireworksModelMaxCompletionTokens,
|
||||
fireworksModelMaxTokens,
|
||||
sapAiCoreBaseUrl,
|
||||
sapAiCoreTokenUrl,
|
||||
sapAiResourceGroup,
|
||||
claudeCodePath,
|
||||
})
|
||||
|
||||
// Batch update secrets
|
||||
this.setSecretsBatch({
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
clineAccountId,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsBedrockApiKey,
|
||||
openAiApiKey,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
togetherApiKey,
|
||||
qwenApiKey,
|
||||
doubaoApiKey,
|
||||
mistralApiKey,
|
||||
liteLlmApiKey,
|
||||
fireworksApiKey,
|
||||
asksageApiKey,
|
||||
xaiApiKey,
|
||||
sambanovaApiKey,
|
||||
cerebrasApiKey,
|
||||
groqApiKey,
|
||||
moonshotApiKey,
|
||||
nebiusApiKey,
|
||||
sapAiCoreClientId,
|
||||
sapAiCoreClientSecret,
|
||||
basetenApiKey,
|
||||
huggingFaceApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get method for global state keys - reads from in-memory cache
|
||||
*/
|
||||
getGlobalStateKey<T>(key: GlobalStateKey): T | undefined {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
return this.globalStateCache.get(key) as T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get method for secret keys - reads from in-memory cache
|
||||
*/
|
||||
getSecretKey(key: SecretKey): string | undefined {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
return this.secretsCache.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get method for workspace state keys - reads from in-memory cache
|
||||
*/
|
||||
getWorkspaceStateKey<T>(key: LocalStateKey): T | undefined {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
|
||||
}
|
||||
return this.workspaceStateCache.get(key) as T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize the cache service by clearing all state and reloading from disk
|
||||
* Used for error recovery when write operations fail
|
||||
*/
|
||||
async reInitialize(): Promise<void> {
|
||||
// Clear all cached data and pending state
|
||||
this.dispose()
|
||||
|
||||
// Reinitialize from disk
|
||||
await this.initialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the cache service
|
||||
*/
|
||||
private dispose(): void {
|
||||
if (this.persistenceTimeout) {
|
||||
clearTimeout(this.persistenceTimeout)
|
||||
this.persistenceTimeout = null
|
||||
}
|
||||
|
||||
this.pendingGlobalState.clear()
|
||||
this.pendingSecrets.clear()
|
||||
this.pendingWorkspaceState.clear()
|
||||
|
||||
this.globalStateCache.clear()
|
||||
this.secretsCache.clear()
|
||||
this.workspaceStateCache.clear()
|
||||
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule debounced persistence - simple timeout-based persistence
|
||||
*/
|
||||
private scheduleDebouncedPersistence(): void {
|
||||
// Clear existing timeout if one is pending
|
||||
if (this.persistenceTimeout) {
|
||||
clearTimeout(this.persistenceTimeout)
|
||||
}
|
||||
|
||||
// Schedule a new timeout to persist pending changes
|
||||
this.persistenceTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
this.persistGlobalStateBatch(this.pendingGlobalState),
|
||||
this.persistSecretsBatch(this.pendingSecrets),
|
||||
this.persistWorkspaceStateBatch(this.pendingWorkspaceState),
|
||||
])
|
||||
|
||||
// Clear pending sets on successful persistence
|
||||
this.pendingGlobalState.clear()
|
||||
this.pendingSecrets.clear()
|
||||
this.pendingWorkspaceState.clear()
|
||||
this.persistenceTimeout = null
|
||||
} catch (error) {
|
||||
console.error("Failed to persist pending changes:", error)
|
||||
this.persistenceTimeout = null
|
||||
|
||||
// Call persistence error callback for error recovery
|
||||
this.onPersistenceError?.({ error: error as Error })
|
||||
}
|
||||
}, this.PERSISTENCE_DELAY_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to batch persist global state keys with Promise.all
|
||||
*/
|
||||
private async persistGlobalStateBatch(keys: Set<GlobalStateKey>): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(keys).map((key) => {
|
||||
const value = this.globalStateCache.get(key)
|
||||
return this.context.globalState.update(key, value)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to persist global state batch:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to batch persist secrets with Promise.all
|
||||
*/
|
||||
private async persistSecretsBatch(keys: Set<SecretKey>): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(keys).map((key) => {
|
||||
const value = this.secretsCache.get(key)
|
||||
if (value) {
|
||||
return this.context.secrets.store(key, value)
|
||||
} else {
|
||||
return this.context.secrets.delete(key)
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to persist secrets batch:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to batch persist workspace state keys with Promise.all
|
||||
*/
|
||||
private async persistWorkspaceStateBatch(keys: Set<LocalStateKey>): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(keys).map((key) => {
|
||||
const value = this.workspaceStateCache.get(key)
|
||||
return this.context.workspaceState.update(key, value)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to persist workspace state batch:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to populate API configuration cache without triggering persistence
|
||||
* Used during initialization
|
||||
*/
|
||||
private populateApiConfigurationCache(apiConfiguration: ApiConfiguration): void {
|
||||
const {
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsBedrockApiKey,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
awsAuthentication,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiHeaders,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
geminiBaseUrl,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
togetherApiKey,
|
||||
qwenApiKey,
|
||||
doubaoApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterProviderSorting,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmApiKey,
|
||||
liteLlmUsePromptCache,
|
||||
qwenApiLine,
|
||||
moonshotApiLine,
|
||||
asksageApiKey,
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
clineAccountId,
|
||||
sambanovaApiKey,
|
||||
cerebrasApiKey,
|
||||
groqApiKey,
|
||||
basetenApiKey,
|
||||
moonshotApiKey,
|
||||
nebiusApiKey,
|
||||
favoritedModelIds,
|
||||
fireworksApiKey,
|
||||
fireworksModelMaxCompletionTokens,
|
||||
fireworksModelMaxTokens,
|
||||
sapAiCoreClientId,
|
||||
sapAiCoreClientSecret,
|
||||
sapAiCoreBaseUrl,
|
||||
sapAiCoreTokenUrl,
|
||||
sapAiResourceGroup,
|
||||
claudeCodePath,
|
||||
huggingFaceApiKey,
|
||||
requestTimeoutMs,
|
||||
// Plan mode configurations
|
||||
planModeApiProvider,
|
||||
planModeApiModelId,
|
||||
planModeThinkingBudgetTokens,
|
||||
planModeReasoningEffort,
|
||||
planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId,
|
||||
planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo,
|
||||
planModeOpenAiModelId,
|
||||
planModeOpenAiModelInfo,
|
||||
planModeOllamaModelId,
|
||||
planModeLmStudioModelId,
|
||||
planModeLiteLlmModelId,
|
||||
planModeLiteLlmModelInfo,
|
||||
planModeRequestyModelId,
|
||||
planModeRequestyModelInfo,
|
||||
planModeTogetherModelId,
|
||||
planModeFireworksModelId,
|
||||
planModeSapAiCoreModelId,
|
||||
planModeGroqModelId,
|
||||
planModeGroqModelInfo,
|
||||
planModeBasetenModelId,
|
||||
planModeBasetenModelInfo,
|
||||
planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo,
|
||||
// Act mode configurations
|
||||
actModeApiProvider,
|
||||
actModeApiModelId,
|
||||
actModeThinkingBudgetTokens,
|
||||
actModeReasoningEffort,
|
||||
actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId,
|
||||
actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo,
|
||||
actModeOpenAiModelId,
|
||||
actModeOpenAiModelInfo,
|
||||
actModeOllamaModelId,
|
||||
actModeLmStudioModelId,
|
||||
actModeLiteLlmModelId,
|
||||
actModeLiteLlmModelInfo,
|
||||
actModeRequestyModelId,
|
||||
actModeRequestyModelInfo,
|
||||
actModeTogetherModelId,
|
||||
actModeFireworksModelId,
|
||||
actModeSapAiCoreModelId,
|
||||
actModeGroqModelId,
|
||||
actModeGroqModelInfo,
|
||||
actModeBasetenModelId,
|
||||
actModeBasetenModelInfo,
|
||||
actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo,
|
||||
} = apiConfiguration
|
||||
|
||||
// Directly populate global state cache without triggering persistence
|
||||
const globalStateUpdates = {
|
||||
// Plan mode configuration updates
|
||||
planModeApiProvider,
|
||||
planModeApiModelId,
|
||||
planModeThinkingBudgetTokens,
|
||||
planModeReasoningEffort,
|
||||
planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId,
|
||||
planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo,
|
||||
planModeOpenAiModelId,
|
||||
planModeOpenAiModelInfo,
|
||||
planModeOllamaModelId,
|
||||
planModeLmStudioModelId,
|
||||
planModeLiteLlmModelId,
|
||||
planModeLiteLlmModelInfo,
|
||||
planModeRequestyModelId,
|
||||
planModeRequestyModelInfo,
|
||||
planModeTogetherModelId,
|
||||
planModeFireworksModelId,
|
||||
planModeSapAiCoreModelId,
|
||||
planModeGroqModelId,
|
||||
planModeGroqModelInfo,
|
||||
planModeBasetenModelId,
|
||||
planModeBasetenModelInfo,
|
||||
planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo,
|
||||
|
||||
// Act mode configuration updates
|
||||
actModeApiProvider,
|
||||
actModeApiModelId,
|
||||
actModeThinkingBudgetTokens,
|
||||
actModeReasoningEffort,
|
||||
actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId,
|
||||
actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo,
|
||||
actModeOpenAiModelId,
|
||||
actModeOpenAiModelInfo,
|
||||
actModeOllamaModelId,
|
||||
actModeLmStudioModelId,
|
||||
actModeLiteLlmModelId,
|
||||
actModeLiteLlmModelInfo,
|
||||
actModeRequestyModelId,
|
||||
actModeRequestyModelInfo,
|
||||
actModeTogetherModelId,
|
||||
actModeFireworksModelId,
|
||||
actModeSapAiCoreModelId,
|
||||
actModeGroqModelId,
|
||||
actModeGroqModelInfo,
|
||||
actModeBasetenModelId,
|
||||
actModeBasetenModelInfo,
|
||||
actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo,
|
||||
|
||||
// Global state updates
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
awsAuthentication,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiHeaders,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiBaseUrl,
|
||||
azureApiVersion,
|
||||
openRouterProviderSorting,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmUsePromptCache,
|
||||
qwenApiLine,
|
||||
moonshotApiLine,
|
||||
asksageApiUrl,
|
||||
favoritedModelIds,
|
||||
requestTimeoutMs,
|
||||
fireworksModelMaxCompletionTokens,
|
||||
fireworksModelMaxTokens,
|
||||
sapAiCoreBaseUrl,
|
||||
sapAiCoreTokenUrl,
|
||||
sapAiResourceGroup,
|
||||
claudeCodePath,
|
||||
}
|
||||
|
||||
// Populate global state cache directly
|
||||
Object.entries(globalStateUpdates).forEach(([key, value]) => {
|
||||
this.globalStateCache.set(key as GlobalStateKey, value)
|
||||
})
|
||||
|
||||
// Directly populate secrets cache without triggering persistence
|
||||
const secretsUpdates = {
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
clineAccountId,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsBedrockApiKey,
|
||||
openAiApiKey,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
togetherApiKey,
|
||||
qwenApiKey,
|
||||
doubaoApiKey,
|
||||
mistralApiKey,
|
||||
liteLlmApiKey,
|
||||
fireworksApiKey,
|
||||
asksageApiKey,
|
||||
xaiApiKey,
|
||||
sambanovaApiKey,
|
||||
cerebrasApiKey,
|
||||
groqApiKey,
|
||||
basetenApiKey,
|
||||
moonshotApiKey,
|
||||
nebiusApiKey,
|
||||
sapAiCoreClientId,
|
||||
sapAiCoreClientSecret,
|
||||
huggingFaceApiKey,
|
||||
}
|
||||
|
||||
// Populate secrets cache directly
|
||||
Object.entries(secretsUpdates).forEach(([key, value]) => {
|
||||
this.secretsCache.set(key as SecretKey, value)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct API configuration from cached component keys
|
||||
*/
|
||||
private constructApiConfigurationFromCache(): ApiConfiguration {
|
||||
return {
|
||||
// Secrets
|
||||
apiKey: this.secretsCache.get("apiKey"),
|
||||
openRouterApiKey: this.secretsCache.get("openRouterApiKey"),
|
||||
clineAccountId: this.secretsCache.get("clineAccountId"),
|
||||
awsAccessKey: this.secretsCache.get("awsAccessKey"),
|
||||
awsSecretKey: this.secretsCache.get("awsSecretKey"),
|
||||
awsSessionToken: this.secretsCache.get("awsSessionToken"),
|
||||
awsBedrockApiKey: this.secretsCache.get("awsBedrockApiKey"),
|
||||
openAiApiKey: this.secretsCache.get("openAiApiKey"),
|
||||
geminiApiKey: this.secretsCache.get("geminiApiKey"),
|
||||
openAiNativeApiKey: this.secretsCache.get("openAiNativeApiKey"),
|
||||
deepSeekApiKey: this.secretsCache.get("deepSeekApiKey"),
|
||||
requestyApiKey: this.secretsCache.get("requestyApiKey"),
|
||||
togetherApiKey: this.secretsCache.get("togetherApiKey"),
|
||||
qwenApiKey: this.secretsCache.get("qwenApiKey"),
|
||||
doubaoApiKey: this.secretsCache.get("doubaoApiKey"),
|
||||
mistralApiKey: this.secretsCache.get("mistralApiKey"),
|
||||
liteLlmApiKey: this.secretsCache.get("liteLlmApiKey"),
|
||||
fireworksApiKey: this.secretsCache.get("fireworksApiKey"),
|
||||
asksageApiKey: this.secretsCache.get("asksageApiKey"),
|
||||
xaiApiKey: this.secretsCache.get("xaiApiKey"),
|
||||
sambanovaApiKey: this.secretsCache.get("sambanovaApiKey"),
|
||||
cerebrasApiKey: this.secretsCache.get("cerebrasApiKey"),
|
||||
groqApiKey: this.secretsCache.get("groqApiKey"),
|
||||
basetenApiKey: this.secretsCache.get("basetenApiKey"),
|
||||
moonshotApiKey: this.secretsCache.get("moonshotApiKey"),
|
||||
nebiusApiKey: this.secretsCache.get("nebiusApiKey"),
|
||||
sapAiCoreClientId: this.secretsCache.get("sapAiCoreClientId"),
|
||||
sapAiCoreClientSecret: this.secretsCache.get("sapAiCoreClientSecret"),
|
||||
huggingFaceApiKey: this.secretsCache.get("huggingFaceApiKey"),
|
||||
|
||||
// Global state
|
||||
awsRegion: this.globalStateCache.get("awsRegion"),
|
||||
awsUseCrossRegionInference: this.globalStateCache.get("awsUseCrossRegionInference"),
|
||||
awsBedrockUsePromptCache: this.globalStateCache.get("awsBedrockUsePromptCache"),
|
||||
awsBedrockEndpoint: this.globalStateCache.get("awsBedrockEndpoint"),
|
||||
awsProfile: this.globalStateCache.get("awsProfile"),
|
||||
awsUseProfile: this.globalStateCache.get("awsUseProfile"),
|
||||
awsAuthentication: this.globalStateCache.get("awsAuthentication"),
|
||||
vertexProjectId: this.globalStateCache.get("vertexProjectId"),
|
||||
vertexRegion: this.globalStateCache.get("vertexRegion"),
|
||||
openAiBaseUrl: this.globalStateCache.get("openAiBaseUrl"),
|
||||
openAiHeaders: this.globalStateCache.get("openAiHeaders") || {},
|
||||
ollamaBaseUrl: this.globalStateCache.get("ollamaBaseUrl"),
|
||||
ollamaApiOptionsCtxNum: this.globalStateCache.get("ollamaApiOptionsCtxNum"),
|
||||
lmStudioBaseUrl: this.globalStateCache.get("lmStudioBaseUrl"),
|
||||
anthropicBaseUrl: this.globalStateCache.get("anthropicBaseUrl"),
|
||||
geminiBaseUrl: this.globalStateCache.get("geminiBaseUrl"),
|
||||
azureApiVersion: this.globalStateCache.get("azureApiVersion"),
|
||||
openRouterProviderSorting: this.globalStateCache.get("openRouterProviderSorting"),
|
||||
liteLlmBaseUrl: this.globalStateCache.get("liteLlmBaseUrl"),
|
||||
liteLlmUsePromptCache: this.globalStateCache.get("liteLlmUsePromptCache"),
|
||||
qwenApiLine: this.globalStateCache.get("qwenApiLine"),
|
||||
moonshotApiLine: this.globalStateCache.get("moonshotApiLine"),
|
||||
asksageApiUrl: this.globalStateCache.get("asksageApiUrl"),
|
||||
favoritedModelIds: this.globalStateCache.get("favoritedModelIds"),
|
||||
requestTimeoutMs: this.globalStateCache.get("requestTimeoutMs"),
|
||||
fireworksModelMaxCompletionTokens: this.globalStateCache.get("fireworksModelMaxCompletionTokens"),
|
||||
fireworksModelMaxTokens: this.globalStateCache.get("fireworksModelMaxTokens"),
|
||||
sapAiCoreBaseUrl: this.globalStateCache.get("sapAiCoreBaseUrl"),
|
||||
sapAiCoreTokenUrl: this.globalStateCache.get("sapAiCoreTokenUrl"),
|
||||
sapAiResourceGroup: this.globalStateCache.get("sapAiResourceGroup"),
|
||||
claudeCodePath: this.globalStateCache.get("claudeCodePath"),
|
||||
|
||||
// Plan mode configurations
|
||||
planModeApiProvider: this.globalStateCache.get("planModeApiProvider"),
|
||||
planModeApiModelId: this.globalStateCache.get("planModeApiModelId"),
|
||||
planModeThinkingBudgetTokens: this.globalStateCache.get("planModeThinkingBudgetTokens"),
|
||||
planModeReasoningEffort: this.globalStateCache.get("planModeReasoningEffort"),
|
||||
planModeVsCodeLmModelSelector: this.globalStateCache.get("planModeVsCodeLmModelSelector"),
|
||||
planModeAwsBedrockCustomSelected: this.globalStateCache.get("planModeAwsBedrockCustomSelected"),
|
||||
planModeAwsBedrockCustomModelBaseId: this.globalStateCache.get("planModeAwsBedrockCustomModelBaseId"),
|
||||
planModeOpenRouterModelId: this.globalStateCache.get("planModeOpenRouterModelId"),
|
||||
planModeOpenRouterModelInfo: this.globalStateCache.get("planModeOpenRouterModelInfo"),
|
||||
planModeOpenAiModelId: this.globalStateCache.get("planModeOpenAiModelId"),
|
||||
planModeOpenAiModelInfo: this.globalStateCache.get("planModeOpenAiModelInfo"),
|
||||
planModeOllamaModelId: this.globalStateCache.get("planModeOllamaModelId"),
|
||||
planModeLmStudioModelId: this.globalStateCache.get("planModeLmStudioModelId"),
|
||||
planModeLiteLlmModelId: this.globalStateCache.get("planModeLiteLlmModelId"),
|
||||
planModeLiteLlmModelInfo: this.globalStateCache.get("planModeLiteLlmModelInfo"),
|
||||
planModeRequestyModelId: this.globalStateCache.get("planModeRequestyModelId"),
|
||||
planModeRequestyModelInfo: this.globalStateCache.get("planModeRequestyModelInfo"),
|
||||
planModeTogetherModelId: this.globalStateCache.get("planModeTogetherModelId"),
|
||||
planModeFireworksModelId: this.globalStateCache.get("planModeFireworksModelId"),
|
||||
planModeSapAiCoreModelId: this.globalStateCache.get("planModeSapAiCoreModelId"),
|
||||
planModeGroqModelId: this.globalStateCache.get("planModeGroqModelId"),
|
||||
planModeGroqModelInfo: this.globalStateCache.get("planModeGroqModelInfo"),
|
||||
planModeBasetenModelId: this.globalStateCache.get("planModeBasetenModelId"),
|
||||
planModeBasetenModelInfo: this.globalStateCache.get("planModeBasetenModelInfo"),
|
||||
planModeHuggingFaceModelId: this.globalStateCache.get("planModeHuggingFaceModelId"),
|
||||
planModeHuggingFaceModelInfo: this.globalStateCache.get("planModeHuggingFaceModelInfo"),
|
||||
|
||||
// Act mode configurations
|
||||
actModeApiProvider: this.globalStateCache.get("actModeApiProvider"),
|
||||
actModeApiModelId: this.globalStateCache.get("actModeApiModelId"),
|
||||
actModeThinkingBudgetTokens: this.globalStateCache.get("actModeThinkingBudgetTokens"),
|
||||
actModeReasoningEffort: this.globalStateCache.get("actModeReasoningEffort"),
|
||||
actModeVsCodeLmModelSelector: this.globalStateCache.get("actModeVsCodeLmModelSelector"),
|
||||
actModeAwsBedrockCustomSelected: this.globalStateCache.get("actModeAwsBedrockCustomSelected"),
|
||||
actModeAwsBedrockCustomModelBaseId: this.globalStateCache.get("actModeAwsBedrockCustomModelBaseId"),
|
||||
actModeOpenRouterModelId: this.globalStateCache.get("actModeOpenRouterModelId"),
|
||||
actModeOpenRouterModelInfo: this.globalStateCache.get("actModeOpenRouterModelInfo"),
|
||||
actModeOpenAiModelId: this.globalStateCache.get("actModeOpenAiModelId"),
|
||||
actModeOpenAiModelInfo: this.globalStateCache.get("actModeOpenAiModelInfo"),
|
||||
actModeOllamaModelId: this.globalStateCache.get("actModeOllamaModelId"),
|
||||
actModeLmStudioModelId: this.globalStateCache.get("actModeLmStudioModelId"),
|
||||
actModeLiteLlmModelId: this.globalStateCache.get("actModeLiteLlmModelId"),
|
||||
actModeLiteLlmModelInfo: this.globalStateCache.get("actModeLiteLlmModelInfo"),
|
||||
actModeRequestyModelId: this.globalStateCache.get("actModeRequestyModelId"),
|
||||
actModeRequestyModelInfo: this.globalStateCache.get("actModeRequestyModelInfo"),
|
||||
actModeTogetherModelId: this.globalStateCache.get("actModeTogetherModelId"),
|
||||
actModeFireworksModelId: this.globalStateCache.get("actModeFireworksModelId"),
|
||||
actModeSapAiCoreModelId: this.globalStateCache.get("actModeSapAiCoreModelId"),
|
||||
actModeGroqModelId: this.globalStateCache.get("actModeGroqModelId"),
|
||||
actModeGroqModelInfo: this.globalStateCache.get("actModeGroqModelInfo"),
|
||||
actModeBasetenModelId: this.globalStateCache.get("actModeBasetenModelId"),
|
||||
actModeBasetenModelInfo: this.globalStateCache.get("actModeBasetenModelInfo"),
|
||||
actModeHuggingFaceModelId: this.globalStateCache.get("actModeHuggingFaceModelId"),
|
||||
actModeHuggingFaceModelInfo: this.globalStateCache.get("actModeHuggingFaceModelInfo"),
|
||||
} as ApiConfiguration
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const GlobalFileNames = {
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
groqModels: "groq_models.json",
|
||||
basetenModels: "baseten_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
workflows: ".clinerules/workflows",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const CACHE_SERVICE_NOT_INITIALIZED = "CacheService must be initialized before attempting to access state."
|
||||
@@ -29,6 +29,7 @@ export type SecretKey =
|
||||
| "sapAiCoreClientSecret"
|
||||
| "groqApiKey"
|
||||
| "huaweiCloudMaasApiKey"
|
||||
| "basetenApiKey"
|
||||
|
||||
export type GlobalStateKey =
|
||||
| "awsRegion"
|
||||
@@ -83,6 +84,7 @@ export type GlobalStateKey =
|
||||
| "sapAiCoreBaseUrl"
|
||||
| "sapAiResourceGroup"
|
||||
| "claudeCodePath"
|
||||
| "strictPlanModeEnabled"
|
||||
// Settings around plan/act and ephemeral model configuration
|
||||
| "preferredLanguage"
|
||||
| "openaiReasoningEffort"
|
||||
@@ -110,6 +112,8 @@ export type GlobalStateKey =
|
||||
| "planModeSapAiCoreModelId"
|
||||
| "planModeGroqModelId"
|
||||
| "planModeGroqModelInfo"
|
||||
| "planModeBasetenModelId"
|
||||
| "planModeBasetenModelInfo"
|
||||
| "planModeHuggingFaceModelId"
|
||||
| "planModeHuggingFaceModelInfo"
|
||||
| "planModeHuaweiCloudMaasModelId"
|
||||
@@ -137,6 +141,8 @@ export type GlobalStateKey =
|
||||
| "actModeSapAiCoreModelId"
|
||||
| "actModeGroqModelId"
|
||||
| "actModeGroqModelInfo"
|
||||
| "actModeBasetenModelId"
|
||||
| "actModeBasetenModelInfo"
|
||||
| "actModeHuggingFaceModelId"
|
||||
| "actModeHuggingFaceModelInfo"
|
||||
| "actModeHuaweiCloudMaasModelId"
|
||||
|
||||
+30
-256
@@ -12,6 +12,7 @@ import { UserInfo } from "@shared/UserInfo"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { DEFAULT_MCP_DISPLAY_MODE, McpDisplayMode } from "@shared/McpDisplayMode"
|
||||
import { migrateEnableCheckpointsSetting, migrateMcpMarketplaceEnableSetting } from "./state-migrations"
|
||||
import { Controller } from "../controller"
|
||||
/*
|
||||
Storage
|
||||
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
|
||||
@@ -166,6 +167,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
sambanovaApiKey,
|
||||
cerebrasApiKey,
|
||||
groqApiKey,
|
||||
basetenApiKey,
|
||||
moonshotApiKey,
|
||||
nebiusApiKey,
|
||||
huggingFaceApiKey,
|
||||
@@ -246,6 +248,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
getSecret(context, "sambanovaApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "cerebrasApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "groqApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "basetenApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "moonshotApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "nebiusApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "huggingFaceApiKey") as Promise<string | undefined>,
|
||||
@@ -282,6 +285,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
preferredLanguage,
|
||||
openaiReasoningEffort,
|
||||
mode,
|
||||
strictPlanModeEnabled,
|
||||
// Plan mode configurations
|
||||
planModeApiProvider,
|
||||
planModeApiModelId,
|
||||
@@ -305,6 +309,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
planModeSapAiCoreModelId,
|
||||
planModeGroqModelId,
|
||||
planModeGroqModelInfo,
|
||||
planModeBasetenModelId,
|
||||
planModeBasetenModelInfo,
|
||||
planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo,
|
||||
planModeHuaweiCloudMaasModelId,
|
||||
@@ -332,6 +338,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
actModeSapAiCoreModelId,
|
||||
actModeGroqModelId,
|
||||
actModeGroqModelInfo,
|
||||
actModeBasetenModelId,
|
||||
actModeBasetenModelInfo,
|
||||
actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo,
|
||||
actModeHuaweiCloudMaasModelId,
|
||||
@@ -340,6 +348,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
getGlobalState(context, "preferredLanguage") as Promise<string | undefined>,
|
||||
getGlobalState(context, "openaiReasoningEffort") as Promise<OpenaiReasoningEffort | undefined>,
|
||||
getGlobalState(context, "mode") as Promise<Mode | undefined>,
|
||||
getGlobalState(context, "strictPlanModeEnabled") as Promise<boolean | undefined>,
|
||||
// Plan mode configurations
|
||||
getGlobalState(context, "planModeApiProvider") as Promise<ApiProvider | undefined>,
|
||||
getGlobalState(context, "planModeApiModelId") as Promise<string | undefined>,
|
||||
@@ -363,6 +372,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
getGlobalState(context, "planModeSapAiCoreModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "planModeGroqModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "planModeGroqModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "planModeBasetenModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "planModeBasetenModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "planModeHuggingFaceModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "planModeHuggingFaceModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "planModeHuaweiCloudMaasModelId") as Promise<string | undefined>,
|
||||
@@ -390,6 +401,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
getGlobalState(context, "actModeSapAiCoreModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "actModeGroqModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "actModeGroqModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "actModeBasetenModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "actModeBasetenModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "actModeHuggingFaceModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "actModeHuggingFaceModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "actModeHuaweiCloudMaasModelId") as Promise<string | undefined>,
|
||||
@@ -483,6 +496,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
sambanovaApiKey,
|
||||
cerebrasApiKey,
|
||||
groqApiKey,
|
||||
basetenApiKey,
|
||||
moonshotApiKey,
|
||||
nebiusApiKey,
|
||||
favoritedModelIds,
|
||||
@@ -517,6 +531,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
planModeSapAiCoreModelId,
|
||||
planModeGroqModelId,
|
||||
planModeGroqModelInfo,
|
||||
planModeBasetenModelId,
|
||||
planModeBasetenModelInfo,
|
||||
planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo,
|
||||
planModeHuaweiCloudMaasModelId,
|
||||
@@ -544,6 +560,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
actModeSapAiCoreModelId,
|
||||
actModeGroqModelId,
|
||||
actModeGroqModelInfo,
|
||||
actModeBasetenModelId,
|
||||
actModeBasetenModelInfo,
|
||||
actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo,
|
||||
actModeHuaweiCloudMaasModelId,
|
||||
@@ -559,6 +577,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
preferredLanguage: preferredLanguage || "English",
|
||||
openaiReasoningEffort: (openaiReasoningEffort as OpenaiReasoningEffort) || "medium",
|
||||
mode: mode || "act",
|
||||
strictPlanModeEnabled: strictPlanModeEnabled ?? false,
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled: mcpMarketplaceEnabled,
|
||||
mcpDisplayMode: mcpDisplayMode ?? DEFAULT_MCP_DISPLAY_MODE,
|
||||
@@ -578,263 +597,18 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateApiConfiguration(context: vscode.ExtensionContext, apiConfiguration: ApiConfiguration) {
|
||||
const {
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsBedrockApiKey,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
awsAuthentication,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiHeaders,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
geminiBaseUrl,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
togetherApiKey,
|
||||
qwenApiKey,
|
||||
doubaoApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterProviderSorting,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmApiKey,
|
||||
liteLlmUsePromptCache,
|
||||
qwenApiLine,
|
||||
moonshotApiLine,
|
||||
asksageApiKey,
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
clineAccountId,
|
||||
sambanovaApiKey,
|
||||
cerebrasApiKey,
|
||||
groqApiKey,
|
||||
moonshotApiKey,
|
||||
nebiusApiKey,
|
||||
favoritedModelIds,
|
||||
fireworksApiKey,
|
||||
fireworksModelMaxCompletionTokens,
|
||||
fireworksModelMaxTokens,
|
||||
sapAiCoreClientId,
|
||||
sapAiCoreClientSecret,
|
||||
sapAiCoreBaseUrl,
|
||||
sapAiCoreTokenUrl,
|
||||
sapAiResourceGroup,
|
||||
claudeCodePath,
|
||||
huggingFaceApiKey,
|
||||
huaweiCloudMaasApiKey,
|
||||
// Plan mode configurations
|
||||
planModeApiProvider,
|
||||
planModeApiModelId,
|
||||
planModeThinkingBudgetTokens,
|
||||
planModeReasoningEffort,
|
||||
planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId,
|
||||
planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo,
|
||||
planModeOpenAiModelId,
|
||||
planModeOpenAiModelInfo,
|
||||
planModeOllamaModelId,
|
||||
planModeLmStudioModelId,
|
||||
planModeLiteLlmModelId,
|
||||
planModeLiteLlmModelInfo,
|
||||
planModeRequestyModelId,
|
||||
planModeRequestyModelInfo,
|
||||
planModeTogetherModelId,
|
||||
planModeFireworksModelId,
|
||||
planModeSapAiCoreModelId,
|
||||
planModeGroqModelId,
|
||||
planModeGroqModelInfo,
|
||||
planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo,
|
||||
planModeHuaweiCloudMaasModelId,
|
||||
planModeHuaweiCloudMaasModelInfo,
|
||||
// Act mode configurations
|
||||
actModeApiProvider,
|
||||
actModeApiModelId,
|
||||
actModeThinkingBudgetTokens,
|
||||
actModeReasoningEffort,
|
||||
actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId,
|
||||
actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo,
|
||||
actModeOpenAiModelId,
|
||||
actModeOpenAiModelInfo,
|
||||
actModeOllamaModelId,
|
||||
actModeLmStudioModelId,
|
||||
actModeLiteLlmModelId,
|
||||
actModeLiteLlmModelInfo,
|
||||
actModeRequestyModelId,
|
||||
actModeRequestyModelInfo,
|
||||
actModeTogetherModelId,
|
||||
actModeFireworksModelId,
|
||||
actModeSapAiCoreModelId,
|
||||
actModeGroqModelId,
|
||||
actModeGroqModelInfo,
|
||||
actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo,
|
||||
actModeHuaweiCloudMaasModelId,
|
||||
actModeHuaweiCloudMaasModelInfo,
|
||||
} = apiConfiguration
|
||||
export async function resetWorkspaceState(controller: Controller) {
|
||||
const context = controller.context
|
||||
await Promise.all(context.workspaceState.keys().map((key) => controller.context.workspaceState.update(key, undefined)))
|
||||
|
||||
// OPTIMIZED: Batch all global state updates into 2 operations instead of 47
|
||||
const batchedGlobalUpdates = {
|
||||
// Plan mode configuration updates
|
||||
planModeApiProvider,
|
||||
planModeApiModelId,
|
||||
planModeThinkingBudgetTokens,
|
||||
planModeReasoningEffort,
|
||||
planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected,
|
||||
planModeAwsBedrockCustomModelBaseId,
|
||||
planModeOpenRouterModelId,
|
||||
planModeOpenRouterModelInfo,
|
||||
planModeOpenAiModelId,
|
||||
planModeOpenAiModelInfo,
|
||||
planModeOllamaModelId,
|
||||
planModeLmStudioModelId,
|
||||
planModeLiteLlmModelId,
|
||||
planModeLiteLlmModelInfo,
|
||||
planModeRequestyModelId,
|
||||
planModeRequestyModelInfo,
|
||||
planModeTogetherModelId,
|
||||
planModeFireworksModelId,
|
||||
planModeSapAiCoreModelId,
|
||||
planModeGroqModelId,
|
||||
planModeGroqModelInfo,
|
||||
planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo,
|
||||
planModeHuaweiCloudMaasModelId,
|
||||
planModeHuaweiCloudMaasModelInfo,
|
||||
|
||||
// Act mode configuration updates
|
||||
actModeApiProvider,
|
||||
actModeApiModelId,
|
||||
actModeThinkingBudgetTokens,
|
||||
actModeReasoningEffort,
|
||||
actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected,
|
||||
actModeAwsBedrockCustomModelBaseId,
|
||||
actModeOpenRouterModelId,
|
||||
actModeOpenRouterModelInfo,
|
||||
actModeOpenAiModelId,
|
||||
actModeOpenAiModelInfo,
|
||||
actModeOllamaModelId,
|
||||
actModeLmStudioModelId,
|
||||
actModeLiteLlmModelId,
|
||||
actModeLiteLlmModelInfo,
|
||||
actModeRequestyModelId,
|
||||
actModeRequestyModelInfo,
|
||||
actModeTogetherModelId,
|
||||
actModeFireworksModelId,
|
||||
actModeSapAiCoreModelId,
|
||||
actModeGroqModelId,
|
||||
actModeGroqModelInfo,
|
||||
actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo,
|
||||
actModeHuaweiCloudMaasModelId,
|
||||
actModeHuaweiCloudMaasModelInfo,
|
||||
|
||||
// Global state updates (27 keys)
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
awsAuthentication,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiHeaders: openAiHeaders || {},
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiBaseUrl,
|
||||
azureApiVersion,
|
||||
openRouterProviderSorting,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmUsePromptCache,
|
||||
qwenApiLine,
|
||||
moonshotApiLine,
|
||||
asksageApiUrl,
|
||||
favoritedModelIds,
|
||||
requestTimeoutMs: apiConfiguration.requestTimeoutMs,
|
||||
fireworksModelMaxCompletionTokens,
|
||||
fireworksModelMaxTokens,
|
||||
sapAiCoreBaseUrl,
|
||||
sapAiCoreTokenUrl,
|
||||
sapAiResourceGroup,
|
||||
claudeCodePath,
|
||||
}
|
||||
|
||||
// OPTIMIZED: Batch all secret updates into 1 operation instead of 23
|
||||
const batchedSecretUpdates = {
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
clineAccountId,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsBedrockApiKey,
|
||||
openAiApiKey,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
togetherApiKey,
|
||||
qwenApiKey,
|
||||
doubaoApiKey,
|
||||
mistralApiKey,
|
||||
liteLlmApiKey,
|
||||
fireworksApiKey,
|
||||
asksageApiKey,
|
||||
xaiApiKey,
|
||||
sambanovaApiKey,
|
||||
cerebrasApiKey,
|
||||
groqApiKey,
|
||||
moonshotApiKey,
|
||||
nebiusApiKey,
|
||||
sapAiCoreClientId,
|
||||
sapAiCoreClientSecret,
|
||||
huggingFaceApiKey,
|
||||
huaweiCloudMaasApiKey,
|
||||
}
|
||||
|
||||
// Execute batched operations in parallel for maximum performance
|
||||
await Promise.all([updateGlobalStateBatch(context, batchedGlobalUpdates), updateSecretsBatch(context, batchedSecretUpdates)])
|
||||
await controller.cacheService.reInitialize()
|
||||
}
|
||||
|
||||
export async function resetWorkspaceState(context: vscode.ExtensionContext) {
|
||||
for (const key of context.workspaceState.keys()) {
|
||||
await context.workspaceState.update(key, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetGlobalState(context: vscode.ExtensionContext) {
|
||||
export async function resetGlobalState(controller: Controller) {
|
||||
// TODO: Reset all workspace states?
|
||||
for (const key of context.globalState.keys()) {
|
||||
await context.globalState.update(key, undefined)
|
||||
}
|
||||
const context = controller.context
|
||||
|
||||
await Promise.all(context.globalState.keys().map((key) => context.globalState.update(key, undefined)))
|
||||
const secretKeys: SecretKey[] = [
|
||||
"apiKey",
|
||||
"openRouterApiKey",
|
||||
@@ -859,12 +633,12 @@ export async function resetGlobalState(context: vscode.ExtensionContext) {
|
||||
"sambanovaApiKey",
|
||||
"cerebrasApiKey",
|
||||
"groqApiKey",
|
||||
"basetenApiKey",
|
||||
"moonshotApiKey",
|
||||
"nebiusApiKey",
|
||||
"huggingFaceApiKey",
|
||||
"huaweiCloudMaasApiKey",
|
||||
]
|
||||
for (const key of secretKeys) {
|
||||
await storeSecret(context, key, undefined)
|
||||
}
|
||||
await Promise.all(secretKeys.map((key) => storeSecret(context, key, undefined)))
|
||||
await controller.cacheService.reInitialize()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { showSystemNotification } from "@/integrations/notifications"
|
||||
import { listFiles } from "@/services/glob/list-files"
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
|
||||
import { regexSearchFiles } from "@/services/ripgrep"
|
||||
import { parseSourceCodeForDefinitionsTopLevel } from "@/services/tree-sitter"
|
||||
import { findLast, findLastIndex, parsePartialArrayString } from "@/shared/array"
|
||||
@@ -50,7 +50,7 @@ import { ContextManager } from "../context/context-management/ContextManager"
|
||||
import { loadMcpDocumentation } from "../prompts/loadMcpDocumentation"
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
import { ensureTaskDirectoryExists } from "../storage/disk"
|
||||
import { getGlobalState, getWorkspaceState } from "../storage/state"
|
||||
import { CacheService } from "../storage/CacheService"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import { AutoApprove } from "./tools/autoApprove"
|
||||
@@ -86,6 +86,7 @@ export class ToolExecutor {
|
||||
private clineIgnoreController: ClineIgnoreController,
|
||||
private workspaceTracker: WorkspaceTracker,
|
||||
private contextManager: ContextManager,
|
||||
private cacheService: CacheService,
|
||||
|
||||
// Configuration & Settings
|
||||
private autoApprovalSettings: AutoApprovalSettings,
|
||||
@@ -93,6 +94,7 @@ export class ToolExecutor {
|
||||
private cwd: string,
|
||||
private taskId: string,
|
||||
private mode: Mode,
|
||||
private strictPlanModeEnabled: boolean,
|
||||
|
||||
// Callbacks to the Task (Entity)
|
||||
private say: (
|
||||
@@ -106,7 +108,12 @@ export class ToolExecutor {
|
||||
type: ClineAsk,
|
||||
text?: string,
|
||||
partial?: boolean,
|
||||
) => Promise<{ response: ClineAskResponse; text?: string; images?: string[]; files?: string[] }>,
|
||||
) => Promise<{
|
||||
response: ClineAskResponse
|
||||
text?: string
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
}>,
|
||||
private saveCheckpoint: (isAttemptCompletionMessage?: boolean) => Promise<void>,
|
||||
private sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string, relPath?: string) => Promise<any>,
|
||||
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>,
|
||||
@@ -123,6 +130,22 @@ export class ToolExecutor {
|
||||
this.autoApprover.updateSettings(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the tools which should be restricted in plan mode
|
||||
*/
|
||||
private isPlanModeToolRestricted(toolName: ToolUseName): boolean {
|
||||
const planModeRestrictedTools: ToolUseName[] = ["write_to_file", "replace_in_file"]
|
||||
return planModeRestrictedTools.includes(toolName)
|
||||
}
|
||||
|
||||
public updateMode(mode: Mode): void {
|
||||
this.mode = mode
|
||||
}
|
||||
|
||||
public updateStrictPlanModeEnabled(strictPlanModeEnabled: boolean): void {
|
||||
this.strictPlanModeEnabled = strictPlanModeEnabled
|
||||
}
|
||||
|
||||
private pushToolResult = (content: ToolResponse, block: ToolUse) => {
|
||||
const isNextGenModel =
|
||||
isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api) || isGrok4ModelFamily(this.api)
|
||||
@@ -436,6 +459,15 @@ export class ToolExecutor {
|
||||
return
|
||||
}
|
||||
|
||||
// Logic for plan-model tool call restrictions
|
||||
if (this.strictPlanModeEnabled && this.mode === "plan" && block.name && this.isPlanModeToolRestricted(block.name)) {
|
||||
const errorMessage = `Tool '${block.name}' is not available in PLAN MODE. This tool is restricted to ACT MODE for file modifications. Only use tools available for PLAN MODE when in that mode.`
|
||||
await this.say("error", errorMessage)
|
||||
this.pushToolResult(formatResponse.toolError(errorMessage), block)
|
||||
await this.saveCheckpoint()
|
||||
return
|
||||
}
|
||||
|
||||
if (block.name !== "browser_action") {
|
||||
await this.browserSession.closeBrowser()
|
||||
}
|
||||
@@ -445,7 +477,7 @@ export class ToolExecutor {
|
||||
case "write_to_file":
|
||||
case "replace_in_file": {
|
||||
const relPath: string | undefined = block.params.path
|
||||
let content: string | undefined = block.params.content // for write_to_file
|
||||
const content: string | undefined = block.params.content // for write_to_file
|
||||
let diff: string | undefined = block.params.diff // for replace_in_file
|
||||
if (!relPath || (!content && !diff)) {
|
||||
// checking for content/diff ensures relPath is complete
|
||||
@@ -1185,7 +1217,7 @@ export class ToolExecutor {
|
||||
if (this.context) {
|
||||
await this.browserSession.dispose()
|
||||
|
||||
let useWebp = this.api ? !modelDoesntSupportWebp(this.api) : true
|
||||
const useWebp = this.api ? !modelDoesntSupportWebp(this.api) : true
|
||||
this.browserSession = new BrowserSession(this.context, this.browserSettings, useWebp)
|
||||
} else {
|
||||
console.warn("no controller context available for browserSession")
|
||||
@@ -1931,10 +1963,8 @@ export class ToolExecutor {
|
||||
vscode.extensions.getExtension("saoudrizwan.claude-dev")?.packageJSON.version || "Unknown"
|
||||
const systemInfo = `VSCode: ${vscode.version}, Node.js: ${process.version}, Architecture: ${os.arch()}`
|
||||
const currentMode = this.mode
|
||||
const apiProvider =
|
||||
currentMode === "plan"
|
||||
? await getGlobalState(this.context, "planModeApiProvider")
|
||||
: await getGlobalState(this.context, "actModeApiProvider")
|
||||
const apiConfig = this.cacheService.getApiConfiguration()
|
||||
const apiProvider = currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
|
||||
const providerAndModel = `${apiProvider} / ${this.api.getModel().id}`
|
||||
|
||||
// Ask user for confirmation
|
||||
|
||||
+59
-29
@@ -14,7 +14,7 @@ import { BrowserSession } from "@services/browser/BrowserSession"
|
||||
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
|
||||
import { listFiles } from "@services/glob/list-files"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
import { ApiConfiguration } from "@shared/api"
|
||||
import { findLast, findLastIndex } from "@shared/array"
|
||||
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
@@ -38,7 +38,7 @@ import * as vscode from "vscode"
|
||||
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ClineErrorType } from "@/services/error/ClineError"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { errorService } from "@/services/posthog/PostHogClientProvider"
|
||||
import { parseAssistantMessageV2, parseAssistantMessageV3, ToolUseName } from "@core/assistant-message"
|
||||
import {
|
||||
checkIsAnthropicContextWindowError,
|
||||
@@ -85,6 +85,7 @@ import { MessageStateHandler } from "./message-state"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { ToolExecutor } from "./ToolExecutor"
|
||||
import { updateApiReqMsg } from "./utils"
|
||||
import { CacheService } from "../storage/CacheService"
|
||||
import { Mode, OpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
|
||||
@@ -130,6 +131,9 @@ export class Task {
|
||||
private reinitExistingTaskFromId: (taskId: string) => Promise<void>
|
||||
private cancelTask: () => Promise<void>
|
||||
|
||||
// Cache service
|
||||
private cacheService: CacheService
|
||||
|
||||
// User chat state
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
browserSettings: BrowserSettings
|
||||
@@ -153,12 +157,14 @@ export class Task {
|
||||
preferredLanguage: string,
|
||||
openaiReasoningEffort: OpenaiReasoningEffort,
|
||||
mode: Mode,
|
||||
strictPlanModeEnabled: boolean,
|
||||
shellIntegrationTimeout: number,
|
||||
terminalReuseEnabled: boolean,
|
||||
terminalOutputLineLimit: number,
|
||||
defaultTerminalProfile: string,
|
||||
enableCheckpointsSetting: boolean,
|
||||
cwd: string,
|
||||
cacheService: CacheService,
|
||||
task?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
@@ -202,6 +208,7 @@ export class Task {
|
||||
this.mode = mode
|
||||
this.enableCheckpoints = enableCheckpointsSetting
|
||||
this.cwd = cwd
|
||||
this.cacheService = cacheService
|
||||
|
||||
// Set up MCP notification callback for real-time notifications
|
||||
this.mcpHub.setNotificationCallback(async (serverName: string, level: string, message: string) => {
|
||||
@@ -236,7 +243,7 @@ export class Task {
|
||||
this.modelContextTracker = new ModelContextTracker(context, this.taskId)
|
||||
|
||||
// Prepare effective API configuration
|
||||
let effectiveApiConfiguration: ApiConfiguration = {
|
||||
const effectiveApiConfiguration: ApiConfiguration = {
|
||||
...apiConfiguration,
|
||||
taskId: this.taskId,
|
||||
onRetryAttempt: async (attempt: number, maxRetries: number, delay: number, error: any) => {
|
||||
@@ -318,11 +325,13 @@ export class Task {
|
||||
this.clineIgnoreController,
|
||||
this.workspaceTracker,
|
||||
this.contextManager,
|
||||
this.cacheService,
|
||||
this.autoApprovalSettings,
|
||||
this.browserSettings,
|
||||
cwd,
|
||||
this.taskId,
|
||||
this.mode,
|
||||
strictPlanModeEnabled,
|
||||
this.say.bind(this),
|
||||
this.ask.bind(this),
|
||||
this.saveCheckpoint.bind(this),
|
||||
@@ -333,6 +342,15 @@ export class Task {
|
||||
)
|
||||
}
|
||||
|
||||
public updateMode(mode: Mode): void {
|
||||
this.mode = mode
|
||||
this.toolExecutor.updateMode(mode)
|
||||
}
|
||||
|
||||
public updateStrictPlanMode(strictPlanModeEnabled: boolean): void {
|
||||
this.toolExecutor.updateStrictPlanModeEnabled(strictPlanModeEnabled)
|
||||
}
|
||||
|
||||
// While a task is ref'd by a controller, it will always have access to the extension context
|
||||
// This error is thrown if the controller derefs the task after e.g., aborting the task
|
||||
private getContext(): vscode.ExtensionContext {
|
||||
@@ -456,7 +474,7 @@ export class Task {
|
||||
if (!didWorkspaceRestoreFail) {
|
||||
switch (restoreType) {
|
||||
case "task":
|
||||
case "taskAndWorkspace":
|
||||
case "taskAndWorkspace": {
|
||||
this.taskState.conversationHistoryDeletedRange = message.conversationHistoryDeletedRange
|
||||
const apiConversationHistory = this.messageStateHandler.getApiConversationHistory()
|
||||
const newConversationHistory = apiConversationHistory.slice(0, (message.conversationHistoryIndex || 0) + 2) // +1 since this index corresponds to the last user message, and another +1 since slice end index is exclusive
|
||||
@@ -499,6 +517,7 @@ export class Task {
|
||||
} satisfies ClineApiReqInfo),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "workspace":
|
||||
break
|
||||
}
|
||||
@@ -1025,9 +1044,9 @@ export class Task {
|
||||
|
||||
this.taskState.isInitialized = true
|
||||
|
||||
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
|
||||
const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
|
||||
|
||||
let userContent: UserContent = [
|
||||
const userContent: UserContent = [
|
||||
{
|
||||
type: "text",
|
||||
text: `<task>\n${task}\n</task>`,
|
||||
@@ -1154,7 +1173,7 @@ export class Task {
|
||||
throw new Error("Unexpected: No existing API conversation history")
|
||||
}
|
||||
|
||||
let newUserContent: UserContent = [...modifiedOldUserContent]
|
||||
const newUserContent: UserContent = [...modifiedOldUserContent]
|
||||
|
||||
const agoText = (() => {
|
||||
const timestamp = lastClineMessage?.ts ?? Date.now()
|
||||
@@ -1610,7 +1629,7 @@ export class Task {
|
||||
// grouping command_output messages despite any gaps anyways)
|
||||
await setTimeoutPromise(50)
|
||||
|
||||
let result = this.terminalManager.processOutput(outputLines)
|
||||
const result = this.terminalManager.processOutput(outputLines)
|
||||
|
||||
if (userFeedback) {
|
||||
await this.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
|
||||
@@ -1659,18 +1678,21 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
private async getCurrentProviderInfo(): Promise<{ modelId: string; providerId: string }> {
|
||||
private async getCurrentProviderInfo(): Promise<{
|
||||
modelId: string
|
||||
providerId: string
|
||||
}> {
|
||||
const modelId = this.api.getModel()?.id
|
||||
const providerId =
|
||||
this.mode === "plan"
|
||||
? ((await getGlobalState(this.getContext(), "planModeApiProvider")) as string)
|
||||
: ((await getGlobalState(this.getContext(), "actModeApiProvider")) as string)
|
||||
const apiConfig = this.cacheService.getApiConfiguration()
|
||||
const providerId = (this.mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
|
||||
return { modelId, providerId }
|
||||
}
|
||||
|
||||
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
|
||||
// Wait for MCP servers to be connected before generating system prompt
|
||||
await pWaitFor(() => this.mcpHub.isConnecting !== true, { timeout: 10_000 }).catch(() => {
|
||||
await pWaitFor(() => this.mcpHub.isConnecting !== true, {
|
||||
timeout: 10_000,
|
||||
}).catch(() => {
|
||||
console.error("MCP servers failed to connect in time")
|
||||
})
|
||||
|
||||
@@ -1747,7 +1769,7 @@ export class Task {
|
||||
// saves task history item which we use to keep track of conversation history deleted range
|
||||
}
|
||||
|
||||
let stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
|
||||
const stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
@@ -1763,17 +1785,12 @@ export class Task {
|
||||
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
|
||||
const isAnthropicContextWindowError = checkIsAnthropicContextWindowError(error) && isAnthropic
|
||||
const { modelId, providerId } = await this.getCurrentProviderInfo()
|
||||
const clineError = ErrorService.toClineError(error, modelId, providerId)
|
||||
const clineError = errorService.toClineError(error, modelId, providerId)
|
||||
|
||||
// Capture provider failure telemetry using clineError
|
||||
// TODO: Move into ErrorService
|
||||
telemetryService.captureProviderApiError({
|
||||
taskId: this.taskId,
|
||||
model: modelInfo.id,
|
||||
errorMessage: clineError.message,
|
||||
errorStatus: clineError._error?.status,
|
||||
requestId: clineError._error?.request_id,
|
||||
})
|
||||
// TODO: Move into errorService
|
||||
errorService.logMessage(clineError.message)
|
||||
errorService.logException(clineError)
|
||||
|
||||
if (isAnthropic && isAnthropicContextWindowError && !this.taskState.didAutomaticallyRetryFailedApiRequest) {
|
||||
this.taskState.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
|
||||
@@ -1852,12 +1869,24 @@ export class Task {
|
||||
throw new Error("API request failed")
|
||||
}
|
||||
|
||||
// Do not retry automatically again if currently unauthenticated
|
||||
if (clineError.isErrorType(ClineErrorType.Auth)) {
|
||||
return
|
||||
// Clear streamingFailedMessage when user manually retries
|
||||
const manualRetryApiReqIndex = findLastIndex(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
(m) => m.say === "api_req_started",
|
||||
)
|
||||
if (manualRetryApiReqIndex !== -1) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const currentApiReqInfo: ClineApiReqInfo = JSON.parse(clineMessages[manualRetryApiReqIndex].text || "{}")
|
||||
delete currentApiReqInfo.streamingFailedMessage
|
||||
await this.messageStateHandler.updateClineMessage(manualRetryApiReqIndex, {
|
||||
text: JSON.stringify(currentApiReqInfo),
|
||||
})
|
||||
}
|
||||
|
||||
await this.say("api_req_retried")
|
||||
|
||||
// Reset the automatic retry flag so the request can proceed
|
||||
this.taskState.didAutomaticallyRetryFailedApiRequest = false
|
||||
}
|
||||
// delegate generator output from the recursive call
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
@@ -2316,7 +2345,7 @@ export class Task {
|
||||
await this.say("reasoning", reasoningMessage, undefined, undefined, true)
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
case "text": {
|
||||
if (reasoningMessage && assistantMessage.length === 0) {
|
||||
// complete reasoning message
|
||||
await this.say("reasoning", reasoningMessage, undefined, undefined, false)
|
||||
@@ -2337,6 +2366,7 @@ export class Task {
|
||||
// present content to user
|
||||
this.presentAssistantMessage()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (this.taskState.abort) {
|
||||
@@ -2367,7 +2397,7 @@ export class Task {
|
||||
// abandoned happens when extension is no longer waiting for the cline instance to finish aborting (error is thrown here when any function in the for loop throws due to this.abort)
|
||||
if (!this.taskState.abandoned) {
|
||||
this.abortTask() // if the stream failed, there's various states the task could be in (i.e. could have streamed some tools the user may have executed), so we just resort to replicating a cancel task
|
||||
const clineError = ErrorService.toClineError(error, this.api.getModel().id)
|
||||
const clineError = errorService.toClineError(error, this.api.getModel().id)
|
||||
const errorMessage = clineError.serialize()
|
||||
|
||||
await abortStream("streaming_failed", errorMessage)
|
||||
|
||||
@@ -18,7 +18,6 @@ export abstract class WebviewProvider {
|
||||
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
|
||||
private static activeInstances: Set<WebviewProvider> = new Set()
|
||||
private static clientIdMap = new Map<WebviewProvider, string>()
|
||||
protected disposables: vscode.Disposable[] = []
|
||||
controller: Controller
|
||||
private clientId: string
|
||||
|
||||
@@ -30,6 +29,8 @@ export abstract class WebviewProvider {
|
||||
WebviewProvider.activeInstances.add(this)
|
||||
this.clientId = uuidv4()
|
||||
WebviewProvider.clientIdMap.set(this, this.clientId)
|
||||
|
||||
// Create controller with cache service
|
||||
this.controller = new Controller(context, (message) => this.postMessageToWebview(message), this.clientId)
|
||||
}
|
||||
|
||||
@@ -44,12 +45,6 @@ export abstract class WebviewProvider {
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
while (this.disposables.length) {
|
||||
const x = this.disposables.pop()
|
||||
if (x) {
|
||||
x.dispose()
|
||||
}
|
||||
}
|
||||
await this.controller.dispose()
|
||||
WebviewProvider.activeInstances.delete(this)
|
||||
// Remove from client ID map
|
||||
|
||||
+37
-73
@@ -1,11 +1,11 @@
|
||||
// The module 'vscode' contains the VS Code extensibility API
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
import { DIFF_VIEW_URI_SCHEME } from "@hosts/vscode/VscodeDiffViewProvider"
|
||||
import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/cline/ui"
|
||||
|
||||
import assert from "node:assert"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import { DIFF_VIEW_URI_SCHEME } from "@hosts/vscode/VscodeDiffViewProvider"
|
||||
import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/cline/ui"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import * as vscode from "vscode"
|
||||
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
|
||||
import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChatButtonClicked"
|
||||
@@ -19,25 +19,26 @@ import {
|
||||
} from "./core/storage/state-migrations"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
import { createClineAPI } from "./exports"
|
||||
import { ErrorService } from "./services/error/ErrorService"
|
||||
import { Logger } from "./services/logging/Logger"
|
||||
import { posthogClientProvider } from "./services/posthog/PostHogClientProvider"
|
||||
import { telemetryService } from "./services/posthog/telemetry/TelemetryService"
|
||||
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
|
||||
import { cleanupTestMode, initializeTestMode } from "./services/test/TestMode"
|
||||
import { WebviewProviderType } from "./shared/webview/types"
|
||||
import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
|
||||
import { readTextFromClipboard, writeTextToClipboard } from "@/utils/env"
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
|
||||
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
|
||||
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
|
||||
import { GitCommitGenerator } from "./integrations/git/commit-message-generator"
|
||||
import { AuthService } from "./services/auth/AuthService"
|
||||
import { telemetryService } from "./services/posthog/PostHogClientProvider"
|
||||
import { ShowMessageType } from "./shared/proto/host/window"
|
||||
import { SharedUriHandler } from "./services/uri/SharedUriHandler"
|
||||
import { getLatestAnnouncementId } from "./utils/announcements"
|
||||
/*
|
||||
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
|
||||
@@ -52,7 +53,10 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/framewo
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
maybeSetupHostProviders(context)
|
||||
|
||||
ErrorService.initialize()
|
||||
// Initialize PostHog client provider
|
||||
const distinctId = context.globalState.get<string>("cline.distinctId")
|
||||
PostHogClientProvider.getInstance(distinctId)
|
||||
|
||||
Logger.log("Cline extension activated")
|
||||
|
||||
// Migrate custom instructions to global Cline rules (one-time cleanup)
|
||||
@@ -91,7 +95,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
// Use the same condition as announcements: focus when there's a new announcement to show
|
||||
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
|
||||
const latestAnnouncementId = context.extension?.packageJSON?.version?.split(".").slice(0, 2).join(".") ?? ""
|
||||
const latestAnnouncementId = getLatestAnnouncementId(context)
|
||||
|
||||
if (lastShownAnnouncementId !== latestAnnouncementId) {
|
||||
// Focus Cline when there's a new announcement to show (major/minor updates or fresh installs)
|
||||
@@ -100,7 +104,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
: `Welcome to Cline v${currentVersion}`
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
HostProvider.window.showMessage({ type: ShowMessageType.INFORMATION, message })
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message,
|
||||
})
|
||||
}
|
||||
// Always update the main version tracker for the next launch.
|
||||
await context.globalState.update("clineVersion", currentVersion)
|
||||
@@ -110,15 +117,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
|
||||
}
|
||||
|
||||
// backup id in case vscMachineID doesn't work
|
||||
let installId = context.globalState.get<string>("installId")
|
||||
|
||||
if (!installId) {
|
||||
installId = uuidv4()
|
||||
await context.globalState.update("installId", installId)
|
||||
}
|
||||
|
||||
telemetryService.captureExtensionActivated(installId)
|
||||
telemetryService.captureExtensionActivated()
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.plusButtonClicked", async (webview: any) => {
|
||||
@@ -265,44 +264,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
})()
|
||||
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(DIFF_VIEW_URI_SCHEME, diffContentProvider))
|
||||
|
||||
// URI Handler
|
||||
const handleUri = async (uri: vscode.Uri) => {
|
||||
console.log("URI Handler called with:", {
|
||||
path: uri.path,
|
||||
query: uri.query,
|
||||
scheme: uri.scheme,
|
||||
})
|
||||
|
||||
const path = uri.path
|
||||
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
if (!visibleWebview) {
|
||||
return
|
||||
}
|
||||
switch (path) {
|
||||
case "/openrouter": {
|
||||
const code = query.get("code")
|
||||
if (code) {
|
||||
await visibleWebview?.controller.handleOpenRouterCallback(code)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "/auth": {
|
||||
console.log("Auth callback received:", uri.toString())
|
||||
|
||||
const token = query.get("idToken")
|
||||
const provider = query.get("provider")
|
||||
|
||||
console.log("Auth callback received:", { provider })
|
||||
|
||||
if (token) {
|
||||
await visibleWebview?.controller.handleAuthCallback(token, provider)
|
||||
// await authService.handleAuthCallback(token)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
const success = await SharedUriHandler.handleUri(uri)
|
||||
if (!success) {
|
||||
console.warn("Extension URI handler: Failed to process URI:", uri.toString())
|
||||
}
|
||||
}
|
||||
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
|
||||
@@ -369,7 +334,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
|
||||
|
||||
// Get copied content
|
||||
let terminalContents = (await readTextFromClipboard()).trim()
|
||||
const terminalContents = (await readTextFromClipboard()).trim()
|
||||
|
||||
// Restore original clipboard content
|
||||
await writeTextToClipboard(tempCopyBuffer)
|
||||
@@ -585,7 +550,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
let activeWebviewProvider: WebviewProvider | undefined = WebviewProvider.getVisibleInstance()
|
||||
|
||||
// If a tab is visible and active, ensure it's fully revealed (might be redundant but safe)
|
||||
if (activeWebviewProvider?.getWebview() && activeWebviewProvider.getWebview().hasOwnProperty("reveal")) {
|
||||
if (activeWebviewProvider?.getWebview() && Object.hasOwn(activeWebviewProvider.getWebview(), "reveal")) {
|
||||
const panelView = activeWebviewProvider.getWebview() as vscode.WebviewPanel
|
||||
panelView.reveal(panelView.viewColumn)
|
||||
} else if (!activeWebviewProvider) {
|
||||
@@ -600,7 +565,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const tabInstances = WebviewProvider.getTabInstances()
|
||||
if (tabInstances.length > 0) {
|
||||
const potentialTabInstance = tabInstances[tabInstances.length - 1] // Get the most recent one
|
||||
if (potentialTabInstance.getWebview() && potentialTabInstance.getWebview().hasOwnProperty("reveal")) {
|
||||
if (potentialTabInstance.getWebview() && Object.hasOwn(potentialTabInstance.getWebview(), "reveal")) {
|
||||
const panelView = potentialTabInstance.getWebview() as vscode.WebviewPanel
|
||||
panelView.reveal(panelView.viewColumn)
|
||||
activeWebviewProvider = potentialTabInstance
|
||||
@@ -617,7 +582,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
() => {
|
||||
const visibleInstance = WebviewProvider.getVisibleInstance()
|
||||
// Ensure a boolean is returned
|
||||
return !!(visibleInstance?.getWebview() && visibleInstance.getWebview().hasOwnProperty("reveal"))
|
||||
return !!(visibleInstance?.getWebview() && Object.hasOwn(visibleInstance.getWebview(), "reveal"))
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
@@ -664,7 +629,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
if (event.key === "clineAccountId") {
|
||||
// Check if the secret was removed (logout) or added/updated (login)
|
||||
const secretValue = await context.secrets.get("clineAccountId")
|
||||
const authService = AuthService.getInstance(context)
|
||||
const activeWebviewProvider = WebviewProvider.getVisibleInstance()
|
||||
const controller = activeWebviewProvider?.controller
|
||||
|
||||
const authService = AuthService.getInstance(controller)
|
||||
if (secretValue) {
|
||||
// Secret was added or updated - restore auth info (login from another window)
|
||||
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
@@ -679,35 +647,31 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
return createClineAPI(sidebarWebview.controller)
|
||||
}
|
||||
|
||||
export function getLatestAnnouncementId(context: vscode.ExtensionContext) {
|
||||
return context.extension?.packageJSON?.version?.split(".").slice(0, 2).join(".") ?? ""
|
||||
}
|
||||
|
||||
function maybeSetupHostProviders(context: ExtensionContext) {
|
||||
if (!HostProvider.isInitialized()) {
|
||||
console.log("Setting up vscode host providers...")
|
||||
|
||||
const createWebview = function (type: WebviewProviderType) {
|
||||
return new VscodeWebviewProvider(context, type)
|
||||
}
|
||||
const createDiffView = function () {
|
||||
return new VscodeDiffViewProvider()
|
||||
}
|
||||
const createWebview = (type: WebviewProviderType) => new VscodeWebviewProvider(context, type)
|
||||
const createDiffView = () => new VscodeDiffViewProvider()
|
||||
const outputChannel = vscode.window.createOutputChannel("Cline")
|
||||
context.subscriptions.push(outputChannel)
|
||||
|
||||
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient, outputChannel.appendLine)
|
||||
const getCallbackUri = async function () {
|
||||
return `${vscode.env.uriScheme || "vscode"}://saoudrizwan.claude-dev`
|
||||
}
|
||||
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient, outputChannel.appendLine, getCallbackUri)
|
||||
}
|
||||
}
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
export async function deactivate() {
|
||||
PostHogClientProvider.getInstance().dispose()
|
||||
|
||||
// Dispose all webview instances
|
||||
await WebviewProvider.disposeAllInstances()
|
||||
|
||||
// Clean up test mode
|
||||
cleanupTestMode()
|
||||
await posthogClientProvider.shutdown()
|
||||
|
||||
Logger.log("Cline extension deactivated")
|
||||
}
|
||||
|
||||
Vendored
+270
@@ -0,0 +1,270 @@
|
||||
import type { IncomingMessage, Server, ServerResponse } from "node:http"
|
||||
import http from "node:http"
|
||||
import type { AddressInfo } from "node:net"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { SharedUriHandler } from "@/services/uri/SharedUriHandler"
|
||||
|
||||
const SERVER_TIMEOUT = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
/**
|
||||
* Handles OAuth authentication flow by creating a local server to receive tokens.
|
||||
*/
|
||||
export class AuthHandler {
|
||||
private static instance: AuthHandler | null = null
|
||||
|
||||
private port = 0
|
||||
private server: Server | null = null
|
||||
private serverCreationPromise: Promise<void> | null = null
|
||||
private timeoutId: NodeJS.Timeout | null = null
|
||||
private enabled: boolean = false
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance of AuthHandler
|
||||
* @returns The singleton AuthHandler instance
|
||||
*/
|
||||
public static getInstance(): AuthHandler {
|
||||
if (!AuthHandler.instance) {
|
||||
AuthHandler.instance = new AuthHandler()
|
||||
}
|
||||
return AuthHandler.instance
|
||||
}
|
||||
|
||||
public setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled
|
||||
}
|
||||
|
||||
public async getCallbackUri(): Promise<string> {
|
||||
if (!this.enabled) {
|
||||
throw Error("AuthHandler was not enabled")
|
||||
}
|
||||
|
||||
if (!this.server) {
|
||||
// If server creation is already in progress, wait for it
|
||||
if (this.serverCreationPromise) {
|
||||
await this.serverCreationPromise
|
||||
} else {
|
||||
// Start server creation and track the promise
|
||||
this.serverCreationPromise = this.createServer()
|
||||
await this.serverCreationPromise
|
||||
}
|
||||
} else {
|
||||
this.updateTimeout()
|
||||
}
|
||||
|
||||
return `http://127.0.0.1:${this.port}`
|
||||
}
|
||||
|
||||
private async createServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const server = http.createServer(this.handleRequest.bind(this))
|
||||
|
||||
// Use callback to ensure server is ready before getting address
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (!address) {
|
||||
console.error("AuthHandler: Failed to get server address")
|
||||
this.server = null
|
||||
this.port = 0
|
||||
this.serverCreationPromise = null
|
||||
reject(new Error("Failed to get server address"))
|
||||
return
|
||||
}
|
||||
|
||||
// Get the assigned port and set up the server
|
||||
this.port = (address as AddressInfo).port
|
||||
this.server = server
|
||||
console.log("AuthHandler: Server started on port", this.port)
|
||||
this.updateTimeout()
|
||||
this.serverCreationPromise = null
|
||||
resolve()
|
||||
})
|
||||
|
||||
server.on("error", (error) => {
|
||||
console.error("AuthHandler: Server error", error)
|
||||
this.server = null
|
||||
this.port = 0
|
||||
this.serverCreationPromise = null
|
||||
reject(error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("AuthHandler: Failed to create server", error)
|
||||
this.server = null
|
||||
this.port = 0
|
||||
this.serverCreationPromise = null
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private updateTimeout(): void {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
}
|
||||
|
||||
this.timeoutId = setTimeout(() => this.stop(), SERVER_TIMEOUT)
|
||||
}
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
console.log("AuthHandler: Received request", req.url)
|
||||
|
||||
if (!req.url) {
|
||||
this.sendResponse(res, 404, "text/plain", "Not found")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert HTTP URL to vscode.Uri and use shared handler directly
|
||||
const fullUrl = `http://127.0.0.1:${this.port}${req.url}`
|
||||
const uri = SharedUriHandler.convertHttpUrlToUri(fullUrl)
|
||||
|
||||
// Use SharedUriHandler directly - it handles all validation and processing
|
||||
const success = await SharedUriHandler.handleUri(uri)
|
||||
|
||||
if (success) {
|
||||
this.sendResponse(res, 200, "text/html", TOKEN_REQUEST_VIEW)
|
||||
} else {
|
||||
this.sendResponse(res, 400, "text/plain", "Bad request")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AuthHandler: Error processing request", error)
|
||||
this.sendResponse(res, 400, "text/plain", "Bad request")
|
||||
} finally {
|
||||
// Stop the server after handling any request (success or failure)
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private sendResponse(res: ServerResponse, status: number, type: string, content: string): void {
|
||||
res.writeHead(status, { "Content-Type": type })
|
||||
res.end(content)
|
||||
}
|
||||
|
||||
private async openBrowser(callbackUrl: URL): Promise<void> {
|
||||
await openExternal(callbackUrl.toString())
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
this.timeoutId = null
|
||||
}
|
||||
|
||||
if (this.server) {
|
||||
this.server.close()
|
||||
this.server = null
|
||||
}
|
||||
|
||||
this.serverCreationPromise = null
|
||||
this.port = 0
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_REQUEST_VIEW = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cline - Authentication Success</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Azeret+Mono:wght@300;400;700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Azeret Mono', monospace;
|
||||
background-color: #ffffff;
|
||||
color: #333333;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
background-color: #f8f8f8;
|
||||
border: 1px solid #e1e1e1;
|
||||
border-radius: 6px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background-color: #73c991;
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.checkmark::after {
|
||||
content: '✓';
|
||||
font-size: 24px;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 24px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-size: 0.8125rem;
|
||||
color: #666666;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d1d1d1;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="checkmark"></div>
|
||||
<h1>Authentication Successful</h1>
|
||||
<p>Your authentication token has been securely sent back to your IDE. You can now return to your development environment to continue working.</p>
|
||||
<div class="countdown">Feel free to close this window and continue in your IDE</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -26,17 +26,22 @@ export class HostProvider {
|
||||
// Logs to a user-visible output channel.
|
||||
logToChannel: LogToChannel
|
||||
|
||||
// Returns a callback URI that will redirect to Cline.
|
||||
getCallbackUri: () => Promise<string>
|
||||
|
||||
// Private constructor to enforce singleton pattern
|
||||
private constructor(
|
||||
createWebviewProvider: WebviewProviderCreator,
|
||||
createDiffViewProvider: DiffViewProviderCreator,
|
||||
hostBridge: HostBridgeClientProvider,
|
||||
logToChannel: LogToChannel,
|
||||
getCallbackUri: () => Promise<string>,
|
||||
) {
|
||||
this.createWebviewProvider = createWebviewProvider
|
||||
this.createDiffViewProvider = createDiffViewProvider
|
||||
this.hostBridge = hostBridge
|
||||
this.logToChannel = logToChannel
|
||||
this.getCallbackUri = getCallbackUri
|
||||
}
|
||||
|
||||
public static initialize(
|
||||
@@ -44,6 +49,7 @@ export class HostProvider {
|
||||
diffViewProviderCreator: DiffViewProviderCreator,
|
||||
hostBridgeProvider: HostBridgeClientProvider,
|
||||
logToChannel: LogToChannel,
|
||||
getCallbackUri: () => Promise<string>,
|
||||
): HostProvider {
|
||||
if (HostProvider.instance) {
|
||||
throw new Error("Host providers have already been initialized.")
|
||||
@@ -53,6 +59,7 @@ export class HostProvider {
|
||||
diffViewProviderCreator,
|
||||
hostBridgeProvider,
|
||||
logToChannel,
|
||||
getCallbackUri,
|
||||
)
|
||||
return HostProvider.instance
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
|
||||
*/
|
||||
|
||||
export class VscodeWebviewProvider extends WebviewProvider implements vscode.WebviewViewProvider {
|
||||
public webview?: vscode.WebviewView | vscode.WebviewPanel
|
||||
private webview?: vscode.WebviewView | vscode.WebviewPanel
|
||||
private disposables: vscode.Disposable[] = []
|
||||
|
||||
constructor(context: vscode.ExtensionContext, providerType: WebviewProviderType) {
|
||||
super(context, providerType)
|
||||
@@ -166,6 +167,12 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
if (this.webview && "dispose" in this.webview) {
|
||||
this.webview.dispose()
|
||||
}
|
||||
while (this.disposables.length) {
|
||||
const x = this.disposables.pop()
|
||||
if (x) {
|
||||
x.dispose()
|
||||
}
|
||||
}
|
||||
super.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { strict as assert } from "assert"
|
||||
import * as vscode from "vscode"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import { getOpenTabs } from "@/hosts/vscode/hostbridge/window/getOpenTabs"
|
||||
import { GetOpenTabsRequest } from "@/shared/proto/host/window"
|
||||
|
||||
@@ -54,8 +55,18 @@ describe("Hostbridge - Window - getOpenTabs", () => {
|
||||
await createAndOpenTestDocument(1, vscode.ViewColumn.One)
|
||||
await createAndOpenTestDocument(2, vscode.ViewColumn.Two)
|
||||
|
||||
// Wait a bit for tabs to be fully created
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
// Wait for tabs to be fully created
|
||||
await pWaitFor(
|
||||
async () => {
|
||||
const request = GetOpenTabsRequest.create({})
|
||||
const response = await getOpenTabs(request)
|
||||
return response.paths.length === 2
|
||||
},
|
||||
{
|
||||
timeout: 2000,
|
||||
interval: 50,
|
||||
},
|
||||
)
|
||||
|
||||
const request = GetOpenTabsRequest.create({})
|
||||
const response = await getOpenTabs(request)
|
||||
@@ -74,8 +85,18 @@ describe("Hostbridge - Window - getOpenTabs", () => {
|
||||
await createAndOpenTestDocument(2, vscode.ViewColumn.One)
|
||||
await createAndOpenTestDocument(3, vscode.ViewColumn.One)
|
||||
|
||||
// Wait a bit for tabs to be fully created
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
// Wait for tabs to be fully created
|
||||
await pWaitFor(
|
||||
async () => {
|
||||
const request = GetOpenTabsRequest.create({})
|
||||
const response = await getOpenTabs(request)
|
||||
return response.paths.length === 3
|
||||
},
|
||||
{
|
||||
timeout: 2000,
|
||||
interval: 50,
|
||||
},
|
||||
)
|
||||
|
||||
const request = GetOpenTabsRequest.create({})
|
||||
const response = await getOpenTabs(request)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, before, after, beforeEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import { saveOpenDocumentIfDirty } from "@/hosts/vscode/hostbridge/workspace/saveOpenDocumentIfDirty"
|
||||
import { SaveOpenDocumentIfDirtyRequest } from "@/shared/proto/index.host"
|
||||
|
||||
describe("saveOpenDocumentIfDirty Integration Test", () => {
|
||||
let testWorkspaceRoot: string
|
||||
let testFilePath: string
|
||||
let testFileUri: vscode.Uri
|
||||
|
||||
before(async () => {
|
||||
// Use a temporary directory for tests
|
||||
testWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cline-test-"))
|
||||
|
||||
// Create a test file path
|
||||
testFilePath = path.join(testWorkspaceRoot, "test-save-document.txt")
|
||||
testFileUri = vscode.Uri.file(testFilePath)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Clean up: close all editors and delete test directory
|
||||
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
|
||||
try {
|
||||
await fs.rm(testWorkspaceRoot, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
// Directory might not exist, ignore
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Close all editors before each test
|
||||
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
|
||||
})
|
||||
|
||||
it("should save a dirty document and return wasSaved: true", async () => {
|
||||
// Create a test file with initial content
|
||||
await fs.writeFile(testFilePath, "Initial content")
|
||||
|
||||
// Open the document in VSCode
|
||||
const document = await vscode.workspace.openTextDocument(testFileUri)
|
||||
const editor = await vscode.window.showTextDocument(document)
|
||||
|
||||
// Make the document dirty by editing it
|
||||
await editor.edit((editBuilder) => {
|
||||
editBuilder.insert(new vscode.Position(0, 0), "Modified ")
|
||||
})
|
||||
|
||||
// Verify the document is dirty
|
||||
expect(document.isDirty).to.be.true
|
||||
|
||||
// Call saveOpenDocumentIfDirty
|
||||
const request = SaveOpenDocumentIfDirtyRequest.create({
|
||||
filePath: testFilePath,
|
||||
})
|
||||
const response = await saveOpenDocumentIfDirty(request)
|
||||
|
||||
// Verify the response
|
||||
expect(response.wasSaved).to.be.true
|
||||
|
||||
// Verify the document is no longer dirty
|
||||
expect(document.isDirty).to.be.false
|
||||
|
||||
// Verify the file content was saved
|
||||
const savedContent = await fs.readFile(testFilePath, "utf-8")
|
||||
expect(savedContent).to.equal("Modified Initial content")
|
||||
})
|
||||
|
||||
it("should not save a clean document and return empty response", async () => {
|
||||
// Create a test file
|
||||
await fs.writeFile(testFilePath, "Clean content")
|
||||
|
||||
// Open the document in VSCode
|
||||
const document = await vscode.workspace.openTextDocument(testFileUri)
|
||||
await vscode.window.showTextDocument(document)
|
||||
|
||||
// Verify the document is not dirty
|
||||
expect(document.isDirty).to.be.false
|
||||
|
||||
// Call saveOpenDocumentIfDirty
|
||||
const request = SaveOpenDocumentIfDirtyRequest.create({
|
||||
filePath: testFilePath,
|
||||
})
|
||||
const response = await saveOpenDocumentIfDirty(request)
|
||||
|
||||
// Verify the response
|
||||
expect(response.wasSaved).to.be.undefined
|
||||
|
||||
// Verify the document is still not dirty
|
||||
expect(document.isDirty).to.be.false
|
||||
})
|
||||
|
||||
it("should return empty response when document is not open", async () => {
|
||||
// Ensure no documents are open
|
||||
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
|
||||
|
||||
// Call saveOpenDocumentIfDirty with a non-existent file
|
||||
const request = SaveOpenDocumentIfDirtyRequest.create({
|
||||
filePath: path.join(testWorkspaceRoot, "non-existent-file.txt"),
|
||||
})
|
||||
const response = await saveOpenDocumentIfDirty(request)
|
||||
|
||||
// Verify the response
|
||||
expect(response.wasSaved).to.be.undefined
|
||||
})
|
||||
|
||||
it("should handle multiple open documents and save only the specified one", async () => {
|
||||
// Create multiple test files
|
||||
const testFile1 = path.join(testWorkspaceRoot, "test-file-1.txt")
|
||||
const testFile2 = path.join(testWorkspaceRoot, "test-file-2.txt")
|
||||
const testFile3 = path.join(testWorkspaceRoot, "test-file-3.txt")
|
||||
|
||||
await fs.writeFile(testFile1, "File 1 content")
|
||||
await fs.writeFile(testFile2, "File 2 content")
|
||||
await fs.writeFile(testFile3, "File 3 content")
|
||||
|
||||
try {
|
||||
// Open all documents
|
||||
const doc1 = await vscode.workspace.openTextDocument(vscode.Uri.file(testFile1))
|
||||
const doc2 = await vscode.workspace.openTextDocument(vscode.Uri.file(testFile2))
|
||||
const doc3 = await vscode.workspace.openTextDocument(vscode.Uri.file(testFile3))
|
||||
|
||||
// Edit all documents to make them dirty
|
||||
const editor1 = await vscode.window.showTextDocument(doc1)
|
||||
await editor1.edit((editBuilder) => {
|
||||
editBuilder.insert(new vscode.Position(0, 0), "Modified ")
|
||||
})
|
||||
|
||||
const editor2 = await vscode.window.showTextDocument(doc2)
|
||||
await editor2.edit((editBuilder) => {
|
||||
editBuilder.insert(new vscode.Position(0, 0), "Modified ")
|
||||
})
|
||||
|
||||
const editor3 = await vscode.window.showTextDocument(doc3)
|
||||
await editor3.edit((editBuilder) => {
|
||||
editBuilder.insert(new vscode.Position(0, 0), "Modified ")
|
||||
})
|
||||
|
||||
// Verify all documents are dirty
|
||||
expect(doc1.isDirty).to.be.true
|
||||
expect(doc2.isDirty).to.be.true
|
||||
expect(doc3.isDirty).to.be.true
|
||||
|
||||
// Save only the second document
|
||||
const request = SaveOpenDocumentIfDirtyRequest.create({
|
||||
filePath: testFile2,
|
||||
})
|
||||
const response = await saveOpenDocumentIfDirty(request)
|
||||
|
||||
// Verify the response
|
||||
expect(response.wasSaved).to.be.true
|
||||
|
||||
// Verify only doc2 was saved
|
||||
expect(doc1.isDirty).to.be.true
|
||||
expect(doc2.isDirty).to.be.false
|
||||
expect(doc3.isDirty).to.be.true
|
||||
|
||||
// Verify the file content
|
||||
const savedContent = await fs.readFile(testFile2, "utf-8")
|
||||
expect(savedContent).to.equal("Modified File 2 content")
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.unlink(testFile1).catch(() => {})
|
||||
await fs.unlink(testFile2).catch(() => {})
|
||||
await fs.unlink(testFile3).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle empty file path gracefully", async () => {
|
||||
const request = SaveOpenDocumentIfDirtyRequest.create({
|
||||
filePath: "",
|
||||
})
|
||||
const response = await saveOpenDocumentIfDirty(request)
|
||||
|
||||
expect(response.wasSaved).to.be.undefined
|
||||
})
|
||||
|
||||
it("should handle undefined file path gracefully", async () => {
|
||||
const request = SaveOpenDocumentIfDirtyRequest.create({})
|
||||
const response = await saveOpenDocumentIfDirty(request)
|
||||
|
||||
expect(response.wasSaved).to.be.undefined
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,12 @@
|
||||
import { SaveOpenDocumentIfDirtyRequest } from "@/shared/proto/index.host"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { SaveOpenDocumentIfDirtyRequest, SaveOpenDocumentIfDirtyResponse } from "@/shared/proto/index.host"
|
||||
import * as vscode from "vscode"
|
||||
import { arePathsEqual } from "@utils/path"
|
||||
|
||||
export async function saveOpenDocumentIfDirty(request: SaveOpenDocumentIfDirtyRequest): Promise<Empty> {
|
||||
export async function saveOpenDocumentIfDirty(request: SaveOpenDocumentIfDirtyRequest): Promise<SaveOpenDocumentIfDirtyResponse> {
|
||||
const existingDocument = vscode.workspace.textDocuments.find((doc) => arePathsEqual(doc.uri.fsPath, request.filePath))
|
||||
|
||||
if (existingDocument && existingDocument.isDirty) {
|
||||
await existingDocument.save()
|
||||
return { wasSaved: true }
|
||||
}
|
||||
|
||||
return Empty.create({})
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import { globby } from "globby"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import simpleGit, { type SimpleGit } from "simple-git"
|
||||
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
|
||||
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
|
||||
interface CheckpointAddResult {
|
||||
success: boolean
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import simpleGit from "simple-git"
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { GitOperations } from "./CheckpointGitOperations"
|
||||
import { getShadowGitPath, getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as vscode from "vscode"
|
||||
import { openExternal } from "@utils/env"
|
||||
import { HostProvider } from "@hosts/host-provider"
|
||||
import { ShowMessageType } from "@shared/proto/host/window"
|
||||
|
||||
/**
|
||||
* Detects potential AI-generated code omissions in the given file content.
|
||||
@@ -41,13 +42,16 @@ function detectCodeOmission(originalFileContent: string, newFileContent: string)
|
||||
*/
|
||||
export function showOmissionWarning(originalFileContent: string, newFileContent: string): void {
|
||||
if (detectCodeOmission(originalFileContent, newFileContent)) {
|
||||
vscode.window
|
||||
.showWarningMessage(
|
||||
"Potential code truncation detected. This happens when the AI reaches its max output limit.",
|
||||
"Follow this guide to fix the issue",
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === "Follow this guide to fix the issue") {
|
||||
HostProvider.window
|
||||
.showMessage({
|
||||
type: ShowMessageType.WARNING,
|
||||
message: "Potential code truncation detected. This happens when the AI reaches its max output limit.",
|
||||
options: {
|
||||
items: ["Follow this guide to fix the issue"],
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.selectedOption === "Follow this guide to fix the issue") {
|
||||
openExternal(
|
||||
"https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Cline-Deleting-Code-with-%22Rest-of-Code-Here%22-Comments",
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import { TerminalProcess } from "./TerminalProcess"
|
||||
import * as vscode from "vscode"
|
||||
import { TerminalProcess } from "./TerminalProcess"
|
||||
import { TerminalRegistry } from "./TerminalRegistry"
|
||||
|
||||
declare module "vscode" {
|
||||
@@ -36,6 +37,7 @@ describe("TerminalProcess (Integration Tests)", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox({ useFakeTimers: true })
|
||||
setVscodeHostProviderMock()
|
||||
process = new TerminalProcess()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { stripAnsi } from "./ansiUtils"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { getLatestTerminalOutput } from "./get-latest-output"
|
||||
|
||||
export interface TerminalProcessEvents {
|
||||
@@ -28,9 +27,6 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
private gracePeriodTimer: NodeJS.Timeout | null = null
|
||||
private hasEmittedCompleted: boolean = false
|
||||
|
||||
// constructor() {
|
||||
// super()
|
||||
|
||||
private async emitCurrentTerminalContents(): Promise<void> {
|
||||
try {
|
||||
const terminalSnapshot = await getLatestTerminalOutput()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import vscode from "vscode"
|
||||
import { clineEnvConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { getRequestRegistry, type StreamingResponseHandler } from "@/core/controller/grpc-handler"
|
||||
import { storeSecret } from "@/core/storage/state"
|
||||
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
|
||||
import { featureFlagsService, telemetryService } from "@services/posthog/PostHogClientProvider"
|
||||
import { AuthState, UserInfo } from "@shared/proto/cline/account"
|
||||
import { type EmptyRequest, String } from "@shared/proto/cline/common"
|
||||
import { FirebaseAuthProvider } from "./providers/FirebaseAuthProvider"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { FEATURE_FLAGS } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
|
||||
const DefaultClineAccountURI = `${clineEnvConfig.appBaseUrl}/auth`
|
||||
let authProviders: any[] = []
|
||||
@@ -56,7 +56,7 @@ export class AuthService {
|
||||
protected _clineAuthInfo: ClineAuthInfo | null = null
|
||||
protected _provider: { provider: FirebaseAuthProvider } | null = null
|
||||
protected _activeAuthStatusUpdateSubscriptions = new Set<[Controller, StreamingResponseHandler<AuthState>]>()
|
||||
protected _context: vscode.ExtensionContext
|
||||
protected _controller: Controller
|
||||
|
||||
/**
|
||||
* Creates an instance of AuthService.
|
||||
@@ -64,7 +64,7 @@ export class AuthService {
|
||||
* @param authProvider - Optional authentication provider to use.
|
||||
* @param controller - Optional reference to the Controller instance.
|
||||
*/
|
||||
protected constructor(context: vscode.ExtensionContext, config: ServiceConfig, authProvider?: any) {
|
||||
protected constructor(controller: Controller, config: ServiceConfig, authProvider?: any) {
|
||||
const providerName = authProvider || "firebase"
|
||||
this._config = Object.assign({ URI: DefaultClineAccountURI }, config)
|
||||
|
||||
@@ -95,7 +95,7 @@ export class AuthService {
|
||||
|
||||
this._setProvider(authProviders.find((authProvider) => authProvider.name === providerName).name)
|
||||
|
||||
this._context = context
|
||||
this._controller = controller
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,29 +105,29 @@ export class AuthService {
|
||||
* @param controller - Optional reference to the Controller instance.
|
||||
* @returns The singleton instance of AuthService.
|
||||
*/
|
||||
public static getInstance(context?: vscode.ExtensionContext, config?: ServiceConfig, authProvider?: any): AuthService {
|
||||
public static getInstance(controller?: Controller, config?: ServiceConfig, authProvider?: any): AuthService {
|
||||
if (!AuthService.instance) {
|
||||
if (!context) {
|
||||
if (!controller) {
|
||||
console.warn("Extension context was not provided to AuthService.getInstance, using default context")
|
||||
context = {} as vscode.ExtensionContext
|
||||
controller = {} as Controller
|
||||
}
|
||||
if (process.env.E2E_TEST) {
|
||||
// Use require instead of import to avoid circular dependency issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { AuthServiceMock } = require("./AuthServiceMock")
|
||||
AuthService.instance = AuthServiceMock.getInstance(context, config || {}, authProvider)
|
||||
AuthService.instance = AuthServiceMock.getInstance(controller, config || {}, authProvider)
|
||||
} else {
|
||||
AuthService.instance = new AuthService(context, config || {}, authProvider)
|
||||
AuthService.instance = new AuthService(controller, config || {}, authProvider)
|
||||
}
|
||||
}
|
||||
if (context !== undefined && AuthService.instance) {
|
||||
AuthService.instance.context = context
|
||||
if (controller !== undefined && AuthService.instance) {
|
||||
AuthService.instance.controller = controller
|
||||
}
|
||||
return AuthService.instance!
|
||||
}
|
||||
|
||||
set context(context: vscode.ExtensionContext) {
|
||||
this._context = context
|
||||
set controller(controller: Controller) {
|
||||
this._controller = controller
|
||||
}
|
||||
|
||||
get authProvider(): any {
|
||||
@@ -195,7 +195,8 @@ export class AuthService {
|
||||
throw new Error("Authentication URI is not configured")
|
||||
}
|
||||
|
||||
const callbackUrl = `${vscode.env.uriScheme || "vscode"}://saoudrizwan.claude-dev/auth`
|
||||
const callbackHost = await HostProvider.get().getCallbackUri()
|
||||
const callbackUrl = `${callbackHost}/auth`
|
||||
|
||||
// Use URL object for more graceful query construction
|
||||
const authUrl = new URL(this._config.URI)
|
||||
@@ -228,15 +229,10 @@ export class AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
this._clineAuthInfo = await this._provider.provider.signIn(this._context, token, provider)
|
||||
this._clineAuthInfo = await this._provider.provider.signIn(this._controller, token, provider)
|
||||
this._authenticated = true
|
||||
|
||||
if (this._clineAuthInfo) {
|
||||
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
|
||||
}
|
||||
|
||||
await this.sendAuthStatusUpdate()
|
||||
// return this._clineAuthInfo
|
||||
} catch (error) {
|
||||
console.error("Error signing in with custom token:", error)
|
||||
throw error
|
||||
@@ -248,7 +244,7 @@ export class AuthService {
|
||||
* This is typically called when the user logs out.
|
||||
*/
|
||||
async clearAuthToken(): Promise<void> {
|
||||
await storeSecret(this._context, "clineAccountId", undefined)
|
||||
this._controller.cacheService.setSecret("clineAccountId", undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,10 +257,9 @@ export class AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
this._clineAuthInfo = await this._provider.provider.retrieveClineAuthInfo(this._context)
|
||||
this._clineAuthInfo = await this._provider.provider.retrieveClineAuthInfo(this._controller)
|
||||
if (this._clineAuthInfo) {
|
||||
this._authenticated = true
|
||||
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
|
||||
await this.sendAuthStatusUpdate()
|
||||
} else {
|
||||
console.warn("No user found after restoring auth token")
|
||||
@@ -329,6 +324,15 @@ export class AuthService {
|
||||
false, // Not the last message
|
||||
)
|
||||
|
||||
// Identify the user in telemetry if available
|
||||
// Fetch the feature flags for the user
|
||||
if (this._clineAuthInfo?.userInfo?.id) {
|
||||
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
|
||||
for (const flag of Object.values(FEATURE_FLAGS)) {
|
||||
await featureFlagsService?.isFeatureFlagEnabled(flag)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the state in the webview
|
||||
if (controller) {
|
||||
await controller.postStateToWebview()
|
||||
|
||||
@@ -4,10 +4,11 @@ import { clineEnvConfig } from "@/config"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import type { UserResponse } from "@/shared/ClineAccount"
|
||||
import { AuthService, type ServiceConfig } from "./AuthService"
|
||||
import { Controller } from "@/core/controller"
|
||||
|
||||
export class AuthServiceMock extends AuthService {
|
||||
protected constructor(context: vscode.ExtensionContext, config: ServiceConfig, authProvider?: any) {
|
||||
super(context, config, authProvider)
|
||||
protected constructor(controller: Controller, config: ServiceConfig, authProvider?: any) {
|
||||
super(controller, config, authProvider)
|
||||
|
||||
if (process?.env?.CLINE_ENVIRONMENT !== "local") {
|
||||
throw new Error("AuthServiceMock should only be used in local environment for testing purposes.")
|
||||
@@ -18,26 +19,22 @@ export class AuthServiceMock extends AuthService {
|
||||
const providerName = "firebase"
|
||||
this._setProvider(providerName)
|
||||
|
||||
this._context = context
|
||||
this._controller = controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance of AuthServiceMock.
|
||||
*/
|
||||
public static override getInstance(
|
||||
context?: vscode.ExtensionContext,
|
||||
config?: ServiceConfig,
|
||||
authProvider?: any,
|
||||
): AuthServiceMock {
|
||||
public static override getInstance(controller?: Controller, config?: ServiceConfig, authProvider?: any): AuthServiceMock {
|
||||
if (!AuthServiceMock.instance) {
|
||||
if (!context) {
|
||||
console.warn("Extension context was not provided to AuthServiceMock.getInstance, using default context")
|
||||
context = {} as vscode.ExtensionContext
|
||||
if (!controller) {
|
||||
console.error("Extension controller was not provided to AuthServiceMock.getInstance")
|
||||
throw new Error("Extension controller was not provided to AuthServiceMock.getInstance")
|
||||
}
|
||||
AuthServiceMock.instance = new AuthServiceMock(context, config || {}, authProvider)
|
||||
AuthServiceMock.instance = new AuthServiceMock(controller, config || {}, authProvider)
|
||||
}
|
||||
if (context !== undefined) {
|
||||
AuthServiceMock.instance.context = context
|
||||
if (controller !== undefined) {
|
||||
AuthServiceMock.instance.controller = controller
|
||||
}
|
||||
return AuthServiceMock.instance
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getSecret, storeSecret } from "@/core/storage/state"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { errorService } from "@services/posthog/PostHogClientProvider"
|
||||
import axios from "axios"
|
||||
import { initializeApp } from "firebase/app"
|
||||
import { GithubAuthProvider, GoogleAuthProvider, User, getAuth, signInWithCredential } from "firebase/auth"
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { ClineAccountUserInfo, ClineAuthInfo } from "../AuthService"
|
||||
import { GithubAuthProvider, GoogleAuthProvider, getAuth, type OAuthCredential, signInWithCredential, User } from "firebase/auth"
|
||||
import { jwtDecode } from "jwt-decode"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { clineEnvConfig } from "@/config"
|
||||
import type { ClineAccountUserInfo, ClineAuthInfo } from "../AuthService"
|
||||
import { Controller } from "@/core/controller"
|
||||
|
||||
export class FirebaseAuthProvider {
|
||||
private _config: any
|
||||
@@ -41,8 +41,8 @@ export class FirebaseAuthProvider {
|
||||
* @returns {Promise<User>} A promise that resolves with the authenticated user.
|
||||
* @throws {Error} Throws an error if the restoration fails.
|
||||
*/
|
||||
async retrieveClineAuthInfo(context: ExtensionContext): Promise<ClineAuthInfo | null> {
|
||||
const userRefreshToken = await getSecret(context, "clineAccountId")
|
||||
async retrieveClineAuthInfo(controller: Controller): Promise<ClineAuthInfo | null> {
|
||||
const userRefreshToken = controller.cacheService.getSecretKey("clineAccountId")
|
||||
if (!userRefreshToken) {
|
||||
console.error("No stored authentication credential found.")
|
||||
return null
|
||||
@@ -89,8 +89,8 @@ export class FirebaseAuthProvider {
|
||||
// return userCredential.user
|
||||
} catch (error) {
|
||||
console.error("Firebase restore token error", error)
|
||||
ErrorService.logMessage("Firebase restore token error", "error")
|
||||
ErrorService.logException(error)
|
||||
errorService.logMessage("Firebase restore token error", "error")
|
||||
errorService.logException(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -100,9 +100,9 @@ export class FirebaseAuthProvider {
|
||||
* @returns {Promise<User>} A promise that resolves with the authenticated user.
|
||||
* @throws {Error} Throws an error if the sign-in fails.
|
||||
*/
|
||||
async signIn(context: ExtensionContext, token: string, provider: string): Promise<ClineAuthInfo | null> {
|
||||
async signIn(controller: Controller, token: string, provider: string): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
let credential
|
||||
let credential: OAuthCredential
|
||||
switch (provider) {
|
||||
case "google":
|
||||
credential = GoogleAuthProvider.credential(token)
|
||||
@@ -123,18 +123,18 @@ export class FirebaseAuthProvider {
|
||||
|
||||
// store the long-lived refresh token in secret storage
|
||||
try {
|
||||
await storeSecret(context, "clineAccountId", userCredential.refreshToken)
|
||||
controller.cacheService.setSecret("clineAccountId", userCredential.refreshToken)
|
||||
} catch (error) {
|
||||
ErrorService.logMessage("Firebase store token error", "error")
|
||||
ErrorService.logException(error)
|
||||
errorService.logMessage("Firebase store token error", "error")
|
||||
errorService.logException(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
// userCredential = await this._signInWithCredential(context, credential)
|
||||
return await this.retrieveClineAuthInfo(context)
|
||||
return await this.retrieveClineAuthInfo(controller)
|
||||
} catch (error) {
|
||||
ErrorService.logMessage("Firebase sign-in error", "error")
|
||||
ErrorService.logException(error)
|
||||
errorService.logMessage("Firebase sign-in error", "error")
|
||||
errorService.logException(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { BrowserSettings } from "@shared/BrowserSettings"
|
||||
import { discoverChromeInstances, testBrowserConnection, isPortOpen } from "./BrowserDiscovery"
|
||||
import * as chromeLauncher from "chrome-launcher"
|
||||
import { Controller } from "@core/controller"
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
|
||||
import os from "os"
|
||||
|
||||
interface PCRStats {
|
||||
@@ -88,7 +88,10 @@ export class BrowserSession {
|
||||
// First check browserSettings (from UI, stored in global state)
|
||||
await this.migrateChromeExecutablePathSetting()
|
||||
if (this.browserSettings.chromeExecutablePath && (await fileExistsAtPath(this.browserSettings.chromeExecutablePath))) {
|
||||
return { path: this.browserSettings.chromeExecutablePath, isBundled: false }
|
||||
return {
|
||||
path: this.browserSettings.chromeExecutablePath,
|
||||
isBundled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Then try to find system Chrome
|
||||
@@ -554,8 +557,8 @@ export class BrowserSession {
|
||||
const minStableSizeIterations = 3
|
||||
|
||||
while (checkCounts++ <= maxChecks) {
|
||||
let html = await page.content()
|
||||
let currentHTMLSize = html.length
|
||||
const html = await page.content()
|
||||
const currentHTMLSize = html.length
|
||||
|
||||
// let bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length)
|
||||
console.info("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize)
|
||||
|
||||
@@ -1,103 +1,55 @@
|
||||
import * as Sentry from "@sentry/browser"
|
||||
import * as vscode from "vscode"
|
||||
import { telemetryService } from "../posthog/telemetry/TelemetryService"
|
||||
import * as pkg from "../../../package.json"
|
||||
import type { PostHogClientProvider } from "../posthog/PostHogClientProvider"
|
||||
import { ClineError } from "./ClineError"
|
||||
|
||||
let telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
|
||||
let isTelemetryEnabled = ["all", "error"].includes(telemetryLevel)
|
||||
|
||||
vscode.workspace.onDidChangeConfiguration(() => {
|
||||
telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
|
||||
isTelemetryEnabled = ["all", "error"].includes(telemetryLevel)
|
||||
ErrorService.toggleEnabled(isTelemetryEnabled)
|
||||
if (isTelemetryEnabled) {
|
||||
ErrorService.setLevel(telemetryLevel as "error" | "all")
|
||||
}
|
||||
})
|
||||
|
||||
const isDev = process.env.IS_DEV === "true"
|
||||
|
||||
export class ErrorService {
|
||||
private static serviceEnabled: boolean
|
||||
private static serviceLevel: string
|
||||
private posthogProvider: PostHogClientProvider
|
||||
|
||||
static initialize() {
|
||||
// Initialize sentry
|
||||
Sentry.init({
|
||||
dsn: "https://7936780e3f0f0290fcf8d4a395c249b7@o4509028819664896.ingest.us.sentry.io/4509052955983872",
|
||||
environment: process.env.NODE_ENV,
|
||||
release: `cline@${pkg.version}`,
|
||||
integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
|
||||
beforeSend(event) {
|
||||
// TelemetryService keeps track of whether the user has opted in to telemetry/error reporting
|
||||
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
|
||||
if (isUserManuallyOptedIn && ErrorService.isEnabled() && !isDev) {
|
||||
return event
|
||||
}
|
||||
return null
|
||||
},
|
||||
constructor(posthogProvider: PostHogClientProvider, _distinctId: string) {
|
||||
this.posthogProvider = posthogProvider
|
||||
}
|
||||
|
||||
public logException(error: Error | ClineError): void {
|
||||
const errorDetails = {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
extension_version: pkg.version,
|
||||
is_dev: isDev,
|
||||
}
|
||||
|
||||
if (error instanceof ClineError) {
|
||||
Object.assign(errorDetails, {
|
||||
modelId: error.modelId,
|
||||
providerId: error.providerId,
|
||||
serialized_error: error.serialize(),
|
||||
})
|
||||
}
|
||||
|
||||
this.posthogProvider.log("extension.error", {
|
||||
error_type: "exception",
|
||||
...errorDetails,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
ErrorService.toggleEnabled(true)
|
||||
ErrorService.setLevel("error")
|
||||
console.error("[ErrorService] Logging", error)
|
||||
}
|
||||
|
||||
static toggleEnabled(state: boolean) {
|
||||
if (state === false) {
|
||||
ErrorService.serviceEnabled = false
|
||||
return
|
||||
}
|
||||
// If we are trying to enable the service, check that we are allowed to.
|
||||
if (isTelemetryEnabled) {
|
||||
ErrorService.serviceEnabled = true
|
||||
}
|
||||
public logMessage(message: string, level: "error" | "warning" | "log" | "debug" | "info" = "log"): void {
|
||||
this.posthogProvider.log("extension.message", {
|
||||
message: message.substring(0, 500),
|
||||
level,
|
||||
extension_version: pkg.version,
|
||||
is_dev: isDev,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
static setLevel(level: "error" | "all") {
|
||||
switch (telemetryLevel) {
|
||||
case "error": {
|
||||
if (level === "error") {
|
||||
ErrorService.serviceLevel = level
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
ErrorService.serviceLevel = level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static logException(error: Error | ClineError): void {
|
||||
// Don't log if telemetry is off
|
||||
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
|
||||
if (!isUserManuallyOptedIn || !ErrorService.isEnabled()) {
|
||||
return
|
||||
}
|
||||
// Log the error to Sentry
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
|
||||
static logMessage(message: string, level: "error" | "warning" | "log" | "debug" | "info" = "log"): void {
|
||||
// Don't log if telemetry is off
|
||||
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
|
||||
if (!isUserManuallyOptedIn || !ErrorService.isEnabled()) {
|
||||
return
|
||||
}
|
||||
if (ErrorService.serviceLevel === "error" && level === "error") {
|
||||
// Log the message if allowed
|
||||
Sentry.captureMessage(message, { level })
|
||||
return
|
||||
}
|
||||
// Log the message if allowed
|
||||
Sentry.captureMessage(message, { level })
|
||||
}
|
||||
|
||||
static isEnabled(): boolean {
|
||||
return ErrorService.serviceEnabled
|
||||
}
|
||||
|
||||
static toClineError(rawError: any, modelId?: string, providerId?: string): ClineError {
|
||||
return ClineError.transform(rawError, modelId, providerId)
|
||||
public toClineError(rawError: unknown, modelId?: string, providerId?: string): ClineError {
|
||||
const transformed = ClineError.transform(rawError, modelId, providerId)
|
||||
this.logException(transformed)
|
||||
return transformed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ErrorService } from "../error/ErrorService"
|
||||
import { errorService } from "../posthog/PostHogClientProvider"
|
||||
|
||||
/**
|
||||
* Simple logging utility for the extension's backend code.
|
||||
*/
|
||||
export class Logger {
|
||||
public readonly channelName = "Cline Dev Logger"
|
||||
static error(message: string, error?: Error) {
|
||||
Logger.#output("ERROR", message, error)
|
||||
ErrorService.logMessage(message, "error")
|
||||
error && ErrorService.logException(error)
|
||||
errorService.logMessage(message, "error")
|
||||
error && errorService.logException(error)
|
||||
}
|
||||
static warn(message: string) {
|
||||
Logger.#output("WARN", message)
|
||||
ErrorService.logMessage(message, "warning")
|
||||
errorService.logMessage(message, "warning")
|
||||
}
|
||||
static log(message: string) {
|
||||
Logger.#output("LOG", message)
|
||||
|
||||
+17
-34
@@ -1,8 +1,11 @@
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { sendMcpServersUpdate } from "@core/controller/mcp/subscribeToMcpServers"
|
||||
import { GlobalFileNames } from "@core/storage/disk"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import ReconnectingEventSource from "reconnecting-eventsource"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
@@ -10,16 +13,7 @@ import {
|
||||
ListToolsResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { sendMcpServersUpdate } from "@core/controller/mcp/subscribeToMcpServers"
|
||||
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { FileChangeEvent_ChangeType, SubscribeToFileRequest } from "../../shared/proto/host/watch"
|
||||
import { ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
import {
|
||||
DEFAULT_MCP_TIMEOUT_SECONDS,
|
||||
McpResource,
|
||||
@@ -30,19 +24,24 @@ import {
|
||||
McpToolCallResponse,
|
||||
MIN_MCP_TIMEOUT_SECONDS,
|
||||
} from "@shared/mcp"
|
||||
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { secondsToMs } from "@utils/time"
|
||||
import { GlobalFileNames } from "@core/storage/disk"
|
||||
import { ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import * as path from "path"
|
||||
import ReconnectingEventSource from "reconnecting-eventsource"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { FileChangeEvent_ChangeType, SubscribeToFileRequest } from "../../shared/proto/host/watch"
|
||||
import { DEFAULT_REQUEST_TIMEOUT_MS } from "./constants"
|
||||
import { BaseConfigSchema, McpSettingsSchema, ServerConfigSchema } from "./schemas"
|
||||
import { McpConnection, McpServerConfig, Transport } from "./types"
|
||||
import { BaseConfigSchema, ServerConfigSchema, McpSettingsSchema } from "./schemas"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
export class McpHub {
|
||||
getMcpServersPath: () => Promise<string>
|
||||
private getSettingsDirectoryPath: () => Promise<string>
|
||||
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
|
||||
private clientVersion: string
|
||||
|
||||
private disposables: vscode.Disposable[] = []
|
||||
@@ -65,12 +64,10 @@ export class McpHub {
|
||||
constructor(
|
||||
getMcpServersPath: () => Promise<string>,
|
||||
getSettingsDirectoryPath: () => Promise<string>,
|
||||
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
|
||||
clientVersion: string,
|
||||
) {
|
||||
this.getMcpServersPath = getMcpServersPath
|
||||
this.getSettingsDirectoryPath = getSettingsDirectoryPath
|
||||
this.postMessageToWebview = postMessageToWebview
|
||||
this.clientVersion = clientVersion
|
||||
this.watchMcpSettingsFile()
|
||||
this.initializeMcpServers()
|
||||
@@ -398,20 +395,6 @@ export class McpHub {
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Forward to webview if available
|
||||
if (this.postMessageToWebview) {
|
||||
await this.postMessageToWebview({
|
||||
type: "mcpNotification",
|
||||
serverName: name,
|
||||
notification: {
|
||||
level,
|
||||
data,
|
||||
logger,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
} as any)
|
||||
}
|
||||
})
|
||||
console.log(`[MCP Debug] Successfully set notifications/message handler for ${name}`)
|
||||
|
||||
|
||||
@@ -1,31 +1,149 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import * as vscode from "vscode"
|
||||
import { posthogConfig } from "../../shared/services/config/posthog-config"
|
||||
import type { ClineAccountUserInfo } from "../auth/AuthService"
|
||||
import { ErrorService } from "../error/ErrorService"
|
||||
import { FeatureFlagsService } from "./feature-flags/FeatureFlagsService"
|
||||
import { TelemetryService } from "./telemetry/TelemetryService"
|
||||
|
||||
class PostHogClientProvider {
|
||||
private static instance: PostHogClientProvider
|
||||
private client: PostHog
|
||||
const ENV_ID = vscode?.env?.machineId ?? process?.env?.UUID ?? uuidv4()
|
||||
|
||||
private constructor() {
|
||||
interface TelemetrySettings {
|
||||
cline: boolean
|
||||
host: boolean
|
||||
level?: "all" | "off" | "error" | "crash"
|
||||
}
|
||||
|
||||
export class PostHogClientProvider {
|
||||
private static _instance: PostHogClientProvider | null = null
|
||||
|
||||
public static getInstance(id?: string): PostHogClientProvider {
|
||||
if (!PostHogClientProvider._instance) {
|
||||
PostHogClientProvider._instance = new PostHogClientProvider(id)
|
||||
}
|
||||
return PostHogClientProvider._instance
|
||||
}
|
||||
|
||||
protected telemetrySettings: TelemetrySettings = {
|
||||
cline: true,
|
||||
host: true,
|
||||
level: "all",
|
||||
}
|
||||
|
||||
public readonly client: PostHog
|
||||
|
||||
public readonly featureFlags: FeatureFlagsService
|
||||
public readonly telemetry: TelemetryService
|
||||
public readonly error: ErrorService
|
||||
|
||||
private constructor(public distinctId = ENV_ID) {
|
||||
// Initialize PostHog client
|
||||
this.client = new PostHog(posthogConfig.apiKey, {
|
||||
host: posthogConfig.host,
|
||||
enableExceptionAutocapture: false,
|
||||
enableExceptionAutocapture: true,
|
||||
})
|
||||
|
||||
vscode.env.onDidChangeTelemetryEnabled((isTelemetryEnabled) => {
|
||||
this.telemetrySettings.host = isTelemetryEnabled
|
||||
})
|
||||
|
||||
if (vscode?.env?.isTelemetryEnabled === false) {
|
||||
this.telemetrySettings.host = false
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
if (config.get("telemetrySetting") === "disabled") {
|
||||
this.telemetrySettings.cline = false
|
||||
}
|
||||
|
||||
this.telemetrySettings.level = this.telemetryLevel
|
||||
|
||||
// Initialize services
|
||||
this.telemetry = new TelemetryService(this)
|
||||
this.error = new ErrorService(this, this.distinctId)
|
||||
this.featureFlags = new FeatureFlagsService(
|
||||
(flag: string) => this.client.getFeatureFlag(flag, this.distinctId),
|
||||
(flag: string) => this.client.getFeatureFlagPayload(flag, this.distinctId),
|
||||
)
|
||||
}
|
||||
|
||||
private get isTelemetryEnabled(): boolean {
|
||||
return this.telemetrySettings.cline && this.telemetrySettings.host
|
||||
}
|
||||
|
||||
/** Whether telemetry is currently enabled based on user and VSCode settings */
|
||||
private get telemetryLevel(): TelemetrySettings["level"] {
|
||||
if (!vscode?.env?.isTelemetryEnabled) {
|
||||
return "off"
|
||||
}
|
||||
const config = vscode.workspace.getConfiguration("telemetry")
|
||||
return config?.get<TelemetrySettings["level"]>("telemetryLevel") || "all"
|
||||
}
|
||||
|
||||
public toggleOptIn(optIn: boolean): void {
|
||||
if (optIn && !this.telemetrySettings.cline) {
|
||||
this.client.optIn()
|
||||
}
|
||||
if (!optIn && this.telemetrySettings.cline) {
|
||||
this.client.optOut()
|
||||
}
|
||||
this.telemetrySettings.cline = optIn
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the accounts user
|
||||
* If userInfo is provided, it will use that to identify the user.
|
||||
* Otherwise, it will use the DISTINCT_ID as the distinct ID.
|
||||
* @param userInfo The user's information
|
||||
*/
|
||||
public identifyAccount(userInfo?: ClineAccountUserInfo, properties: Record<string, unknown> = {}): void {
|
||||
if (!this.isTelemetryEnabled) {
|
||||
return
|
||||
}
|
||||
if (userInfo && userInfo?.id !== this.distinctId) {
|
||||
this.client.identify({
|
||||
distinctId: userInfo.id,
|
||||
properties: {
|
||||
uuid: userInfo.id,
|
||||
email: userInfo.email,
|
||||
name: userInfo.displayName,
|
||||
...properties,
|
||||
alias: this.distinctId,
|
||||
},
|
||||
})
|
||||
this.distinctId = userInfo.id
|
||||
}
|
||||
}
|
||||
|
||||
public log(event: string, properties?: Record<string, unknown>): void {
|
||||
if (!this.isTelemetryEnabled || this.telemetryLevel === "off") {
|
||||
return
|
||||
}
|
||||
// Filter events based on telemetry level
|
||||
if (this.telemetryLevel === "error") {
|
||||
if (!event.includes("error")) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.client.capture({
|
||||
distinctId: this.distinctId,
|
||||
event,
|
||||
properties,
|
||||
})
|
||||
}
|
||||
|
||||
public static getInstance(): PostHogClientProvider {
|
||||
if (!PostHogClientProvider.instance) {
|
||||
PostHogClientProvider.instance = new PostHogClientProvider()
|
||||
}
|
||||
return PostHogClientProvider.instance
|
||||
}
|
||||
|
||||
public getClient(): PostHog {
|
||||
return this.client
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
await this.client.shutdown()
|
||||
public dispose(): void {
|
||||
this.client.shutdown().catch((error) => console.error("Error shutting down PostHog client:", error))
|
||||
}
|
||||
}
|
||||
|
||||
export const posthogClientProvider = PostHogClientProvider.getInstance()
|
||||
const getFeatureFlagsService = (): FeatureFlagsService => PostHogClientProvider.getInstance().featureFlags
|
||||
const getErrorService = (): ErrorService => PostHogClientProvider.getInstance().error
|
||||
const getTelemetryService = (): TelemetryService => PostHogClientProvider.getInstance().telemetry
|
||||
|
||||
// Service accessors
|
||||
export const featureFlagsService = getFeatureFlagsService()
|
||||
export const errorService = getErrorService()
|
||||
export const telemetryService = getTelemetryService()
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
import { posthogClientProvider } from "../PostHogClientProvider"
|
||||
|
||||
class FeatureFlagsService {
|
||||
private static instance: FeatureFlagsService
|
||||
private readonly client: PostHog
|
||||
|
||||
private constructor() {
|
||||
// Get the shared client
|
||||
this.client = posthogClientProvider.getClient()
|
||||
}
|
||||
|
||||
public static getInstance(): FeatureFlagsService {
|
||||
if (!FeatureFlagsService.instance) {
|
||||
FeatureFlagsService.instance = new FeatureFlagsService()
|
||||
}
|
||||
return FeatureFlagsService.instance
|
||||
/**
|
||||
* FeatureFlagsService provides feature flag functionality that works independently
|
||||
* of telemetry settings. Feature flags are always available to ensure proper
|
||||
* functionality of the extension regardless of user's telemetry preferences.
|
||||
*/
|
||||
export class FeatureFlagsService {
|
||||
public constructor(
|
||||
private readonly getFeatureFlag: (flag: string) => Promise<boolean | string | undefined>,
|
||||
private readonly getFeatureFlagPayload: (flag: string) => Promise<unknown>,
|
||||
) {
|
||||
console.log("[FeatureFlagsService] Initialized with distinctId:")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled
|
||||
* This method works regardless of telemetry settings to ensure feature flags
|
||||
* can control extension behavior independently of user privacy preferences.
|
||||
*
|
||||
* @param flagName The feature flag key
|
||||
* @returns Boolean indicating if the feature is enabled
|
||||
*/
|
||||
public async isFeatureFlagEnabled(flagName: string): Promise<boolean> {
|
||||
try {
|
||||
const payload = await this.client.getFeatureFlagPayload(flagName, "_irrelevant_" /* optional params */)
|
||||
if (payload && typeof payload === "object" && "enabled" in payload) {
|
||||
return Boolean(payload.enabled)
|
||||
}
|
||||
console.warn(`Feature flag ${flagName} not found or missing enabled property.`)
|
||||
return false
|
||||
const flagEnabled = await this.getFeatureFlag(flagName)
|
||||
return flagEnabled === true
|
||||
} catch (error) {
|
||||
console.error(`Error checking if feature flag ${flagName} is enabled:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const featureFlagsService = FeatureFlagsService.getInstance()
|
||||
/**
|
||||
* Get the feature flag payload for advanced use cases
|
||||
* @param flagName The feature flag key
|
||||
* @returns The feature flag payload or null if not found
|
||||
*/
|
||||
public async getPayload(flagName: string): Promise<unknown> {
|
||||
try {
|
||||
return await this.getFeatureFlagPayload(flagName)
|
||||
} catch (error) {
|
||||
console.error(`Error retrieving feature flag payload for ${flagName}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
import * as vscode from "vscode"
|
||||
import { version as extensionVersion } from "../../../../package.json"
|
||||
import { HostProvider } from "@hosts/host-provider"
|
||||
import { ShowMessageType } from "@shared/proto/host/window"
|
||||
|
||||
import type { TaskFeedbackType } from "@shared/WebviewMessage"
|
||||
import type { BrowserSettings } from "@shared/BrowserSettings"
|
||||
import { posthogClientProvider } from "../PostHogClientProvider"
|
||||
import type { PostHogClientProvider } from "../PostHogClientProvider"
|
||||
import { Mode } from "@/shared/storage/types"
|
||||
import { ClineAccountUserInfo } from "@/services/auth/AuthService"
|
||||
|
||||
@@ -26,7 +27,7 @@ type TelemetryCategory = "checkpoints" | "browser"
|
||||
*/
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 500
|
||||
|
||||
class TelemetryService {
|
||||
export class TelemetryService {
|
||||
// Map to control specific telemetry categories (event types)
|
||||
private telemetryCategoryEnabled: Map<TelemetryCategory, boolean> = new Map([
|
||||
["checkpoints", false], // Checkpoints telemetry disabled
|
||||
@@ -39,6 +40,7 @@ class TelemetryService {
|
||||
|
||||
USER: {
|
||||
OPT_OUT: "user.opt_out",
|
||||
TELEMETRY_ENABLED: "user.telemetry_enabled",
|
||||
EXTENSION_ACTIVATED: "user.extension_activated",
|
||||
},
|
||||
TASK: {
|
||||
@@ -92,31 +94,18 @@ class TelemetryService {
|
||||
},
|
||||
}
|
||||
|
||||
/** Singleton instance of the TelemetryService */
|
||||
private static instance: TelemetryService
|
||||
/** PostHog client instance for sending analytics events */
|
||||
private client: PostHog
|
||||
/** Unique identifier for the current VSCode instance */
|
||||
public distinctId: string = vscode.env.machineId
|
||||
/** Whether telemetry is currently enabled based on user and VSCode settings */
|
||||
private telemetryEnabled: boolean = false
|
||||
/** Current version of the extension */
|
||||
private readonly version: string = extensionVersion
|
||||
/** Whether the extension is running in development mode */
|
||||
private readonly isDev = process.env.IS_DEV
|
||||
|
||||
/**
|
||||
* Private constructor to enforce singleton pattern
|
||||
* Initializes PostHog client with configuration
|
||||
* Constructor that accepts a PostHogClientProvider instance
|
||||
* @param provider PostHogClientProvider instance for sending analytics events
|
||||
*/
|
||||
private constructor() {
|
||||
this.client = posthogClientProvider.getClient()
|
||||
}
|
||||
|
||||
private setDistinctId(installId: string) {
|
||||
if (this.distinctId === "someValue.machineId") {
|
||||
this.distinctId = installId
|
||||
}
|
||||
public constructor(private provider: PostHogClientProvider) {
|
||||
this.capture({ event: TelemetryService.EVENTS.USER.TELEMETRY_ENABLED })
|
||||
console.info("[TelemetryService] Initialized with PostHogClientProvider")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,54 +115,29 @@ class TelemetryService {
|
||||
*/
|
||||
public async updateTelemetryState(didUserOptIn: boolean): Promise<void> {
|
||||
// First check global telemetry level - telemetry should only be enabled when level is "all"
|
||||
const telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
|
||||
const globalTelemetryEnabled = telemetryLevel === "all"
|
||||
|
||||
// We only enable telemetry if global vscode telemetry is enabled
|
||||
if (globalTelemetryEnabled) {
|
||||
this.telemetryEnabled = didUserOptIn
|
||||
} else {
|
||||
if (!vscode.env.isTelemetryEnabled) {
|
||||
// Only show warning if user has opted in to Cline telemetry but VS Code telemetry is disabled
|
||||
if (didUserOptIn) {
|
||||
void vscode.window
|
||||
.showWarningMessage(
|
||||
"Anonymous Cline error and usage reporting is enabled, but VSCode telemetry is disabled. To enable error and usage reporting for this extension, enable VSCode telemetry in settings.",
|
||||
"Open Settings",
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === "Open Settings") {
|
||||
void HostProvider.window
|
||||
.showMessage({
|
||||
type: ShowMessageType.WARNING,
|
||||
message:
|
||||
"Anonymous Cline error and usage reporting is enabled, but VSCode telemetry is disabled. To enable error and usage reporting for this extension, enable VSCode telemetry in settings.",
|
||||
options: {
|
||||
items: ["Open Settings"],
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.selectedOption === "Open Settings") {
|
||||
void vscode.commands.executeCommand("workbench.action.openSettings", "telemetry.telemetryLevel")
|
||||
}
|
||||
})
|
||||
}
|
||||
this.telemetryEnabled = false
|
||||
}
|
||||
|
||||
// Update PostHog client state based on telemetry preference
|
||||
if (this.telemetryEnabled) {
|
||||
this.client.optIn()
|
||||
this.client.identify({ distinctId: this.distinctId })
|
||||
} else {
|
||||
this.client.capture({
|
||||
distinctId: this.distinctId,
|
||||
event: TelemetryService.EVENTS.USER.OPT_OUT,
|
||||
properties: this.addProperties({}),
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)) // Delay 1 second before opting out
|
||||
this.client.optOut()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates the singleton instance of TelemetryService
|
||||
* @returns The TelemetryService instance
|
||||
*/
|
||||
public static getInstance(): TelemetryService {
|
||||
if (!TelemetryService.instance) {
|
||||
TelemetryService.instance = new TelemetryService()
|
||||
}
|
||||
return TelemetryService.instance
|
||||
this.provider.toggleOptIn(didUserOptIn)
|
||||
}
|
||||
|
||||
private addProperties(properties: any): any {
|
||||
@@ -188,28 +152,16 @@ class TelemetryService {
|
||||
* Captures a telemetry event if telemetry is enabled
|
||||
* @param event The event to capture with its properties
|
||||
*/
|
||||
public capture(event: { event: string; properties?: any }): void {
|
||||
if (!this.telemetryEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
public capture(event: { event: string; properties?: unknown }): void {
|
||||
const propertiesWithVersion = this.addProperties(event.properties)
|
||||
|
||||
const capturedEvent = {
|
||||
event: event.event,
|
||||
properties: propertiesWithVersion,
|
||||
}
|
||||
|
||||
this.client.capture({ ...capturedEvent, distinctId: this.distinctId })
|
||||
// Use the provider's log method instead of direct client capture
|
||||
this.provider.log(event.event, propertiesWithVersion)
|
||||
}
|
||||
|
||||
public captureExtensionActivated(installId: string) {
|
||||
this.setDistinctId(installId)
|
||||
|
||||
if (this.telemetryEnabled) {
|
||||
this.client.identify({ distinctId: this.distinctId })
|
||||
this.client.capture({ distinctId: this.distinctId, event: TelemetryService.EVENTS.USER.EXTENSION_ACTIVATED })
|
||||
}
|
||||
public captureExtensionActivated() {
|
||||
// Use provider's log method for the activation event
|
||||
this.provider.log(TelemetryService.EVENTS.USER.EXTENSION_ACTIVATED)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,24 +169,10 @@ class TelemetryService {
|
||||
* @param userInfo The user's information
|
||||
*/
|
||||
public identifyAccount(userInfo: ClineAccountUserInfo) {
|
||||
if (!this.telemetryEnabled) {
|
||||
return
|
||||
}
|
||||
const propertiesWithVersion = this.addProperties({})
|
||||
|
||||
if (!this.client) {
|
||||
console.warn("Telemetry client is not initialized. Skipping identifyAccount.")
|
||||
return
|
||||
}
|
||||
|
||||
this.client.identify({
|
||||
distinctId: userInfo.id,
|
||||
properties: {
|
||||
uuid: userInfo.id,
|
||||
email: userInfo.email,
|
||||
name: userInfo.displayName,
|
||||
...this.addProperties({}),
|
||||
},
|
||||
})
|
||||
// Use the provider's log method instead of direct client capture
|
||||
this.provider.identifyAccount(userInfo, propertiesWithVersion)
|
||||
}
|
||||
|
||||
// Task events
|
||||
@@ -300,7 +238,7 @@ class TelemetryService {
|
||||
return
|
||||
}
|
||||
|
||||
const properties: Record<string, any> = {
|
||||
const properties: Record<string, unknown> = {
|
||||
taskId,
|
||||
provider,
|
||||
model,
|
||||
@@ -355,7 +293,10 @@ class TelemetryService {
|
||||
* @param feedbackType The type of feedback ("thumbs_up" or "thumbs_down")
|
||||
*/
|
||||
public captureTaskFeedback(taskId: string, feedbackType: TaskFeedbackType) {
|
||||
console.info("TelemetryService: Capturing task feedback", { taskId, feedbackType })
|
||||
console.info("TelemetryService: Capturing task feedback", {
|
||||
taskId,
|
||||
feedbackType,
|
||||
})
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.FEEDBACK,
|
||||
properties: {
|
||||
@@ -536,7 +477,7 @@ class TelemetryService {
|
||||
action?: string
|
||||
url?: string
|
||||
isRemote?: boolean
|
||||
[key: string]: any
|
||||
[key: string]: unknown
|
||||
},
|
||||
) {
|
||||
if (!this.isCategoryEnabled("browser")) {
|
||||
@@ -672,14 +613,6 @@ class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if telemetry is enabled
|
||||
* @returns Boolean indicating whether telemetry is enabled
|
||||
*/
|
||||
public isTelemetryEnabled(): boolean {
|
||||
return this.telemetryEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific telemetry category is enabled
|
||||
* @param category The telemetry category to check
|
||||
@@ -689,10 +622,4 @@ class TelemetryService {
|
||||
// Default to true if category has not been explicitly configured
|
||||
return this.telemetryCategoryEnabled.get(category) ?? true
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
await this.client.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
export const telemetryService = TelemetryService.getInstance()
|
||||
|
||||
@@ -5,21 +5,15 @@ import { execa } from "execa"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { WebviewProvider } from "@core/webview"
|
||||
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
import { TaskServiceClient } from "webview-ui/src/services/grpc-client"
|
||||
import { validateWorkspacePath, initializeGitRepository, getFileChanges, calculateToolSuccessRate } from "./GitHelper"
|
||||
import {
|
||||
updateGlobalState,
|
||||
getAllExtensionState,
|
||||
updateApiConfiguration,
|
||||
storeSecret,
|
||||
updateWorkspaceState,
|
||||
} from "@core/storage/state"
|
||||
import { updateGlobalState, getAllExtensionState, storeSecret } from "@core/storage/state"
|
||||
import { ClineAsk, ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
import { ApiProvider } from "@shared/api"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { getSavedClineMessages, getSavedApiConversationHistory } from "@core/storage/disk"
|
||||
import { AskResponseRequest } from "@shared/proto/cline/task"
|
||||
import { getCwd } from "@/utils/path"
|
||||
import { askResponse } from "@core/controller/task/askResponse"
|
||||
|
||||
/**
|
||||
* Creates a tracker to monitor tool calls and failures during task execution
|
||||
@@ -268,14 +262,17 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
|
||||
}
|
||||
|
||||
// Store the API key securely
|
||||
await storeSecret(visibleWebview.controller.context, "clineAccountId", apiKey)
|
||||
visibleWebview.controller.cacheService.setSecret("clineAccountId", apiKey)
|
||||
|
||||
// Update the API configuration
|
||||
await updateApiConfiguration(visibleWebview.controller.context, updatedConfig)
|
||||
visibleWebview.controller.cacheService.setApiConfiguration(updatedConfig)
|
||||
|
||||
// Update global state to use cline provider
|
||||
await updateGlobalState(visibleWebview.controller.context, "planModeApiProvider", "cline")
|
||||
await updateGlobalState(visibleWebview.controller.context, "actModeApiProvider", "cline")
|
||||
// Update cache service to use cline provider
|
||||
const currentConfig = visibleWebview.controller.cacheService.getApiConfiguration()
|
||||
visibleWebview.controller.cacheService.setApiConfiguration({
|
||||
...currentConfig,
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiProvider: "cline",
|
||||
})
|
||||
|
||||
// Post state to webview to reflect changes
|
||||
await visibleWebview.controller.postStateToWebview()
|
||||
@@ -624,9 +621,10 @@ async function autoRespondToAsk(webviewProvider: WebviewProvider, askType: Cline
|
||||
// we use the default "yesButtonClicked" to approve the action
|
||||
}
|
||||
|
||||
// Send the response message
|
||||
// Send the response message using the backend controller method
|
||||
try {
|
||||
await TaskServiceClient.askResponse(
|
||||
await askResponse(
|
||||
webviewProvider.controller,
|
||||
AskResponseRequest.create({
|
||||
responseType,
|
||||
text: responseText,
|
||||
|
||||
@@ -129,6 +129,9 @@ async function parseFile(
|
||||
try {
|
||||
// Parse the file content into an Abstract Syntax Tree (AST), a tree-like representation of the code
|
||||
const tree = parser.parse(fileContent)
|
||||
if (!tree || !tree.rootNode) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Apply the query to the AST and get the captures
|
||||
// Captures are specific parts of the AST that match our query patterns, each capture represents a node in the AST that we're interested in.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as vscode from "vscode"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
|
||||
/**
|
||||
* Shared URI handler that processes both VSCode URI events and HTTP server callbacks
|
||||
*/
|
||||
export class SharedUriHandler {
|
||||
/**
|
||||
* Processes a URI and routes it to the appropriate handler
|
||||
* @param uri The URI to process (can be from VSCode or converted from HTTP)
|
||||
* @returns Promise<boolean> indicating success (true) or failure (false)
|
||||
*/
|
||||
public static async handleUri(uri: vscode.Uri): Promise<boolean> {
|
||||
console.log("SharedUriHandler: Processing URI:", {
|
||||
path: uri.path,
|
||||
query: uri.query,
|
||||
scheme: uri.scheme,
|
||||
})
|
||||
|
||||
const path = uri.path
|
||||
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
|
||||
if (!visibleWebview) {
|
||||
console.warn("SharedUriHandler: No visible webview found")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
switch (path) {
|
||||
case "/openrouter": {
|
||||
const code = query.get("code")
|
||||
if (code) {
|
||||
await visibleWebview.controller.handleOpenRouterCallback(code)
|
||||
return true
|
||||
}
|
||||
console.warn("SharedUriHandler: Missing code parameter for OpenRouter callback")
|
||||
return false
|
||||
}
|
||||
case "/auth": {
|
||||
console.log("SharedUriHandler: Auth callback received:", { path: uri.path, provider: query.get("provider") })
|
||||
|
||||
const token = query.get("idToken")
|
||||
const provider = query.get("provider")
|
||||
|
||||
if (token) {
|
||||
await visibleWebview.controller.handleAuthCallback(token, provider)
|
||||
return true
|
||||
}
|
||||
console.warn("SharedUriHandler: Missing idToken parameter for auth callback")
|
||||
return false
|
||||
}
|
||||
default:
|
||||
console.warn(`SharedUriHandler: Unknown path: ${path}`)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SharedUriHandler: Error processing URI:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an HTTP URL to a vscode.Uri for unified processing
|
||||
* @param httpUrl The HTTP URL to convert
|
||||
* @returns vscode.Uri representation of the URL
|
||||
*/
|
||||
public static convertHttpUrlToUri(httpUrl: string): vscode.Uri {
|
||||
return vscode.Uri.parse(httpUrl)
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,15 @@ import { McpDisplayMode } from "./McpDisplayMode"
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
type: "grpc_response" // New type for gRPC responses
|
||||
grpc_response?: GrpcResponse
|
||||
}
|
||||
|
||||
grpc_response?: {
|
||||
message?: any // JSON serialized protobuf message
|
||||
request_id: string // Same ID as the request
|
||||
error?: string // Optional error message
|
||||
is_streaming?: boolean // Whether this is part of a streaming response
|
||||
sequence_number?: number // For ordering chunks in streaming responses
|
||||
}
|
||||
export type GrpcResponse = {
|
||||
message?: any // JSON serialized protobuf message
|
||||
request_id: string // Same ID as the request
|
||||
error?: string // Optional error message
|
||||
is_streaming?: boolean // Whether this is part of a streaming response
|
||||
sequence_number?: number // For ordering chunks in streaming responses
|
||||
}
|
||||
|
||||
export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown"
|
||||
@@ -62,6 +63,7 @@ export interface ExtensionState {
|
||||
localCursorRulesToggles: ClineRulesToggles
|
||||
localWindsurfRulesToggles: ClineRulesToggles
|
||||
mcpResponsesCollapsed?: boolean
|
||||
strictPlanModeEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface ClineMessage {
|
||||
|
||||
@@ -1,71 +1,19 @@
|
||||
import { ApiConfiguration } from "./api"
|
||||
import { BrowserSettings } from "./BrowserSettings"
|
||||
import { UserInfo } from "./UserInfo"
|
||||
import { ChatContent } from "./ChatContent"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import { McpViewTab } from "./mcp"
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
| "requestVsCodeLmModels"
|
||||
| "fetchMcpMarketplace"
|
||||
| "searchCommits"
|
||||
| "telemetrySetting"
|
||||
| "grpc_request"
|
||||
| "grpc_request_cancel"
|
||||
type: "grpc_request" | "grpc_request_cancel"
|
||||
grpc_request?: GrpcRequest
|
||||
grpc_request_cancel?: GrpcCancel
|
||||
}
|
||||
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
apiConfiguration?: ApiConfiguration
|
||||
images?: string[]
|
||||
files?: string[]
|
||||
bool?: boolean
|
||||
number?: number
|
||||
browserSettings?: BrowserSettings
|
||||
chatContent?: ChatContent
|
||||
mcpId?: string
|
||||
timeout?: number
|
||||
tab?: McpViewTab
|
||||
// For toggleToolAutoApprove
|
||||
serverName?: string
|
||||
serverUrl?: string
|
||||
toolNames?: string[]
|
||||
autoApprove?: boolean
|
||||
export type GrpcRequest = {
|
||||
service: string
|
||||
method: string
|
||||
message: any // JSON serialized protobuf message
|
||||
request_id: string // For correlating requests and responses
|
||||
is_streaming: boolean // Whether this is a streaming request
|
||||
}
|
||||
|
||||
// For auth
|
||||
user?: UserInfo | null
|
||||
customToken?: string
|
||||
planActSeparateModelsSetting?: boolean
|
||||
enableCheckpointsSetting?: boolean
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
mcpResponsesCollapsed?: boolean
|
||||
telemetrySetting?: TelemetrySetting
|
||||
mcpRichDisplayEnabled?: boolean
|
||||
mentionsRequestId?: string
|
||||
query?: string
|
||||
// For toggleFavoriteModel
|
||||
modelId?: string
|
||||
grpc_request?: {
|
||||
service: string
|
||||
method: string
|
||||
message: any // JSON serialized protobuf message
|
||||
request_id: string // For correlating requests and responses
|
||||
is_streaming?: boolean // Whether this is a streaming request
|
||||
}
|
||||
grpc_request_cancel?: {
|
||||
request_id: string // ID of the request to cancel
|
||||
}
|
||||
// For cline rules and workflows
|
||||
isGlobal?: boolean
|
||||
rulePath?: string
|
||||
workflowPath?: string
|
||||
enabled?: boolean
|
||||
filename?: string
|
||||
|
||||
offset?: number
|
||||
shellIntegrationTimeout?: number
|
||||
terminalReuseEnabled?: boolean
|
||||
defaultTerminalProfile?: string
|
||||
export type GrpcCancel = {
|
||||
request_id: string // ID of the request to cancel
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
@@ -47,6 +47,11 @@ describe("Mention Regex", () => {
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
it("handles unquoted paths with spaces correctly", () => {
|
||||
// Should stop at the space
|
||||
const match = mentionRegex.exec("@/path with spaces/file.txt")
|
||||
expect(match?.[0]).to.equal("@/path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Existing Functionality", () => {
|
||||
@@ -107,6 +112,7 @@ describe("Mention Regex", () => {
|
||||
["C:\\folder\\file.txt", null],
|
||||
["@", null],
|
||||
["@ C:\\file.txt", null],
|
||||
["@'/path/file.tar.gz'", null],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
@@ -188,4 +194,228 @@ describe("Mention Regex", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
describe("Git Hash Edge Cases", () => {
|
||||
it("matches git hashes of various valid lengths", () => {
|
||||
const cases: Array<[string, string | null]> = [
|
||||
// Valid lengths (7-40 characters)
|
||||
["@abcdef1", "@abcdef1"], // 7 chars (minimum)
|
||||
["@abcdef12", "@abcdef12"], // 8 chars
|
||||
["@abcdef1234567890", "@abcdef1234567890"], // 16 chars
|
||||
["@abcdef1234567890abcdef1234567890abcdef12", "@abcdef1234567890abcdef1234567890abcdef12"], // 40 chars (maximum)
|
||||
|
||||
// Invalid lengths
|
||||
["@abcdef", null], // 6 chars (too short)
|
||||
["@abcdef1234567890abcdef1234567890abcdef123", null], // 41 chars (too long, but would match first 40)
|
||||
|
||||
// Invalid characters
|
||||
["@ghijklm", null], // Contains non-hex characters
|
||||
["@ABCDEF1", null], // Uppercase not allowed
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
const actual = match ? match[0] : null
|
||||
if (expected && expected.includes("41 chars")) {
|
||||
// Special case: should match first 40 chars
|
||||
expect(actual).to.equal("@abcdef1234567890abcdef1234567890abcdef12")
|
||||
} else {
|
||||
expect(actual).to.equal(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Punctuation at Boundaries", () => {
|
||||
it("excludes all types of trailing punctuation", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@/path/file.txt.", "@/path/file.txt"],
|
||||
["@problems:", "@problems"],
|
||||
["@terminal;", "@terminal"],
|
||||
["@/path/file.txt!", "@/path/file.txt"],
|
||||
["@/path/file.txt?", "@/path/file.txt"],
|
||||
["@git-changes,", "@git-changes"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it("handles multiple punctuation marks", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@/path/file.txt!?", "@/path/file.txt"],
|
||||
["@problems...", "@problems"],
|
||||
["@terminal!!", "@terminal"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it("doesn't match trailing punctuation in context", () => {
|
||||
const cases: Array<[string, string[]]> = [
|
||||
["Check the file at @/C:\\folder\\file.txt! for details.", ["@/C:\\folder\\file.txt"]],
|
||||
["Review @problems, and @git-changes.", ["@problems", "@git-changes"]],
|
||||
["Multiple: @/file1.txt, and @/C:\\file2.txt; and @terminal?", ["@/file1.txt", "@/C:\\file2.txt", "@terminal"]],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const matches = input.match(mentionRegexGlobal)
|
||||
expect(matches).deep.eq(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("URL Protocol Variations", () => {
|
||||
it("matches various URL protocols", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@file://localhost/path/to/file", "@file://localhost/path/to/file"],
|
||||
["@custom://app/resource", "@custom://app/resource"],
|
||||
["@app://settings", "@app://settings"],
|
||||
["@ssh://git@github.com/repo", "@ssh://git@github.com/repo"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it("matches URLs with complex structures", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@https://example.com?q=test&p=1", "@https://example.com?q=test&p=1"],
|
||||
["@https://example.com#section", "@https://example.com#section"],
|
||||
["@http://localhost:3000", "@http://localhost:3000"],
|
||||
["@https://user:pass@example.com", "@https://user:pass@example.com"],
|
||||
["@https://example.com/", "@https://example.com/"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("End of String Handling", () => {
|
||||
it("matches mentions at end of string", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["Check @/path/file.txt", "@/path/file.txt"],
|
||||
["Review @problems", "@problems"],
|
||||
["Open @terminal", "@terminal"],
|
||||
["See @git-changes", "@git-changes"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Complex Real-World Scenarios", () => {
|
||||
it("handles mentions in markdown-like text", () => {
|
||||
const text = "See @/docs/README.md, check @problems, and visit @https://example.com."
|
||||
const matches = text.match(mentionRegexGlobal)
|
||||
expect(matches).to.deep.equal(["@/docs/README.md", "@problems", "@https://example.com"])
|
||||
})
|
||||
|
||||
it("handles mentions in code comments", () => {
|
||||
const text = "// TODO: Fix @problems in @/src/index.js (see @git-changes)"
|
||||
const matches = text.match(mentionRegexGlobal)
|
||||
expect(matches).to.deep.equal(["@problems", "@/src/index.js", "@git-changes"])
|
||||
})
|
||||
})
|
||||
describe("Quoted file paths", () => {
|
||||
it("handles quoted paths correctly", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['@"/path with space.txt"', '@"/path with space.txt"'],
|
||||
['@"/path/ends/with-space "', '@"/path/ends/with-space "'],
|
||||
['@"/ path-starts-with-space.txt"', '@"/ path-starts-with-space.txt"'],
|
||||
['@"/path with space.txt!"', '@"/path with space.txt!"'],
|
||||
['@"/path with space.txt!"!', '@"/path with space.txt!"'],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
it("handles quotes inside file paths correctly", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['@/"path/file.txt', '@/"path/file.txt'],
|
||||
['@/path"/file".tar.gz', '@/path"/file".tar.gz'],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe("Path Edge Cases", () => {
|
||||
it("matches various path structures", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@/", "@/"], // root directory
|
||||
['@"/"', '@"/"'], // quoted root directory
|
||||
["@/path/to/.hidden/file", "@/path/to/.hidden/file"],
|
||||
["@/path/file...txt", "@/path/file...txt"],
|
||||
["@/path/file.tar.gz", "@/path/file.tar.gz"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe("Whitespace Handling", () => {
|
||||
it("stops at various whitespace characters", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@/path/file.txt\trest", "@/path/file.txt"],
|
||||
["@/path/file.txt\nrest", "@/path/file.txt"],
|
||||
["@/path/file.txt\rrest", "@/path/file.txt"],
|
||||
["@/path/file.txt rest", "@/path/file.txt"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Keyword Boundaries", () => {
|
||||
it("only matches exact keywords", () => {
|
||||
const cases: Array<[string, string | null]> = [
|
||||
["@problemsolver", null], // Should not match
|
||||
["@terminals", null], // Should not match
|
||||
["@git-changeset", null], // Should not match
|
||||
["@problem", null], // Should not match
|
||||
["@git-change", null], // Should not match
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
const actual = match ? match[0] : null
|
||||
expect(actual).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it("matches keywords with trailing punctuation", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@problems!", "@problems"],
|
||||
["@terminal.", "@terminal"],
|
||||
["@git-changes,", "@git-changes"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const match = mentionRegex.exec(input)
|
||||
expect(match?.[0]).to.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+192
-5
@@ -31,6 +31,7 @@ export type ApiProvider =
|
||||
| "groq"
|
||||
| "huggingface"
|
||||
| "huawei-cloud-maas"
|
||||
| "baseten"
|
||||
|
||||
export interface ApiHandlerOptions {
|
||||
// Global configuration (not mode-specific)
|
||||
@@ -87,6 +88,7 @@ export interface ApiHandlerOptions {
|
||||
sambanovaApiKey?: string
|
||||
cerebrasApiKey?: string
|
||||
groqApiKey?: string
|
||||
basetenApiKey?: string
|
||||
requestTimeoutMs?: number
|
||||
sapAiCoreClientId?: string
|
||||
sapAiCoreClientSecret?: string
|
||||
@@ -117,6 +119,8 @@ export interface ApiHandlerOptions {
|
||||
planModeSapAiCoreModelId?: string
|
||||
planModeGroqModelId?: string
|
||||
planModeGroqModelInfo?: ModelInfo
|
||||
planModeBasetenModelId?: string
|
||||
planModeBasetenModelInfo?: ModelInfo
|
||||
planModeHuggingFaceModelId?: string
|
||||
planModeHuggingFaceModelInfo?: ModelInfo
|
||||
planModeHuaweiCloudMaasModelId?: string
|
||||
@@ -144,6 +148,8 @@ export interface ApiHandlerOptions {
|
||||
actModeSapAiCoreModelId?: string
|
||||
actModeGroqModelId?: string
|
||||
actModeGroqModelInfo?: ModelInfo
|
||||
actModeBasetenModelId?: string
|
||||
actModeBasetenModelInfo?: ModelInfo
|
||||
actModeHuggingFaceModelId?: string
|
||||
actModeHuggingFaceModelInfo?: ModelInfo
|
||||
actModeHuaweiCloudMaasModelId?: string
|
||||
@@ -209,6 +215,16 @@ export const anthropicModels = {
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
"claude-opus-4-1-20250805": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
cacheReadsPrice: 1.5,
|
||||
},
|
||||
"claude-opus-4-20250514": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -282,6 +298,11 @@ export const claudeCodeModels = {
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-4-1-20250805": {
|
||||
...anthropicModels["claude-opus-4-1-20250805"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-4-20250514": {
|
||||
...anthropicModels["claude-opus-4-20250514"],
|
||||
supportsImages: false,
|
||||
@@ -329,6 +350,16 @@ export const bedrockModels = {
|
||||
cacheWritesPrice: 18.75,
|
||||
cacheReadsPrice: 1.5,
|
||||
},
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
cacheReadsPrice: 1.5,
|
||||
},
|
||||
"amazon.nova-premier-v1:0": {
|
||||
maxTokens: 10_000,
|
||||
contextWindow: 1_000_000,
|
||||
@@ -1117,6 +1148,26 @@ export const deepSeekModels = {
|
||||
export type HuggingFaceModelId = keyof typeof huggingFaceModels
|
||||
export const huggingFaceDefaultModelId: HuggingFaceModelId = "moonshotai/Kimi-K2-Instruct"
|
||||
export const huggingFaceModels = {
|
||||
"openai/gpt-oss-120b": {
|
||||
maxTokens: 32766,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"Large open-weight reasoning model for high-end desktops and data centers, built for complex coding, math, and general AI tasks.",
|
||||
},
|
||||
"openai/gpt-oss-20b": {
|
||||
maxTokens: 32766,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"Medium open-weight reasoning model that runs on most desktops, balancing strong reasoning with broad accessibility.",
|
||||
},
|
||||
"moonshotai/Kimi-K2-Instruct": {
|
||||
maxTokens: 131_072,
|
||||
contextWindow: 131_072,
|
||||
@@ -2480,8 +2531,37 @@ export const sambanovaModels = {
|
||||
// Cerebras
|
||||
// https://inference-docs.cerebras.ai/api-reference/models
|
||||
export type CerebrasModelId = keyof typeof cerebrasModels
|
||||
export const cerebrasDefaultModelId: CerebrasModelId = "qwen-3-235b-a22b-instruct-2507"
|
||||
export const cerebrasDefaultModelId: CerebrasModelId = "qwen-3-coder-480b-free"
|
||||
export const cerebrasModels = {
|
||||
"gpt-oss-120b": {
|
||||
maxTokens: 65536,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Intelligent general purpose model with 3,000 tokens/s",
|
||||
},
|
||||
"qwen-3-coder-480b-free": {
|
||||
maxTokens: 40000,
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"SOTA coding model with ~2000 tokens/s ($0 free tier)\n\n• Use this if you don't have a Cerebras subscription\n• 64K context window\n• Rate limits: 150K TPM, 1M TPH/TPD, 10 RPM, 100 RPH/RPD\n\nUpgrade for higher limits: [https://cloud.cerebras.ai/?utm=cline](https://cloud.cerebras.ai/?utm=cline)",
|
||||
},
|
||||
"qwen-3-coder-480b": {
|
||||
maxTokens: 40000,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"SOTA coding model with ~2000 tokens/s ($50/$250 paid tiers)\n\n• Use this if you have a Cerebras subscription\n• 131K context window with higher rate limits",
|
||||
},
|
||||
"qwen-3-235b-a22b-instruct-2507": {
|
||||
maxTokens: 64000,
|
||||
contextWindow: 64000,
|
||||
@@ -2509,9 +2589,9 @@ export const cerebrasModels = {
|
||||
outputPrice: 0,
|
||||
description: "SOTA coding performance with ~2500 tokens/s",
|
||||
},
|
||||
"qwen-3-235b-a22b": {
|
||||
maxTokens: 40000,
|
||||
contextWindow: 40000,
|
||||
"qwen-3-235b-a22b-thinking-2507": {
|
||||
maxTokens: 32000,
|
||||
contextWindow: 65000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
@@ -2524,8 +2604,28 @@ export const cerebrasModels = {
|
||||
// https://console.groq.com/docs/models
|
||||
// https://groq.com/pricing/
|
||||
export type GroqModelId = keyof typeof groqModels
|
||||
export const groqDefaultModelId: GroqModelId = "moonshotai/kimi-k2-instruct"
|
||||
export const groqDefaultModelId: GroqModelId = "openai/gpt-oss-120b"
|
||||
export const groqModels = {
|
||||
"openai/gpt-oss-120b": {
|
||||
maxTokens: 32766, // Model fails if you try to use more than 32K tokens
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.15,
|
||||
outputPrice: 0.75,
|
||||
description:
|
||||
"A state-of-the-art 120B open-weight Mixture-of-Experts language model optimized for strong reasoning, tool use, and efficient deployment on large GPUs",
|
||||
},
|
||||
"openai/gpt-oss-20b": {
|
||||
maxTokens: 32766, // Model fails if you try to use more than 32K tokens
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.5,
|
||||
description:
|
||||
"A compact 20B open-weight Mixture-of-Experts language model designed for strong reasoning and tool use, ideal for edge devices and local inference.",
|
||||
},
|
||||
// Compound Beta Models - Hybrid architectures optimized for tool use
|
||||
"compound-beta": {
|
||||
maxTokens: 8192,
|
||||
@@ -2864,3 +2964,90 @@ export const huaweiCloudMaasModels = {
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Baseten
|
||||
// https://baseten.co/products/model-apis/
|
||||
export const basetenModels = {
|
||||
"deepseek-ai/DeepSeek-R1-0528": {
|
||||
maxTokens: 131072,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 2.55,
|
||||
outputPrice: 5.95,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description:
|
||||
"DeepSeek R1 0528 - A state-of-the-art 671B-parameter MoE LLM with o1-style reasoning licensed for commercial use.",
|
||||
},
|
||||
"deepseek-ai/DeepSeek-V3-0324": {
|
||||
maxTokens: 131072,
|
||||
contextWindow: 163840,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.77,
|
||||
outputPrice: 0.77,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description: "DeepSeek V3 0324 - A state-of-the-art 671B-parameter MoE LLM licensed for commercial use.",
|
||||
},
|
||||
"meta-llama/Llama-4-Maverick-17B-128E-Instruct": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.19,
|
||||
outputPrice: 0.72,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description: "Meta's Llama 4 Maverick - A SOTA mixture-of-experts multi-modal LLM with 400 billion total parameters.",
|
||||
},
|
||||
"meta-llama/Llama-4-Scout-17B-16E-Instruct": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.13,
|
||||
outputPrice: 0.5,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description: "Meta's Llama 4 Scout - A SOTA mixture-of-experts multi-modal LLM with 109 billion total parameters.",
|
||||
},
|
||||
"moonshotai/Kimi-K2-Instruct": {
|
||||
maxTokens: 131072,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 2.5,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description: "Moonshot AI's Kimi K2 - The world's first 1 trillion parameter open source model.",
|
||||
},
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507": {
|
||||
maxTokens: 163800,
|
||||
contextWindow: 163800,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.22,
|
||||
outputPrice: 0.8,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description:
|
||||
"Qwen3-235B-A22B-Instruct-2507 is a multilingual, instruction-tuned mixture-of-experts language model based on the Qwen3-235B architecture, with 22B active parameters per forward pass.",
|
||||
},
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct": {
|
||||
maxTokens: 163800,
|
||||
contextWindow: 163800,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.7,
|
||||
outputPrice: 1.7,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description:
|
||||
"Qwen3-Coder-480B-A35B-Instruct is a 480B parameter, instruction-tuned, agentic coding model that excels at function calling, tool use, and long-context reasoning over repositories.",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
export type BasetenModelId = keyof typeof basetenModels
|
||||
export const basetenDefaultModelId = "moonshotai/Kimi-K2-Instruct" satisfies BasetenModelId
|
||||
|
||||
@@ -29,10 +29,10 @@ Mention regex:
|
||||
- **Exact Word ('terminal')**: Matches the exact word 'terminal'.
|
||||
- **Word Boundary (`\b`)**: Ensures that 'terminal' is matched as a whole word and not as part of another word (e.g., 'terminals').
|
||||
|
||||
- `(?=[.,;:!?]?(?=[\s\r\n]|$))`:
|
||||
- `(?=[.,;:!?()]*(?=[\s\r\n]|$))`:
|
||||
- **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match.
|
||||
- `[.,;:!?]?`:
|
||||
- **Optional Punctuation (`[.,;:!?]?`)**: Matches zero or one of the specified punctuation marks.
|
||||
- `[.,;:!?()]*`:
|
||||
- **Optional Punctuation (`[.,;:!?()]*`)**: Matches zero or more of the specified punctuation marks (including parentheses).
|
||||
- `(?=[\s\r\n]|$)`:
|
||||
- **Nested Positive Lookahead (`(?=[\s\r\n]|$)`)**: Ensures that the punctuation (if present) is followed by a whitespace character, a line break, or the end of the string.
|
||||
|
||||
@@ -49,6 +49,16 @@ Mention regex:
|
||||
- `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string.
|
||||
|
||||
*/
|
||||
export const mentionRegex =
|
||||
/@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|terminal\b|git-changes\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
|
||||
export const mentionRegex = new RegExp(
|
||||
`@(` +
|
||||
`/[^\\s]*?` + // Simple file paths (can't contain)
|
||||
`|"\\/[^"]*?"` + // Quoted file paths which can contain spaces
|
||||
`|(?:\\w+:\\/\\/)[^\\s]+?` + // URLs
|
||||
`|[a-f0-9]{7,40}\\b` + // Git commit hashes
|
||||
`|problems\\b` + // Exact word 'problems'
|
||||
`|terminal\\b` + // Exact word 'terminal'
|
||||
`|git-changes\\b` + // Exact word 'git-changes'
|
||||
`)` +
|
||||
`(?=[.,;:!?()]*(?=[\\s\\r\\n]|$))`, // Lookahead for trailing punctuation (multiple allowed)
|
||||
)
|
||||
export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
|
||||
|
||||
@@ -240,6 +240,8 @@ function convertApiProviderToProto(provider: string | undefined): ProtoApiProvid
|
||||
return ProtoApiProvider.CEREBRAS
|
||||
case "groq":
|
||||
return ProtoApiProvider.GROQ
|
||||
case "baseten":
|
||||
return ProtoApiProvider.BASETEN
|
||||
case "sapaicore":
|
||||
return ProtoApiProvider.SAPAICORE
|
||||
case "claude-code":
|
||||
@@ -308,6 +310,8 @@ function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvider {
|
||||
return "cerebras"
|
||||
case ProtoApiProvider.GROQ:
|
||||
return "groq"
|
||||
case ProtoApiProvider.BASETEN:
|
||||
return "baseten"
|
||||
case ProtoApiProvider.SAPAICORE:
|
||||
return "sapaicore"
|
||||
case ProtoApiProvider.CLAUDE_CODE:
|
||||
@@ -376,6 +380,7 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
|
||||
sambanovaApiKey: config.sambanovaApiKey,
|
||||
cerebrasApiKey: config.cerebrasApiKey,
|
||||
groqApiKey: config.groqApiKey,
|
||||
basetenApiKey: config.basetenApiKey,
|
||||
requestTimeoutMs: config.requestTimeoutMs,
|
||||
sapAiCoreClientId: config.sapAiCoreClientId,
|
||||
sapAiCoreClientSecret: config.sapAiCoreClientSecret,
|
||||
@@ -406,6 +411,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
|
||||
planModeFireworksModelId: config.planModeFireworksModelId,
|
||||
planModeGroqModelId: config.planModeGroqModelId,
|
||||
planModeGroqModelInfo: convertModelInfoToProtoOpenRouter(config.planModeGroqModelInfo),
|
||||
planModeBasetenModelId: config.planModeBasetenModelId,
|
||||
planModeBasetenModelInfo: convertModelInfoToProtoOpenRouter(config.planModeBasetenModelInfo),
|
||||
planModeHuggingFaceModelId: config.planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo: convertModelInfoToProtoOpenRouter(config.planModeHuggingFaceModelInfo),
|
||||
planModeSapAiCoreModelId: config.planModeSapAiCoreModelId,
|
||||
@@ -434,6 +441,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
|
||||
actModeFireworksModelId: config.actModeFireworksModelId,
|
||||
actModeGroqModelId: config.actModeGroqModelId,
|
||||
actModeGroqModelInfo: convertModelInfoToProtoOpenRouter(config.actModeGroqModelInfo),
|
||||
actModeBasetenModelId: config.actModeBasetenModelId,
|
||||
actModeBasetenModelInfo: convertModelInfoToProtoOpenRouter(config.actModeBasetenModelInfo),
|
||||
actModeHuggingFaceModelId: config.actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo: convertModelInfoToProtoOpenRouter(config.actModeHuggingFaceModelInfo),
|
||||
actModeSapAiCoreModelId: config.actModeSapAiCoreModelId,
|
||||
@@ -502,6 +511,7 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
|
||||
sambanovaApiKey: protoConfig.sambanovaApiKey,
|
||||
cerebrasApiKey: protoConfig.cerebrasApiKey,
|
||||
groqApiKey: protoConfig.groqApiKey,
|
||||
basetenApiKey: protoConfig.basetenApiKey,
|
||||
requestTimeoutMs: protoConfig.requestTimeoutMs,
|
||||
sapAiCoreClientId: protoConfig.sapAiCoreClientId,
|
||||
sapAiCoreClientSecret: protoConfig.sapAiCoreClientSecret,
|
||||
@@ -535,6 +545,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
|
||||
planModeFireworksModelId: protoConfig.planModeFireworksModelId,
|
||||
planModeGroqModelId: protoConfig.planModeGroqModelId,
|
||||
planModeGroqModelInfo: convertProtoToModelInfo(protoConfig.planModeGroqModelInfo),
|
||||
planModeBasetenModelId: protoConfig.planModeBasetenModelId,
|
||||
planModeBasetenModelInfo: convertProtoToModelInfo(protoConfig.planModeBasetenModelInfo),
|
||||
planModeHuggingFaceModelId: protoConfig.planModeHuggingFaceModelId,
|
||||
planModeHuggingFaceModelInfo: convertProtoToModelInfo(protoConfig.planModeHuggingFaceModelInfo),
|
||||
planModeSapAiCoreModelId: protoConfig.planModeSapAiCoreModelId,
|
||||
@@ -564,6 +576,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
|
||||
actModeFireworksModelId: protoConfig.actModeFireworksModelId,
|
||||
actModeGroqModelId: protoConfig.actModeGroqModelId,
|
||||
actModeGroqModelInfo: convertProtoToModelInfo(protoConfig.actModeGroqModelInfo),
|
||||
actModeBasetenModelId: protoConfig.actModeBasetenModelId,
|
||||
actModeBasetenModelInfo: convertProtoToModelInfo(protoConfig.actModeBasetenModelInfo),
|
||||
actModeHuggingFaceModelId: protoConfig.actModeHuggingFaceModelId,
|
||||
actModeHuggingFaceModelInfo: convertProtoToModelInfo(protoConfig.actModeHuggingFaceModelInfo),
|
||||
actModeSapAiCoreModelId: protoConfig.actModeSapAiCoreModelId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const FEATURE_FLAGS = {
|
||||
CUSTOM_INSTRUCTIONS: "custom-instructions",
|
||||
// Further flags here
|
||||
DEV_ENV_POSTHOG: "dev-env-posthog",
|
||||
} as const
|
||||
|
||||
export type FeatureFlag = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { activate } from "@/extension"
|
||||
import { Controller } from "@core/controller"
|
||||
import { CacheService } from "@core/storage/CacheService"
|
||||
import { ExternalDiffViewProvider } from "@hosts/external/ExternalDiffviewProvider"
|
||||
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
|
||||
import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-client-manager"
|
||||
@@ -9,18 +10,24 @@ import { v4 as uuidv4 } from "uuid"
|
||||
import { log } from "./utils"
|
||||
import { extensionContext, postMessage } from "./vscode-context"
|
||||
import { startProtobusService } from "./protobus-service"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
|
||||
async function main() {
|
||||
log("\n\n\nStarting cline-core service...\n\n\n")
|
||||
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
|
||||
setupHostProvider()
|
||||
|
||||
// Set up global error handlers to prevent process crashes
|
||||
setupGlobalErrorHandlers()
|
||||
|
||||
activate(extensionContext)
|
||||
// Create and initialize cache service
|
||||
|
||||
// Create controller with cache service
|
||||
const controller = new Controller(extensionContext, postMessage, uuidv4())
|
||||
startProtobusService(controller)
|
||||
}
|
||||
@@ -32,8 +39,11 @@ function setupHostProvider() {
|
||||
const createDiffView = (): DiffViewProvider => {
|
||||
return new ExternalDiffViewProvider()
|
||||
}
|
||||
const getCallbackUri = (): Promise<string> => {
|
||||
return AuthHandler.getInstance().getCallbackUri()
|
||||
}
|
||||
|
||||
HostProvider.initialize(createWebview, createDiffView, new ExternalHostBridgeClientManager(), log)
|
||||
HostProvider.initialize(createWebview, createDiffView, new ExternalHostBridgeClientManager(), log, getCallbackUri)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,8 @@ const log = (...args: unknown[]) => {
|
||||
function getPackageDefinition() {
|
||||
// Load service definitions.
|
||||
const descriptorSet = fs.readFileSync("proto/descriptor_set.pb")
|
||||
const descriptorDefs = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet)
|
||||
const options = { longs: Number } // Encode int64 fields as numbers
|
||||
const descriptorDefs = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet, options)
|
||||
const healthDef = protoLoader.loadSync(health.protoPath)
|
||||
const packageDefinition = { ...descriptorDefs, ...healthDef }
|
||||
return packageDefinition
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { DiffViewProviderCreator, WebviewProviderCreator } from "@/hosts/host-provider"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
|
||||
import { createClineAPI } from "@/exports"
|
||||
import * as stateModule from "@core/storage/state"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import * as should from "should"
|
||||
import * as sinon from "sinon"
|
||||
import type { ClineAPI } from "../cline"
|
||||
import { createClineAPI } from "../index"
|
||||
import type { ClineAPI } from "../exports/cline"
|
||||
import { setVscodeHostProviderMock } from "./host-provider-test-utils"
|
||||
|
||||
describe("ClineAPI Core Functionality", () => {
|
||||
let api: ClineAPI
|
||||
@@ -15,17 +13,13 @@ describe("ClineAPI Core Functionality", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let getGlobalStateStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Create mock log function
|
||||
mockLogToChannel = sandbox.stub<[string], void>()
|
||||
HostProvider.initialize(
|
||||
((_) => {}) as WebviewProviderCreator,
|
||||
(() => {}) as DiffViewProviderCreator,
|
||||
{} as HostBridgeClientProvider,
|
||||
mockLogToChannel,
|
||||
)
|
||||
setVscodeHostProviderMock({ logToChannel: mockLogToChannel })
|
||||
|
||||
// Stub the getGlobalState function from the state module
|
||||
// This is needed because the real createClineAPI uses it for getCustomInstructions
|
||||
getGlobalStateStub = sandbox.stub(stateModule, "getGlobalState")
|
||||
@@ -33,6 +27,7 @@ describe("ClineAPI Core Functionality", () => {
|
||||
// Create a mock controller that matches what the real createClineAPI expects
|
||||
// We don't import the real Controller to avoid the webview dependencies
|
||||
mockController = {
|
||||
id: "test-controller-id",
|
||||
context: {
|
||||
globalState: {
|
||||
get: sandbox.stub(),
|
||||
@@ -73,10 +68,6 @@ describe("ClineAPI Core Functionality", () => {
|
||||
// Verify task clearing sequence
|
||||
sinon.assert.called(mockController.clearTask)
|
||||
sinon.assert.called(mockController.postStateToWebview)
|
||||
sinon.assert.calledWith(mockController.postMessageToWebview, {
|
||||
type: "action",
|
||||
action: "chatButtonClicked",
|
||||
})
|
||||
sinon.assert.calledWith(mockController.initTask, taskDescription, images)
|
||||
|
||||
// Verify logging - first it logs "Starting new task"
|
||||
@@ -5,9 +5,12 @@ import { ClineApiServerMock } from "../fixtures/server"
|
||||
import { getResultsDir, rmForRetries } from "./helpers"
|
||||
|
||||
teardown("cleanup test environment", async () => {
|
||||
const assetsDir = getResultsDir()
|
||||
await ClineApiServerMock.stopGlobalServer()
|
||||
.then(() => console.log("ClineApiServerMock stopped successfully."))
|
||||
.catch((error) => console.error("Error stopping ClineApiServerMock:", error))
|
||||
|
||||
try {
|
||||
const assetsDir = getResultsDir()
|
||||
const results = await fs.readdir(assetsDir, { withFileTypes: true })
|
||||
await Promise.all(
|
||||
results
|
||||
@@ -22,12 +25,10 @@ teardown("cleanup test environment", async () => {
|
||||
}
|
||||
}),
|
||||
)
|
||||
await ClineApiServerMock.stopGlobalServer()
|
||||
console.log("ClineApiServerMock stopped successfully.")
|
||||
} catch (error) {
|
||||
// Silently handle case where assets directory doesn't exist
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error
|
||||
console.error("Error during cleanup:", error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -124,11 +124,11 @@ export class E2ETestHelper {
|
||||
}
|
||||
|
||||
public static async runCommandPalette(page: Page, command: string): Promise<void> {
|
||||
await page.locator("li").filter({ hasText: "[Extension Development Host]" }).first().click()
|
||||
const editorMenu = page.locator("li").filter({ hasText: "[Extension Development Host]" }).first()
|
||||
await editorMenu.click({ delay: 100 })
|
||||
const editorSearchBar = page.getByRole("textbox", {
|
||||
name: "Search files by name (append",
|
||||
})
|
||||
await expect(editorSearchBar).toBeVisible()
|
||||
await editorSearchBar.click({ delay: 100 }) // Ensure focus
|
||||
await editorSearchBar.fill(`>${command}`)
|
||||
await page.keyboard.press("Enter")
|
||||
@@ -273,7 +273,6 @@ export const e2e = test
|
||||
.extend({
|
||||
page: async ({ app }, use) => {
|
||||
const page = await app.firstWindow()
|
||||
await E2ETestHelper.runCommandPalette(page, "notifications: toggle do not disturb")
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { HostProvider, WebviewProviderCreator, DiffViewProviderCreator } from "@/hosts/host-provider"
|
||||
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
|
||||
|
||||
/**
|
||||
* Initializes the HostProvider with test defaults.
|
||||
* This is a common setup used across multiple test files.
|
||||
*
|
||||
* @param options Optional overrides for the default test configuration
|
||||
*/
|
||||
export function setVscodeHostProviderMock(options?: {
|
||||
webviewProviderCreator?: WebviewProviderCreator
|
||||
diffViewProviderCreator?: DiffViewProviderCreator
|
||||
hostBridgeClient?: HostBridgeClientProvider
|
||||
logToChannel?: (message: string) => void
|
||||
getCallbackUri?: () => Promise<string>
|
||||
}) {
|
||||
HostProvider.reset()
|
||||
HostProvider.initialize(
|
||||
options?.webviewProviderCreator ?? (((_) => {}) as WebviewProviderCreator),
|
||||
options?.diffViewProviderCreator ?? ((() => {}) as DiffViewProviderCreator),
|
||||
options?.hostBridgeClient ?? vscodeHostBridgeClient,
|
||||
options?.logToChannel ?? ((_) => {}),
|
||||
options?.getCallbackUri ?? (async () => "http://example.com:1234/"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as vscode from "vscode"
|
||||
|
||||
/**
|
||||
* Gets the latest announcement ID based on the extension version
|
||||
* Uses major.minor version format (e.g., "1.2" from "1.2.3")
|
||||
*
|
||||
* @param context The VSCode extension context
|
||||
* @returns The announcement ID string (major.minor version) or empty string if unavailable
|
||||
*/
|
||||
export function getLatestAnnouncementId(context: vscode.ExtensionContext): string {
|
||||
return context.extension?.packageJSON?.version?.split(".").slice(0, 2).join(".") ?? ""
|
||||
}
|
||||
@@ -13,11 +13,12 @@
|
||||
* fields containing special characters.
|
||||
*/
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import * as cp from "child_process"
|
||||
import * as os from "os"
|
||||
import * as util from "util"
|
||||
import { writeTextToClipboard, openExternal } from "@/utils/env"
|
||||
import { HostProvider } from "@hosts/host-provider"
|
||||
import { ShowMessageType } from "@shared/proto/host/window"
|
||||
|
||||
/**
|
||||
* Creates a properly encoded GitHub issue URL.
|
||||
@@ -152,13 +153,16 @@ export async function openUrlInBrowser(url: string): Promise<void> {
|
||||
console.error(`Error with openExternal utility: ${openExternalError}`)
|
||||
|
||||
// Last fallback: Show a message with instructions
|
||||
vscode.window
|
||||
.showInformationMessage(
|
||||
"Couldn't open the URL automatically. It has been copied to your clipboard.",
|
||||
"Copy URL Again",
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === "Copy URL Again") {
|
||||
HostProvider.window
|
||||
.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Couldn't open the URL automatically. It has been copied to your clipboard.",
|
||||
options: {
|
||||
items: ["Copy URL Again"],
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.selectedOption === "Copy URL Again") {
|
||||
writeTextToClipboard(url)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const tsConfigPaths = require("tsconfig-paths")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const Module = require("module")
|
||||
|
||||
const baseUrl = path.resolve(__dirname)
|
||||
|
||||
@@ -23,3 +24,16 @@ tsConfigPaths.register({
|
||||
baseUrl: baseUrl,
|
||||
paths: outPaths,
|
||||
})
|
||||
|
||||
// Mock the @google/genai module to avoid ESM compatibility issues in tests
|
||||
// The module is ES6 only, but the integration tests are compiled to commonJS.
|
||||
const originalRequire = Module.prototype.require
|
||||
Module.prototype.require = function (id) {
|
||||
// Intercept requires for @google/genai
|
||||
if (id === "@google/genai") {
|
||||
// Return the mock instead
|
||||
const mockPath = path.join(baseUrl, "out/src/api/providers/gemini-mock.test.js")
|
||||
return originalRequire.call(this, mockPath)
|
||||
}
|
||||
return originalRequire.call(this, id)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ const AppContent = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
{showSettings && <SettingsView onDone={hideSettings} />}
|
||||
{showHistory && <HistoryView onDone={hideHistory} />}
|
||||
{showMcp && <McpView initialTab={mcpTab} onDone={closeMcpView} />}
|
||||
|
||||
@@ -1,38 +1,48 @@
|
||||
import { useEffect, type ReactNode } from "react"
|
||||
import { PostHogProvider } from "posthog-js/react"
|
||||
import posthog from "posthog-js"
|
||||
import { posthogConfig } from "@shared/services/config/posthog-config"
|
||||
import posthog from "posthog-js"
|
||||
import { PostHogProvider } from "posthog-js/react"
|
||||
import { type ReactNode, useEffect, useState } from "react"
|
||||
import { useExtensionState } from "./context/ExtensionStateContext"
|
||||
|
||||
export function CustomPostHogProvider({ children }: { children: ReactNode }) {
|
||||
const { telemetrySetting, distinctId, version } = useExtensionState()
|
||||
const isTelemetryEnabled = telemetrySetting !== "disabled"
|
||||
const { telemetrySetting, distinctId, version, userInfo } = useExtensionState()
|
||||
|
||||
// NOTE: This is a hack to stop recording webview click events temporarily.
|
||||
// Remove this to re-enable.
|
||||
const temporaryDisabled = true
|
||||
// const isTelemetryEnabled = telemetrySetting !== "disabled";
|
||||
const isTelemetryEnabled = false
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (temporaryDisabled) {
|
||||
if (isActive || !isTelemetryEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
posthog.init(posthogConfig.apiKey, {
|
||||
api_host: posthogConfig.host,
|
||||
ui_host: posthogConfig.uiHost,
|
||||
disable_session_recording: true,
|
||||
capture_pageview: false,
|
||||
capture_dead_clicks: true,
|
||||
// Feature flags should work regardless of telemetry opt-out
|
||||
advanced_disable_decide: false,
|
||||
// Autocapture should respect telemetry settings
|
||||
autocapture: false,
|
||||
})
|
||||
setIsActive(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (temporaryDisabled || distinctId.length === 0 || version.length === 0) {
|
||||
if (!isTelemetryEnabled || !isActive || !distinctId || !version) {
|
||||
return
|
||||
}
|
||||
|
||||
posthog.set_config({
|
||||
before_send: (payload: any) => {
|
||||
before_send: (payload) => {
|
||||
// Only filter out events if telemetry is disabled, but allow feature flag requests
|
||||
if (!isTelemetryEnabled && payload?.event !== "$feature_flag_called") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (payload?.properties) {
|
||||
payload.properties.extension_version = version
|
||||
payload.properties.distinct_id = distinctId
|
||||
@@ -43,14 +53,20 @@ export function CustomPostHogProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const optedIn = posthog.has_opted_in_capturing()
|
||||
const optedOut = posthog.has_opted_out_capturing()
|
||||
|
||||
const args = {
|
||||
email: userInfo?.email,
|
||||
name: userInfo?.displayName,
|
||||
}
|
||||
if (isTelemetryEnabled && !optedIn) {
|
||||
posthog.opt_in_capturing()
|
||||
posthog.identify(distinctId)
|
||||
posthog.identify(distinctId, args)
|
||||
} else if (!isTelemetryEnabled && !optedOut) {
|
||||
// For feature flags to work, we need to identify the user even when telemetry is disabled
|
||||
posthog.identify(distinctId, args)
|
||||
// Then opt out of capturing other events
|
||||
posthog.opt_out_capturing()
|
||||
}
|
||||
}, [isTelemetryEnabled, distinctId, version])
|
||||
}, [isActive, isTelemetryEnabled, distinctId, version])
|
||||
|
||||
return <PostHogProvider client={posthog}>{children}</PostHogProvider>
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user