mirror of
https://github.com/cline/cline.git
synced 2026-09-07 22:16:30 +08:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e45137c24 | ||
|
|
03ab2968a6 | ||
|
|
a0d52d4d59 | ||
|
|
75fbeb4aad | ||
|
|
70db6bde34 | ||
|
|
02c2601e0e | ||
|
|
94692b5091 | ||
|
|
a2794c680f | ||
|
|
6d3f8e1d5d | ||
|
|
3e5847890b | ||
|
|
0eab54ab12 | ||
|
|
7fa0a4924b | ||
|
|
8680218e0e | ||
|
|
8787ab35b9 | ||
|
|
6ed3944f04 | ||
|
|
1dd8e763d1 | ||
|
|
28e6297769 | ||
|
|
5a2a5d1c0a | ||
|
|
113039a259 | ||
|
|
4023c18257 | ||
|
|
7c95b53892 | ||
|
|
9fd2b99be4 | ||
|
|
7c782abaf4 | ||
|
|
3a14a88f4f | ||
|
|
28d60a83a4 | ||
|
|
ae6468b161 | ||
|
|
af93e31862 | ||
|
|
ca154eb8f5 | ||
|
|
1d8497c6bf | ||
|
|
5871fd02b1 | ||
|
|
2d81c310d2 | ||
|
|
0c691f72d2 | ||
|
|
a8409137b3 | ||
|
|
34a21b8e26 | ||
|
|
eb9f53edf4 | ||
|
|
c60f18d907 | ||
|
|
36c68a6ab9 | ||
|
|
80dfce0f60 | ||
|
|
955ae2f62f | ||
|
|
8cb0c6d236 |
@@ -0,0 +1,4 @@
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add missing smoke evaluation npm scripts so documented commands like `npm run eval:smoke:run` work from the repository root.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Storage Architecture
|
||||
|
||||
Global settings, secrets and workspace state are stored in **file-backed JSON stores** under `~/.cline/data/`. This is the shared storage layer used by VSCode, CLI, and JetBrains.
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
### `StorageContext` (src/shared/storage/storage-context.ts)
|
||||
The entry point. Created via `createStorageContext()` and passed to `StateManager.initialize()`. Contains three `ClineFileStorage` instances:
|
||||
- `globalState` → `~/.cline/data/globalState.json`
|
||||
- `secrets` → `~/.cline/data/secrets.json` (mode 0o600)
|
||||
- `workspaceState` → `~/.cline/data/workspaces/<hash>/workspaceState.json`
|
||||
|
||||
### `ClineFileStorage` (src/shared/storage/ClineFileStorage.ts)
|
||||
Synchronous JSON key-value store backed by a single file. Supports `get()`, `set()`, `setBatch()`, `delete()`. Writes are atomic (write-then-rename).
|
||||
|
||||
### `StateManager` (src/core/storage/StateManager.ts)
|
||||
In-memory cache on top of `StorageContext`. All runtime reads hit the cache; writes update cache immediately and debounce-flush to disk.
|
||||
|
||||
## ⚠️ Do NOT Use VSCode's ExtensionContext for Storage
|
||||
|
||||
**Do not** read from or write to `context.globalState`, `context.workspaceState`, or `context.secrets` for persistent data. These are VSCode-specific and not available on CLI or JetBrains.
|
||||
|
||||
Instead, use:
|
||||
```typescript
|
||||
// Reading state
|
||||
StateManager.get().getGlobalStateKey("myKey")
|
||||
StateManager.get().getSecretKey("mySecretKey")
|
||||
StateManager.get().getWorkspaceStateKey("myWsKey")
|
||||
|
||||
// Writing state
|
||||
StateManager.get().setGlobalState("myKey", value)
|
||||
StateManager.get().setSecret("mySecretKey", value)
|
||||
StateManager.get().setWorkspaceState("myWsKey", value)
|
||||
```
|
||||
|
||||
Remember that your data may be read by a different client than the one that wrote it. For example, a value written by Cline in JetBrains may be read by Cline CLI.
|
||||
|
||||
## VSCode Migration (src/hosts/vscode/vscode-to-file-migration.ts)
|
||||
|
||||
On VSCode startup, a migration copies data from VSCode's `ExtensionContext` storage into the file-backed stores. This runs in `src/common.ts` before `StateManager.initialize()`.
|
||||
|
||||
- **Sentinel**: `__vscodeMigrationVersion` key in global state and workspace state — prevents re-migration.
|
||||
- **Merge strategy**: File store wins. Existing values are never overwritten.
|
||||
- **Safe downgrade**: VSCode storage is NOT cleared, so older extension versions still work.
|
||||
|
||||
## Adding New Storage Keys
|
||||
|
||||
1. Add to `src/shared/storage/state-keys.ts` (see existing patterns)
|
||||
2. Read/write via `StateManager` (NOT via `context.globalState`)
|
||||
3. If adding a secret, add to `SecretKeys` array in `state-keys.ts`
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
~/.cline/
|
||||
data/
|
||||
globalState.json # Global settings & state
|
||||
secrets.json # API keys (mode 0o600)
|
||||
tasks/
|
||||
taskHistory.json # Task history (separate file)
|
||||
workspaces/
|
||||
<hash>/
|
||||
workspaceState.json # Per-workspace toggles
|
||||
```
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
!endsWith(github.actor, '[bot]')
|
||||
)
|
||||
uses: ./.github/workflows/npm-main.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
confirm_publish: ${{ github.event.inputs.confirm_publish }}
|
||||
|
||||
@@ -49,5 +50,6 @@ jobs:
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_target == 'nightly')
|
||||
)
|
||||
uses: ./.github/workflows/npm-nightly.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
force_publish: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
|
||||
|
||||
@@ -36,7 +36,9 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm install --include=optional
|
||||
|
||||
+10
@@ -51,3 +51,13 @@ test-results
|
||||
|
||||
# Smoke test results (generated)
|
||||
evals/smoke-tests/results/
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tg
|
||||
.next
|
||||
|
||||
target
|
||||
packages-wip
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
[submodule "evals/cline-bench"]
|
||||
path = evals/cline-bench
|
||||
url = https://github.com/cline/cline-bench.git
|
||||
[submodule "sdk-wip"]
|
||||
path = sdk-wip
|
||||
url = https://github.com/cline/sdk-wip.git
|
||||
|
||||
+42
-13
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [3.66.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Gemini-3.1 Pro Preview
|
||||
|
||||
|
||||
## [3.65.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add /skills slash command to CLI for viewing and managing installed skills
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix aggressive context compaction caused by accidental clicks on the context window progress bar silently setting a very low auto-condense threshold
|
||||
- Fix infinite retry loop when write_to_file fails with missing content parameter.
|
||||
- Fixed default claude model
|
||||
|
||||
## [3.64.0]
|
||||
|
||||
### Added
|
||||
- Added sonnet 4.6
|
||||
|
||||
|
||||
## [3.63.0]
|
||||
|
||||
### Added
|
||||
@@ -13,6 +38,7 @@
|
||||
## [3.62.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Banners now display immediately when opening the extension instead of requiring user interaction first
|
||||
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
|
||||
|
||||
@@ -27,11 +53,12 @@
|
||||
## [3.59.0]
|
||||
|
||||
- Added Minimax 2.5 Free Promo
|
||||
- Fixed Response chaining for OpenAI's Responses API
|
||||
- Fixed Response chaining for OpenAI's Responses API
|
||||
|
||||
## [3.58.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Subagent: replace legacy subagents with the native `use_subagents` tool
|
||||
- Bundle `endpoints.json` support so packaged distributions can ship required endpoints out-of-the-box
|
||||
- Amazon Bedrock: support parallel tool calling
|
||||
@@ -42,6 +69,7 @@
|
||||
- ZAI/GLM: add GLM-5
|
||||
|
||||
### Fixed
|
||||
|
||||
- CLI: handle stdin redirection correctly in CI/headless environments
|
||||
- CLI: preserve OAuth callback paths during auth redirects
|
||||
- VS Code Web: generate auth callback URLs via `vscode.env.asExternalUri` (OAuth callback reliability)
|
||||
@@ -53,6 +81,7 @@
|
||||
- CI: increase Windows E2E test timeout to reduce flakiness
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings/model UX: move "reasoning effort" into model configuration and expose it in settings
|
||||
- CLI provider selection: limit provider list to those remotely configured
|
||||
- UI: consolidate ViewHeader component/styling across views
|
||||
@@ -70,7 +99,7 @@
|
||||
### Added
|
||||
|
||||
- Cline CLI 2.0 now available. Install with `npm install -g cline`
|
||||
- Anthopic Opus 4.6
|
||||
- Anthopic Opus 4.6
|
||||
- Minimax-2.1 and Kimi-k2.5 now available for free for a limited time promo
|
||||
- Codex-5.3 through ChatGPT subscription
|
||||
|
||||
@@ -90,23 +119,23 @@
|
||||
|
||||
### Added
|
||||
|
||||
- __CLI authentication:__ Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
|
||||
- __New model:__ Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
|
||||
- __Prompt variant:__ Added Trinity Large prompt variant for improved tool-calling support
|
||||
- __OpenTelemetry:__ Added support for custom headers on metrics and logs endpoints
|
||||
- __Social links:__ Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
|
||||
- **CLI authentication:** Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
|
||||
- **New model:** Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
|
||||
- **Prompt variant:** Added Trinity Large prompt variant for improved tool-calling support
|
||||
- **OpenTelemetry:** Added support for custom headers on metrics and logs endpoints
|
||||
- **Social links:** Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
|
||||
|
||||
### Fixed
|
||||
|
||||
- __LiteLLM:__ Fixed thinking configuration not appearing for reasoning-capable models
|
||||
- __OpenTelemetry:__ Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
|
||||
- __CLI auth:__ Fixed `cline auth` displaying incorrect provider information after configuration
|
||||
- **LiteLLM:** Fixed thinking configuration not appearing for reasoning-capable models
|
||||
- **OpenTelemetry:** Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
|
||||
- **CLI auth:** Fixed `cline auth` displaying incorrect provider information after configuration
|
||||
|
||||
### Changed
|
||||
|
||||
- __Hooks:__ Hook scripts now run from the workspace repository root instead of filesystem root
|
||||
- __Default settings:__ Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
|
||||
- __Settings UI:__ Refreshed feature settings section with collapsible design
|
||||
- **Hooks:** Hook scripts now run from the workspace repository root instead of filesystem root
|
||||
- **Default settings:** Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
|
||||
- **Settings UI:** Refreshed feature settings section with collapsible design
|
||||
|
||||
## [3.55.0]
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We actively patch only the most recent minor release of Cline. Older versions receive fixes at our discretion.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/cline/cline/security/advisories/new) tab.
|
||||
|
||||
The team will send a response indicating the next steps in handling your report. After the initial reply, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
|
||||
When reporting, please include:
|
||||
|
||||
- A short summary of the issue
|
||||
- Steps to reproduce or a proof of concept
|
||||
- Any logs, stack traces, or screenshots that might help us understand the problem
|
||||
|
||||
We acknowledge reports within 48 hours and aim to release a fix or mitigation within 30 days. While we work on a resolution, please keep the details private.
|
||||
|
||||
## Escalation
|
||||
|
||||
If you do not receive an acknowledgement of your report within 5 business days, you may send an email to security@cline.bot.
|
||||
|
||||
Thank you for helping us keep Cline users safe.
|
||||
+22
-2
@@ -1,13 +1,33 @@
|
||||
# cline
|
||||
|
||||
## 2.2.3
|
||||
## 2.4.2
|
||||
|
||||
### Added
|
||||
|
||||
- Gemini-3.1 Pro Preview
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c0020e1: Allows users to enter custom aws region when selecting bedrock as a provider in CLI
|
||||
- VSCode uses shared files for global, workspace and secret state.
|
||||
|
||||
## [2.4.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix infinite retry loop when write_to_file fails with missing content parameter. Provides progressive guidance to the model, escalating from suggestions to hard stops, with context window awareness to break the loop.
|
||||
|
||||
## [2.4.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Adding Anthropic Sonnet 4.6
|
||||
- Allows users to enter custom aws region when selecting bedrock as a provider in CLI
|
||||
- Keep reasoning rows visible when low-stakes tool groups start immediately after reasoning.
|
||||
- Restore reasoning trace visibility in chat and improve the thinking row UX so streamed reasoning is visible, then collapsible after completion.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Banners now display immediately when opening the extension instead of requiring user interaction first
|
||||
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
|
||||
|
||||
## [2.2.2]
|
||||
|
||||
@@ -56,6 +56,8 @@ Run a new task with a prompt.
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
|
||||
|
||||
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-i**, **\--images** *paths...* : Image file paths to include with the task
|
||||
@@ -144,6 +146,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava
|
||||
|
||||
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
|
||||
|
||||
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
|
||||
|
||||
**-m**, **\--model** *model* : Model to use for the task
|
||||
|
||||
**-v**, **\--verbose** : Show verbose output
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.2.3",
|
||||
"version": "2.4.2",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/cli.mjs",
|
||||
"bin": {
|
||||
|
||||
+10
-18
@@ -53,7 +53,6 @@ import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/index.js"
|
||||
import { AuthService } from "@/services/auth/AuthService.js"
|
||||
import { Logger } from "@/shared/services/Logger.js"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import type { Mode } from "@/shared/storage/types"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { ACPDiffViewProvider } from "../acp/ACPDiffViewProvider.js"
|
||||
@@ -177,7 +176,7 @@ export class ClineAgent implements acp.Agent {
|
||||
this.clientCapabilities = params.clientCapabilities
|
||||
this.initializeHostProvider(this.clientCapabilities, connection)
|
||||
await ClineEndpoint.initialize(this.ctx.EXTENSION_DIR)
|
||||
await StateManager.initialize(this.ctx.extensionContext)
|
||||
await StateManager.initialize(this.ctx.storageContext)
|
||||
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
@@ -1007,13 +1006,14 @@ export class ClineAgent implements acp.Agent {
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < AUTH_TIMEOUT_MS) {
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
// Check if auth data has been stored
|
||||
const authData = await secretStorage.get("cline:clineAccountId")
|
||||
const authData = stateManager.getSecretKey("cline:clineAccountId")
|
||||
if (authData) {
|
||||
Logger.debug("[ClineAgent] Authentication successful")
|
||||
|
||||
// Set up the provider configuration for cline
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("actModeApiProvider", "cline")
|
||||
stateManager.setGlobalState("planModeApiProvider", "cline")
|
||||
await stateManager.flushPendingState()
|
||||
@@ -1161,13 +1161,15 @@ export class ClineAgent implements acp.Agent {
|
||||
|
||||
if (currentProvider === "cline") {
|
||||
// For Cline provider, check if we have stored auth data
|
||||
const values = await Promise.all(["clineApiKey", "clineAccountId"].map((key) => secretStorage.get(key)))
|
||||
return values.some(Boolean)
|
||||
return Boolean(
|
||||
stateManager.getSecretKey("clineApiKey") ||
|
||||
stateManager.getSecretKey("clineAccountId") ||
|
||||
stateManager.getSecretKey("cline:clineAccountId"),
|
||||
)
|
||||
}
|
||||
|
||||
// For OpenAI Codex provider, check OAuth credentials
|
||||
if (currentProvider === "openai-codex") {
|
||||
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
|
||||
return await openAiCodexOAuthManager.isAuthenticated()
|
||||
}
|
||||
|
||||
@@ -1178,14 +1180,7 @@ export class ClineAgent implements acp.Agent {
|
||||
}
|
||||
|
||||
const fields = Array.isArray(keyField) ? keyField : [keyField]
|
||||
for (const field of fields) {
|
||||
const value = await secretStorage.get(field)
|
||||
if (value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return fields.some((key) => stateManager.getSecretKey(key))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1201,9 +1196,6 @@ export class ClineAgent implements acp.Agent {
|
||||
Logger.debug("[ClineAgent] Starting OpenAI Codex OAuth flow...")
|
||||
|
||||
try {
|
||||
// Initialize the OAuth manager with extension context
|
||||
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
|
||||
|
||||
// Get the authorization URL and start the callback server
|
||||
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-confi
|
||||
import { useValidProviders } from "../utils/providers"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import {
|
||||
FeaturedModelPicker,
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { ImportView } from "./ImportView"
|
||||
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { CUSTOM_MODEL_ID, getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { getProviderLabel } from "./ProviderPicker"
|
||||
|
||||
@@ -51,6 +52,7 @@ type AuthStep =
|
||||
| "openai_codex_auth"
|
||||
| "bedrock"
|
||||
| "import"
|
||||
| "bedrock_custom"
|
||||
|
||||
interface AuthViewProps {
|
||||
controller: any
|
||||
@@ -76,7 +78,7 @@ const Select: React.FC<{
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
(_, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
@@ -142,7 +144,11 @@ const TextInput: React.FC<{
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color="white">{displayValue || placeholder || ""}</Text>
|
||||
{!displayValue && placeholder ? (
|
||||
<Text color="gray">e.g. {placeholder}</Text>
|
||||
) : (
|
||||
<Text color="white">{displayValue || ""}</Text>
|
||||
)}
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -248,6 +254,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
}, [])
|
||||
|
||||
// Reset provider index when search changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to reset here
|
||||
useEffect(() => {
|
||||
setProviderIndex(0)
|
||||
}, [providerSearch])
|
||||
@@ -273,7 +280,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
return
|
||||
}
|
||||
|
||||
if (authState.user && authState.user.email) {
|
||||
if (authState.user?.email) {
|
||||
// Auth succeeded - save configuration and transition to model selection
|
||||
await applyProviderConfig({ providerId: "cline", controller })
|
||||
setSelectedProvider("cline")
|
||||
@@ -389,6 +396,33 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
[selectedProvider],
|
||||
)
|
||||
|
||||
// Save custom Bedrock ARN configuration with base model for capability detection
|
||||
const saveCustomBedrockConfiguration = useCallback(
|
||||
async (arn: string, baseModelId: string) => {
|
||||
try {
|
||||
if (!bedrockConfig) {
|
||||
throw new Error("Bedrock configuration is missing")
|
||||
}
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: arn,
|
||||
customModelBaseId: baseModelId,
|
||||
controller,
|
||||
})
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
setStep("success")
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
setStep("error")
|
||||
}
|
||||
},
|
||||
[bedrockConfig, controller],
|
||||
)
|
||||
|
||||
const saveConfiguration = useCallback(
|
||||
async (model: string, base: string) => {
|
||||
try {
|
||||
@@ -423,6 +457,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
|
||||
const handleModelIdSubmit = useCallback(
|
||||
(value: string) => {
|
||||
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
|
||||
if (value === CUSTOM_MODEL_ID && selectedProvider === "bedrock") {
|
||||
setStep("bedrock_custom")
|
||||
return
|
||||
}
|
||||
|
||||
if (value.trim()) {
|
||||
setModelId(value)
|
||||
}
|
||||
@@ -530,6 +570,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
// Go back to cline_model if we came from there (Cline provider)
|
||||
if (selectedProvider === "cline") {
|
||||
setStep("cline_model")
|
||||
} else if (selectedProvider === "bedrock") {
|
||||
// Bedrock skips the API key step — go back to Bedrock setup
|
||||
setStep("bedrock")
|
||||
} else {
|
||||
setStep("apikey")
|
||||
}
|
||||
@@ -646,7 +689,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
<Box flexDirection="column">
|
||||
<Text color="white">Model ID</Text>
|
||||
<Text> </Text>
|
||||
<Text color="gray">e.g., claude-sonnet-4-20250514, gpt-4o</Text>
|
||||
<Text color="gray">e.g., claude-sonnet-4-6, gpt-4o</Text>
|
||||
<Text> </Text>
|
||||
<TextInput onChange={setModelId} onSubmit={handleModelIdSubmit} placeholder="model-id" value={modelId} />
|
||||
<Text> </Text>
|
||||
@@ -741,6 +784,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
/>
|
||||
)
|
||||
|
||||
case "bedrock_custom":
|
||||
return (
|
||||
<BedrockCustomModelFlow
|
||||
isActive={step === "bedrock_custom"}
|
||||
onCancel={() => setStep("modelid")}
|
||||
onComplete={(arn, baseModelId) => {
|
||||
setStep("saving")
|
||||
saveCustomBedrockConfiguration(arn, baseModelId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
case "import":
|
||||
if (!importSource) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Bedrock Custom Model Flow component
|
||||
* Two-step flow: ARN/custom model ID input → base model selection for capability detection.
|
||||
* Used by both AuthView (onboarding) and SettingsPanelContent (/settings).
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { getModelList } from "./ModelPicker"
|
||||
import { SearchableList } from "./SearchableList"
|
||||
|
||||
type FlowStep = "arn_input" | "base_model"
|
||||
|
||||
interface BedrockCustomModelFlowProps {
|
||||
/** Whether this component should capture keyboard input */
|
||||
isActive: boolean
|
||||
/** Called when the user completes both steps (ARN + base model selection) */
|
||||
onComplete: (arn: string, baseModelId: string) => void
|
||||
/** Called when the user presses Escape on the first step (ARN input) */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({ isActive, onComplete, onCancel }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [step, setStep] = useState<FlowStep>("arn_input")
|
||||
const [customArn, setCustomArn] = useState("")
|
||||
|
||||
const handleArnSubmit = useCallback(() => {
|
||||
if (customArn.trim()) {
|
||||
setStep("base_model")
|
||||
}
|
||||
}, [customArn])
|
||||
|
||||
const handleBaseModelCancel = useCallback(() => {
|
||||
setStep("arn_input")
|
||||
}, [])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (step === "arn_input") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
handleArnSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setCustomArn((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
setCustomArn((prev) => prev + input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (step === "base_model") {
|
||||
if (key.escape) {
|
||||
handleBaseModelCancel()
|
||||
}
|
||||
// Other input is handled by SearchableList
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isRawModeSupported },
|
||||
)
|
||||
|
||||
if (step === "arn_input") {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Custom Model ID
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter your Application Inference Profile ARN or custom model ID</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
{customArn ? (
|
||||
<Text color="white">{customArn}</Text>
|
||||
) : (
|
||||
<Text color="gray">e.g. arn:aws:bedrock:region:account:application-inference-profile/...</Text>
|
||||
)}
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Enter to continue, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// step === "base_model"
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={COLORS.primaryBlue}>
|
||||
Base Inference Model
|
||||
</Text>
|
||||
<Text color="gray">Select the base model your inference profile uses (for capability detection)</Text>
|
||||
<Box marginTop={1}>
|
||||
<SearchableList
|
||||
isActive={isActive && step === "base_model"}
|
||||
items={getModelList("bedrock").map((id) => ({ id, label: id }))}
|
||||
onSelect={(item) => {
|
||||
onComplete(customArn, item.id)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -150,6 +150,7 @@ import { HighlightedInput } from "./HighlightedInput"
|
||||
import { HistoryPanelContent } from "./HistoryPanelContent"
|
||||
import { providerModels } from "./ModelPicker"
|
||||
import { SettingsPanelContent } from "./SettingsPanelContent"
|
||||
import { SkillsPanelContent } from "./SkillsPanelContent"
|
||||
import { SlashCommandMenu } from "./SlashCommandMenu"
|
||||
import { ThinkingIndicator } from "./ThinkingIndicator"
|
||||
|
||||
@@ -412,6 +413,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
| { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" }
|
||||
| { type: "history" }
|
||||
| { type: "help" }
|
||||
| { type: "skills" }
|
||||
| null
|
||||
>(null)
|
||||
|
||||
@@ -1156,6 +1158,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "skills") {
|
||||
setActivePanel({ type: "skills" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "clear") {
|
||||
clearViewAndResetTask()
|
||||
setSelectedSlashIndex(0)
|
||||
@@ -1545,6 +1555,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
{/* Help panel */}
|
||||
{activePanel?.type === "help" && <HelpPanelContent onClose={() => setActivePanel(null)} />}
|
||||
|
||||
{/* Skills panel */}
|
||||
{activePanel?.type === "skills" && ctrl && (
|
||||
<SkillsPanelContent
|
||||
controller={ctrl}
|
||||
onClose={() => setActivePanel(null)}
|
||||
onUseSkill={(skillPath) => {
|
||||
setActivePanel(null)
|
||||
setTextInput(`@${skillPath} `)
|
||||
setCursorPos(skillPath.length + 2)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Slash command menu - below input (takes priority over file menu) */}
|
||||
{showSlashMenu && !activePanel && (
|
||||
<Box paddingLeft={1} paddingRight={1}>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
|
||||
const isSelected = i === selectedIndex
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={model.id} marginBottom={1}>
|
||||
<Box flexDirection="column" key={`${model.id}-${model.labels[0] || "default"}`} marginBottom={1}>
|
||||
<Box>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>{isSelected ? "❯ " : " "}</Text>
|
||||
<Text bold color={isSelected ? COLORS.primaryBlue : "white"}>
|
||||
@@ -81,7 +81,7 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
|
||||
* Get the maximum valid index for the featured model picker
|
||||
* (includes "Browse all" option if showBrowseAll is true)
|
||||
*/
|
||||
export function getFeaturedModelMaxIndex(showBrowseAll: boolean = true): number {
|
||||
export function getFeaturedModelMaxIndex(showBrowseAll = true): number {
|
||||
const featuredModels = getAllFeaturedModels()
|
||||
return showBrowseAll ? featuredModels.length : featuredModels.length - 1
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ import { COLORS } from "../constants/colors"
|
||||
import { getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
|
||||
import { SearchableList, SearchableListItem } from "./SearchableList"
|
||||
|
||||
// Special ID used to indicate the user wants to enter a custom model ID / ARN
|
||||
export const CUSTOM_MODEL_ID = "__custom__"
|
||||
|
||||
// Map providers to their static model lists and defaults
|
||||
export const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
|
||||
anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId },
|
||||
@@ -169,12 +172,23 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
|
||||
return getModelList(provider)
|
||||
}, [provider, asyncModels])
|
||||
|
||||
// Providers that support custom model IDs (e.g., Bedrock Application Inference Profiles)
|
||||
const supportsCustomModel = provider === "bedrock"
|
||||
|
||||
const items: SearchableListItem[] = useMemo(() => {
|
||||
return modelList.map((modelId) => ({
|
||||
const list = modelList.map((modelId) => ({
|
||||
id: modelId,
|
||||
label: modelId,
|
||||
}))
|
||||
}, [modelList])
|
||||
// Add "Custom" option at the end for providers that support it
|
||||
if (supportsCustomModel) {
|
||||
list.push({
|
||||
id: CUSTOM_MODEL_ID,
|
||||
label: "Custom (ARN / Inference Profile)",
|
||||
})
|
||||
}
|
||||
return list
|
||||
}, [modelList, supportsCustomModel])
|
||||
|
||||
// For providers without a model picker, render nothing
|
||||
if (!hasModelPicker(provider)) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import Spinner from "ink-spinner"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
@@ -29,6 +28,7 @@ import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
|
||||
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
|
||||
import { Checkbox } from "./Checkbox"
|
||||
import {
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
isBrowseAllSelected,
|
||||
} from "./FeaturedModelPicker"
|
||||
import { LanguagePicker } from "./LanguagePicker"
|
||||
import { hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { CUSTOM_MODEL_ID, hasModelPicker, ModelPicker } from "./ModelPicker"
|
||||
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
|
||||
import { OrganizationPicker } from "./OrganizationPicker"
|
||||
import { Panel, PanelTab } from "./Panel"
|
||||
@@ -171,6 +171,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
const [apiKeyValue, setApiKeyValue] = useState("")
|
||||
const [editValue, setEditValue] = useState("")
|
||||
|
||||
// Bedrock custom ARN flow state
|
||||
const [isBedrockCustomFlow, setIsBedrockCustomFlow] = useState(false)
|
||||
|
||||
// Settings state - single object for feature toggles
|
||||
const [features, setFeatures] = useState<Record<FeatureKey, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {}
|
||||
@@ -741,7 +744,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
}
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
controller.task.updateApiHandler(apiConfig, currentMode)
|
||||
}, [controller, stateManager])
|
||||
|
||||
const setReasoningEffortForMode = useCallback(
|
||||
@@ -944,10 +947,56 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
setReasoningEffortForMode,
|
||||
])
|
||||
|
||||
// Handle completion of the Bedrock custom ARN flow (ARN + base model selected)
|
||||
const handleBedrockCustomFlowComplete = useCallback(
|
||||
async (arn: string, baseModelId: string) => {
|
||||
if (!pickingModelKey) return
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
|
||||
// Build a minimal BedrockConfig from current state for applyBedrockConfig
|
||||
const bedrockConfig: BedrockConfig = {
|
||||
awsRegion: apiConfig.awsRegion ?? "us-east-1",
|
||||
awsAuthentication: apiConfig.awsUseProfile ? "profile" : "credentials",
|
||||
awsUseCrossRegionInference: Boolean(apiConfig.awsUseCrossRegionInference),
|
||||
}
|
||||
|
||||
await applyBedrockConfig({
|
||||
bedrockConfig,
|
||||
modelId: arn,
|
||||
customModelBaseId: baseModelId,
|
||||
controller,
|
||||
})
|
||||
|
||||
// Flush pending state to ensure everything is persisted
|
||||
await stateManager.flushPendingState()
|
||||
|
||||
// Rebuild API handler if there's an active task
|
||||
rebuildTaskApi()
|
||||
|
||||
refreshModelIds()
|
||||
setIsBedrockCustomFlow(false)
|
||||
setPickingModelKey(null)
|
||||
|
||||
// If opened from /models command, close the entire settings panel
|
||||
if (initialMode) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[pickingModelKey, stateManager, controller, rebuildTaskApi, refreshModelIds, initialMode, onClose],
|
||||
)
|
||||
|
||||
// Handle model selection from picker
|
||||
const handleModelSelect = useCallback(
|
||||
async (modelId: string) => {
|
||||
if (!pickingModelKey) return
|
||||
|
||||
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
|
||||
if (modelId === CUSTOM_MODEL_ID && provider === "bedrock") {
|
||||
setIsPickingModel(false)
|
||||
setIsBedrockCustomFlow(true)
|
||||
return
|
||||
}
|
||||
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
const actProvider = apiConfig.actModeApiProvider
|
||||
const planProvider = apiConfig.planModeApiProvider || actProvider
|
||||
@@ -996,7 +1045,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const freshApiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...freshApiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
controller.task.updateApiHandler(freshApiConfig, currentMode)
|
||||
}
|
||||
|
||||
refreshModelIds()
|
||||
@@ -1008,7 +1057,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[pickingModelKey, separateModels, stateManager, controller, refreshModelIds, initialMode, onClose],
|
||||
[pickingModelKey, separateModels, stateManager, controller, provider, refreshModelIds, initialMode, onClose],
|
||||
)
|
||||
|
||||
// Handle language selection from picker
|
||||
@@ -1332,6 +1381,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
// Bedrock custom flow - input handled by BedrockCustomModelFlow component
|
||||
if (isBedrockCustomFlow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
if (key.escape) {
|
||||
setIsEditing(false)
|
||||
@@ -1584,6 +1638,20 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Bedrock custom model flow (ARN input + base model selection)
|
||||
if (isBedrockCustomFlow) {
|
||||
return (
|
||||
<BedrockCustomModelFlow
|
||||
isActive={isBedrockCustomFlow}
|
||||
onCancel={() => {
|
||||
setIsBedrockCustomFlow(false)
|
||||
setIsPickingModel(true)
|
||||
}}
|
||||
onComplete={handleBedrockCustomFlowComplete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Account tab - loading state
|
||||
if (currentTab === "account" && isAccountLoading) {
|
||||
return (
|
||||
@@ -1748,6 +1816,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
|
||||
isWaitingForClineAuth ||
|
||||
isShowingOcaEmployeeCheck ||
|
||||
isWaitingForOcaAuth ||
|
||||
isBedrockCustomFlow ||
|
||||
isEditing
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Tests for SkillsPanelContent component
|
||||
*
|
||||
* Tests keyboard interactions and callbacks.
|
||||
* Rendering tests are limited due to ink-testing-library constraints with nested components.
|
||||
*/
|
||||
|
||||
import { render } from "ink-testing-library"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Mock refreshSkills
|
||||
const mockRefreshSkills = vi.fn()
|
||||
vi.mock("@/core/controller/file/refreshSkills", () => ({
|
||||
refreshSkills: () => mockRefreshSkills(),
|
||||
}))
|
||||
|
||||
// Mock toggleSkill
|
||||
const mockToggleSkill = vi.fn()
|
||||
vi.mock("@/core/controller/file/toggleSkill", () => ({
|
||||
toggleSkill: (...args: unknown[]) => mockToggleSkill(...args),
|
||||
}))
|
||||
|
||||
// Mock child_process exec
|
||||
const mockExec = vi.fn()
|
||||
vi.mock("node:child_process", () => ({
|
||||
exec: (...args: unknown[]) => mockExec(...args),
|
||||
}))
|
||||
|
||||
// Mock StdinContext
|
||||
vi.mock("../context/StdinContext", () => ({
|
||||
useStdinContext: () => ({ isRawModeSupported: true }),
|
||||
}))
|
||||
|
||||
import { SkillsPanelContent } from "./SkillsPanelContent"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("SkillsPanelContent", () => {
|
||||
const mockController = {} as any
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUseSkill = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
controller: mockController,
|
||||
onClose: mockOnClose,
|
||||
onUseSkill: mockOnUseSkill,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [],
|
||||
localSkills: [],
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyboard interactions", () => {
|
||||
it("should call onClose when Escape is pressed", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
stdin.write("\x1B") // Escape
|
||||
await delay()
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should call onUseSkill with skill path when Enter is pressed on a skill", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
await delay()
|
||||
|
||||
expect(mockOnUseSkill).toHaveBeenCalledWith("/test/path/SKILL.md")
|
||||
})
|
||||
|
||||
it("should call toggleSkill when Space is pressed on a skill", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
stdin.write(" ") // Space
|
||||
await delay()
|
||||
|
||||
expect(mockToggleSkill).toHaveBeenCalledWith(
|
||||
mockController,
|
||||
expect.objectContaining({
|
||||
skillPath: "/test/path/SKILL.md",
|
||||
isGlobal: true,
|
||||
enabled: false, // toggled from true to false
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should open marketplace URL when Enter is pressed on marketplace item", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "skill", description: "desc", path: "/path", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
// Navigate down to marketplace (past the one skill)
|
||||
stdin.write("\x1B[B") // Down arrow
|
||||
await delay()
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
await delay()
|
||||
|
||||
// Should have called exec with open command
|
||||
expect(mockExec).toHaveBeenCalled()
|
||||
const execCall = mockExec.mock.calls[0][0]
|
||||
expect(execCall).toContain("https://skills.sh/")
|
||||
})
|
||||
|
||||
it("should navigate through skills with arrow keys", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [
|
||||
{ name: "skill-1", description: "First", path: "/path1", enabled: true },
|
||||
{ name: "skill-2", description: "Second", path: "/path2", enabled: true },
|
||||
],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
// Navigate down
|
||||
stdin.write("\x1B[B") // Down arrow
|
||||
await delay()
|
||||
|
||||
// Press Enter - should use second skill
|
||||
stdin.write("\r")
|
||||
await delay()
|
||||
|
||||
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
|
||||
})
|
||||
|
||||
it("should navigate with vim keys (j/k)", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [
|
||||
{ name: "skill-1", description: "First", path: "/path1", enabled: true },
|
||||
{ name: "skill-2", description: "Second", path: "/path2", enabled: true },
|
||||
],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
// Navigate down with j
|
||||
stdin.write("j")
|
||||
await delay()
|
||||
|
||||
// Press Enter - should use second skill
|
||||
stdin.write("\r")
|
||||
await delay()
|
||||
|
||||
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
|
||||
})
|
||||
|
||||
it("should revert optimistic toggle on failure", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
mockToggleSkill.mockRejectedValueOnce(new Error("toggle failed"))
|
||||
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
stdin.write(" ") // Space to toggle
|
||||
await delay(100)
|
||||
|
||||
// toggleSkill was called with enabled: false (toggled from true)
|
||||
expect(mockToggleSkill).toHaveBeenCalledWith(mockController, expect.objectContaining({ enabled: false }))
|
||||
const frame = lastFrame() || ""
|
||||
expect(frame).toContain("● test-skill")
|
||||
expect(frame).not.toContain("○ test-skill")
|
||||
})
|
||||
|
||||
it("should wrap navigation at list boundaries", async () => {
|
||||
mockRefreshSkills.mockResolvedValue({
|
||||
globalSkills: [{ name: "only-skill", description: "Only", path: "/only", enabled: true }],
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
// Navigate up from first item (should wrap to last - marketplace)
|
||||
stdin.write("\x1B[A") // Up arrow
|
||||
await delay()
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
await delay()
|
||||
|
||||
// Should have opened marketplace (wrapped to last item)
|
||||
expect(mockExec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("skill loading", () => {
|
||||
it("should call refreshSkills on mount", async () => {
|
||||
render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
|
||||
expect(mockRefreshSkills).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Skills panel content for inline display in ChatView
|
||||
* Shows installed skills with toggle and use functionality
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process"
|
||||
import os from "node:os"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { refreshSkills } from "@/core/controller/file/refreshSkills"
|
||||
import { toggleSkill } from "@/core/controller/file/toggleSkill"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
const SKILLS_MARKETPLACE_URL = "https://skills.sh/"
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SkillsPanelContentProps {
|
||||
controller: Controller
|
||||
onClose: () => void
|
||||
onUseSkill: (skillPath: string) => void
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 8
|
||||
|
||||
export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controller, onClose, onUseSkill }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
|
||||
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Load skills on mount
|
||||
useEffect(() => {
|
||||
const loadSkills = async () => {
|
||||
try {
|
||||
const skillsData = await refreshSkills(controller)
|
||||
setGlobalSkills(skillsData.globalSkills || [])
|
||||
setLocalSkills(skillsData.localSkills || [])
|
||||
} catch (_error) {
|
||||
// Skills loading failed, show empty state
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadSkills()
|
||||
}, [controller])
|
||||
|
||||
// Build flat list of skills with source info (global first, then local, alphabetical within each)
|
||||
const skillEntries = useMemo(() => {
|
||||
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
|
||||
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
|
||||
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
|
||||
return entries.sort((a, b) => {
|
||||
if (a.isGlobal !== b.isGlobal) return a.isGlobal ? -1 : 1
|
||||
return a.skill.name.localeCompare(b.skill.name)
|
||||
})
|
||||
}, [globalSkills, localSkills])
|
||||
|
||||
// Handle toggle
|
||||
const handleToggle = useCallback(async () => {
|
||||
const entry = skillEntries[selectedIndex]
|
||||
if (!entry) return
|
||||
|
||||
const newEnabled = !entry.skill.enabled
|
||||
const setter = entry.isGlobal ? setGlobalSkills : setLocalSkills
|
||||
const update = (enabled: boolean) =>
|
||||
setter((prev) => prev.map((s) => (s.path === entry.skill.path ? { ...s, enabled } : s)))
|
||||
|
||||
// Optimistic update
|
||||
update(newEnabled)
|
||||
|
||||
try {
|
||||
await toggleSkill(controller, {
|
||||
metadata: undefined,
|
||||
skillPath: entry.skill.path,
|
||||
isGlobal: entry.isGlobal,
|
||||
enabled: newEnabled,
|
||||
})
|
||||
} catch {
|
||||
// Revert on failure
|
||||
update(!newEnabled)
|
||||
}
|
||||
}, [controller, skillEntries, selectedIndex])
|
||||
|
||||
// Handle use skill (insert @ mention)
|
||||
const handleUse = useCallback(() => {
|
||||
const entry = skillEntries[selectedIndex]
|
||||
if (!entry) return
|
||||
onUseSkill(entry.skill.path)
|
||||
}, [skillEntries, selectedIndex, onUseSkill])
|
||||
|
||||
// Handle opening the marketplace URL
|
||||
const openMarketplace = useCallback(() => {
|
||||
const platform = os.platform()
|
||||
let command: string
|
||||
if (platform === "darwin") {
|
||||
command = `open "${SKILLS_MARKETPLACE_URL}"`
|
||||
} else if (platform === "win32") {
|
||||
command = `start "${SKILLS_MARKETPLACE_URL}"`
|
||||
} else {
|
||||
command = `xdg-open "${SKILLS_MARKETPLACE_URL}"`
|
||||
}
|
||||
exec(command, (err) => {
|
||||
if (err) {
|
||||
// Fallback: show URL in terminal if browser open fails
|
||||
console.error(`Visit: ${SKILLS_MARKETPLACE_URL}`)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Total items = skills + 1 for marketplace link
|
||||
const totalItems = skillEntries.length + 1
|
||||
const isMarketplaceSelected = selectedIndex === skillEntries.length
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
// Navigation
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((i) => (i < totalItems - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
|
||||
// Actions
|
||||
if (key.return) {
|
||||
if (isMarketplaceSelected) {
|
||||
openMarketplace()
|
||||
} else {
|
||||
handleUse()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (input === " " && !isMarketplaceSelected) {
|
||||
handleToggle()
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
// Scrolling window (includes marketplace row)
|
||||
const halfVisible = Math.floor(MAX_VISIBLE / 2)
|
||||
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, totalItems - MAX_VISIBLE))
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Panel label="Skills">
|
||||
<Text color="gray">Loading skills...</Text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if marketplace row is in visible window
|
||||
const marketplaceIndex = skillEntries.length
|
||||
const showMarketplace = marketplaceIndex >= startIndex && marketplaceIndex < startIndex + MAX_VISIBLE
|
||||
|
||||
return (
|
||||
<Panel label="Skills">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{skillEntries.length === 0 ? (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="gray">No skills installed.</Text>
|
||||
<Text>
|
||||
Install skills with: <Text color="white">npx skills add owner/repo</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{skillEntries
|
||||
.slice(startIndex, Math.min(startIndex + MAX_VISIBLE, skillEntries.length))
|
||||
.map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = skillEntries[actualIndex - 1]
|
||||
const showHeader = actualIndex === 0 || (prevEntry && prevEntry.isGlobal !== entry.isGlobal)
|
||||
|
||||
return (
|
||||
<React.Fragment key={entry.skill.path}>
|
||||
{showHeader && (
|
||||
<Box marginTop={actualIndex > 0 ? 1 : 0}>
|
||||
<Text bold color="gray">
|
||||
{entry.isGlobal ? "Global Skills:" : "Workspace Skills:"}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Marketplace link - selectable */}
|
||||
{showMarketplace && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={isMarketplaceSelected ? "cyan" : undefined}>
|
||||
{isMarketplaceSelected ? "❯ " : " "}
|
||||
<Text color={COLORS.primaryBlue}>Browse more skills at https://skills.sh/</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">
|
||||
↑/↓ Navigate • Enter {isMarketplaceSelected ? "Open" : "Use"}
|
||||
{!isMarketplaceSelected && " • Space Toggle"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const SkillRow: React.FC<{ skill: SkillInfo; isSelected: boolean }> = ({ skill, isSelected }) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{skill.name}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
{skill.description && (
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray">
|
||||
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -12,23 +12,29 @@ export interface FeaturedModel {
|
||||
|
||||
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
|
||||
recommended: [
|
||||
{
|
||||
id: "google/gemini-3.1-pro-preview",
|
||||
name: "Gemini 3.1 Pro Preview",
|
||||
description: "Latest Gemini release with 1m ctx window and strong coding performance",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
description: "Latest Sonnet release with strong coding and agent performance",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
name: "Claude Opus 4.6",
|
||||
description: "State-of-the-art for complex coding",
|
||||
description: "Most intelligent model for agents and coding",
|
||||
labels: ["BEST"],
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2-codex",
|
||||
name: "GPT 5.2 Codex",
|
||||
description: "OpenAI's latest with strong coding abilities",
|
||||
labels: ["NEW"],
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro",
|
||||
description: "1M context window for large codebases",
|
||||
labels: ["TRENDING"],
|
||||
labels: ["HOT"],
|
||||
},
|
||||
],
|
||||
free: [
|
||||
|
||||
+9
-40
@@ -8,12 +8,11 @@ import { Command } from "commander"
|
||||
import { render } from "ink"
|
||||
import React from "react"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
|
||||
import { ErrorService } from "@/services/error/ErrorService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
@@ -74,24 +73,7 @@ async function disposeTelemetryServices(): Promise<void> {
|
||||
await Promise.allSettled([telemetryService.dispose(), PostHogClientProvider.getInstance().dispose()])
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore yoloModeToggled to its original value from before this CLI session.
|
||||
* This ensures the --yolo flag is session-only and doesn't leak into future runs.
|
||||
* Must be called before flushPendingState so the restored value gets persisted.
|
||||
*/
|
||||
function restoreYoloState(): void {
|
||||
if (savedYoloModeToggled !== null) {
|
||||
try {
|
||||
StateManager.get().setGlobalState("yoloModeToggled", savedYoloModeToggled)
|
||||
savedYoloModeToggled = null
|
||||
} catch {
|
||||
// StateManager may not be initialized (e.g., early exit before init)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disposeCliContext(ctx: CliContext): Promise<void> {
|
||||
restoreYoloState()
|
||||
await ctx.controller.stateManager.flushPendingState()
|
||||
await ctx.controller.dispose()
|
||||
await ErrorService.get().dispose()
|
||||
@@ -204,12 +186,10 @@ function applyTaskOptions(options: TaskOptions): void {
|
||||
telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes))
|
||||
}
|
||||
|
||||
// Override yolo mode only if --yolo flag is explicitly passed.
|
||||
// The original value is saved in initializeCli and restored on exit.
|
||||
// Set yolo mode as a session-scoped override so AutoApprove picks it up,
|
||||
// but it is never persisted to disk (setSessionOverride never touches pendingGlobalState).
|
||||
if (options.yolo) {
|
||||
const state = StateManager.get()
|
||||
savedYoloModeToggled = state.getGlobalSettingsKey("yoloModeToggled") ?? false
|
||||
state.setGlobalState("yoloModeToggled", true)
|
||||
StateManager.get().setSessionOverride("yoloModeToggled", true)
|
||||
telemetryService.captureHostEvent("yolo_flag", "true")
|
||||
}
|
||||
|
||||
@@ -314,9 +294,6 @@ let activeContext: CliContext | null = null
|
||||
let isShuttingDown = false
|
||||
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
|
||||
let isPlainTextMode = false
|
||||
// Track the original yoloModeToggled value from before this CLI session so we can restore it on exit.
|
||||
// The --yolo flag should only affect the current invocation, not persist across runs.
|
||||
let savedYoloModeToggled: boolean | null = null
|
||||
|
||||
/**
|
||||
* Wait for stdout to fully drain before exiting.
|
||||
@@ -358,10 +335,6 @@ function setupSignalHandlers() {
|
||||
printWarning(`${signal} received, shutting down...`)
|
||||
|
||||
try {
|
||||
// Restore yolo state before any cleanup - this is idempotent and safe
|
||||
// even if disposeCliContext also calls it (restoreYoloState checks savedYoloModeToggled !== null)
|
||||
restoreYoloState()
|
||||
|
||||
if (activeContext) {
|
||||
const task = activeContext.controller.task
|
||||
if (task) {
|
||||
@@ -425,7 +398,7 @@ interface InitOptions {
|
||||
*/
|
||||
async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
const workspacePath = options.cwd || process.cwd()
|
||||
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
const { extensionContext, storageContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: workspacePath,
|
||||
})
|
||||
@@ -466,13 +439,9 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
DATA_DIR,
|
||||
)
|
||||
|
||||
await StateManager.initialize(extensionContext as any)
|
||||
|
||||
await StateManager.initialize(storageContext)
|
||||
await ErrorService.initialize()
|
||||
|
||||
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
|
||||
openAiCodexOAuthManager.initialize(extensionContext)
|
||||
|
||||
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
|
||||
const controller = webview.controller
|
||||
|
||||
@@ -754,7 +723,7 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yes/yolo mode (default: 600)")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
@@ -792,7 +761,7 @@ program
|
||||
.description("Authenticate a provider and configure what model is used")
|
||||
.option("-p, --provider <id>", "Provider ID for quick setup (e.g., openai-native, anthropic, moonshot)")
|
||||
.option("-k, --apikey <key>", "API key for the provider")
|
||||
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929, kimi-k2.5)")
|
||||
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-6, kimi-k2.5)")
|
||||
.option("-b, --baseurl <url>", "Base URL (optional, only for openai provider)")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory for the task")
|
||||
@@ -983,7 +952,7 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
|
||||
.option("-t, --timeout <seconds>", "Timeout in seconds for yolo mode (default: 600)")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface PlainTextTaskOptions {
|
||||
imageDataUrls?: string[]
|
||||
verbose?: boolean
|
||||
jsonOutput?: boolean
|
||||
/** Timeout in seconds (default: 600 = 10 minutes) */
|
||||
/** Timeout in seconds (only applied when explicitly provided) */
|
||||
timeoutSeconds?: number
|
||||
/** Task ID to resume an existing task */
|
||||
taskId?: string
|
||||
@@ -153,10 +153,14 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
|
||||
throw new Error("Either taskId or prompt must be provided")
|
||||
}
|
||||
|
||||
// Normal mode: wait for task completion
|
||||
const timeoutMs = (options.timeoutSeconds ?? 600) * 1000 // default 10 minutes
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
|
||||
await Promise.race([completionPromise, timeoutPromise])
|
||||
// Wait for task completion, with optional timeout only when explicitly configured
|
||||
if (options.timeoutSeconds) {
|
||||
const timeoutMs = options.timeoutSeconds * 1000
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
|
||||
await Promise.race([completionPromise, timeoutPromise])
|
||||
} else {
|
||||
await completionPromise
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error)
|
||||
if (jsonOutput) {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@shared/storage"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { BedrockConfig } from "../components/BedrockSetup"
|
||||
@@ -73,22 +72,25 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions):
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
controller.task.updateApiHandler(apiConfig, currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApplyBedrockConfigOptions {
|
||||
bedrockConfig: BedrockConfig
|
||||
modelId?: string
|
||||
customModelBaseId?: string // Base model ID for custom ARN/Inference Profile (for capability detection)
|
||||
controller?: Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Bedrock provider configuration to state
|
||||
* Handles AWS-specific fields (authentication, region, credentials)
|
||||
* When customModelBaseId is provided, sets the custom model flags so the system
|
||||
* knows to use the ARN as the model ID and the base model for capability detection.
|
||||
*/
|
||||
export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Promise<void> {
|
||||
const { bedrockConfig, modelId, controller } = options
|
||||
const { bedrockConfig, modelId, customModelBaseId, controller } = options
|
||||
const stateManager = StateManager.get()
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
@@ -108,6 +110,18 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr
|
||||
if (planModelKey) config[planModelKey] = finalModelId
|
||||
}
|
||||
|
||||
// Handle custom model (Application Inference Profile ARN)
|
||||
if (customModelBaseId) {
|
||||
config.actModeAwsBedrockCustomSelected = true
|
||||
config.planModeAwsBedrockCustomSelected = true
|
||||
config.actModeAwsBedrockCustomModelBaseId = customModelBaseId
|
||||
config.planModeAwsBedrockCustomModelBaseId = customModelBaseId
|
||||
} else {
|
||||
// Ensure custom flags are cleared when using a standard model
|
||||
config.actModeAwsBedrockCustomSelected = false
|
||||
config.planModeAwsBedrockCustomSelected = false
|
||||
}
|
||||
|
||||
// Add optional AWS credentials
|
||||
if (bedrockConfig.awsProfile !== undefined) config.awsProfile = bedrockConfig.awsProfile
|
||||
if (bedrockConfig.awsAccessKey) config.awsAccessKey = bedrockConfig.awsAccessKey
|
||||
@@ -122,6 +136,6 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr
|
||||
if (controller?.task) {
|
||||
const currentMode = stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = stateManager.getApiConfiguration()
|
||||
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
|
||||
controller.task.updateApiHandler(apiConfig, currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
+56
-92
@@ -1,23 +1,21 @@
|
||||
/**
|
||||
* VSCode context stub for CLI mode
|
||||
* Provides mock implementations of VSCode extension context
|
||||
* Provides mock implementations of VSCode extension context.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { ClineFileStorage } from "@/shared/storage"
|
||||
import type { ClineMemento } from "@/shared/storage/ClineStorage"
|
||||
import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context"
|
||||
import { EnvironmentVariableCollection, ExtensionKind, ExtensionMode, readJson, URI } from "./vscode-shim"
|
||||
|
||||
// ES module equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
/**
|
||||
* CLI-specific state overrides.
|
||||
* These values are always returned regardless of what's stored,
|
||||
@@ -35,33 +33,43 @@ const CLI_STATE_OVERRIDES: Record<string, any> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based Memento store with optional key overrides.
|
||||
* Implements VSCode's Memento interface using SyncJsonFileStorage.
|
||||
* Memento adapter that wraps a ClineFileStorage with optional key overrides.
|
||||
* Used for globalState where CLI needs to inject hardcoded overrides.
|
||||
*/
|
||||
class MementoStore extends ClineFileStorage {
|
||||
private overrides: Record<string, any>
|
||||
class MementoAdapter implements ClineMemento {
|
||||
constructor(
|
||||
private readonly store: ClineMemento,
|
||||
private readonly overrides: Record<string, any> = {},
|
||||
) {}
|
||||
|
||||
constructor(filePath: string, overrides: Record<string, any> = {}) {
|
||||
super(filePath, "MementoStore")
|
||||
this.overrides = overrides
|
||||
}
|
||||
|
||||
// VSCode Memento interface - override base class get() with overload support
|
||||
override get<T>(key: string): T | undefined
|
||||
override get<T>(key: string, defaultValue: T): T
|
||||
override get<T>(key: string, defaultValue?: T): T | undefined {
|
||||
get<T>(key: string): T | undefined
|
||||
get<T>(key: string, defaultValue: T): T
|
||||
get<T>(key: string, defaultValue?: T): T | undefined {
|
||||
if (key in this.overrides) {
|
||||
return this.overrides[key] as T
|
||||
}
|
||||
const value = super.get<T>(key)
|
||||
const value = this.store.get<T>(key)
|
||||
return value !== undefined ? value : defaultValue
|
||||
}
|
||||
|
||||
override async update(key: string, value: any): Promise<void> {
|
||||
if (key in this.overrides) {
|
||||
return
|
||||
update(key: string, value: any): Thenable<void> {
|
||||
return this.setBatch({ [key]: value })
|
||||
}
|
||||
|
||||
keys(): readonly string[] {
|
||||
return this.store.keys()
|
||||
}
|
||||
|
||||
setBatch(entries: Record<string, any>): Thenable<void> {
|
||||
// Filter out overridden keys and delegate to underlying store
|
||||
const filteredEntries: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!(key in this.overrides)) {
|
||||
filteredEntries[key] = value
|
||||
}
|
||||
}
|
||||
this.set(key, value)
|
||||
this.store.setBatch(filteredEntries)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
setKeysForSync(_keys: readonly string[]): void {
|
||||
@@ -69,81 +77,45 @@ class MementoStore extends ClineFileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based secret storage implementing VSCode's SecretStorage interface.
|
||||
* Uses sync storage internally but exposes async API for VSCode compatibility.
|
||||
*/
|
||||
class SecretStore {
|
||||
private storage: ClineFileStorage<string>
|
||||
private onDidChangeEmitter = {
|
||||
event: () => ({ dispose: () => {} }),
|
||||
fire: (_e: any) => {},
|
||||
dispose: () => {},
|
||||
}
|
||||
|
||||
onDidChange = this.onDidChangeEmitter.event
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.storage = new ClineFileStorage<string>(filePath, "SecretStore")
|
||||
}
|
||||
|
||||
get(key: string): Promise<string | undefined> {
|
||||
return Promise.resolve(this.storage.get(key))
|
||||
}
|
||||
|
||||
store(key: string, value: string): Promise<void> {
|
||||
this.storage.set(key, value)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
this.storage.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
export interface CliContextConfig {
|
||||
clineDir?: string
|
||||
/** The workspace directory being worked in (for hashing into storage path) */
|
||||
/** The workspace directory being worked in (used to compute workspace storage hash) */
|
||||
workspaceDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short hash of a string for use in directory names
|
||||
*/
|
||||
function hashString(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(16).substring(0, 8)
|
||||
}
|
||||
|
||||
export interface CliContextResult {
|
||||
extensionContext: ClineExtensionContext
|
||||
storageContext: StorageContext
|
||||
DATA_DIR: string
|
||||
EXTENSION_DIR: string
|
||||
WORKSPACE_STORAGE_DIR: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VSCode-like context for CLI mode
|
||||
* Initialize the VSCode-like context for CLI mode.
|
||||
*
|
||||
* Creates a shared StorageContext (the single source of truth for all storage)
|
||||
* and wraps it in a ClineExtensionContext shell for legacy APIs that still
|
||||
* expect the VSCode ExtensionContext shape.
|
||||
*/
|
||||
export function initializeCliContext(config: CliContextConfig = {}): CliContextResult {
|
||||
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
|
||||
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
|
||||
|
||||
// Workspace storage should always be under ~/.cline/data/workspaces/<hash>/
|
||||
// where hash is derived from the workspace path to keep workspaces isolated
|
||||
const workspacePath = config.workspaceDir || process.cwd()
|
||||
const workspaceHash = hashString(workspacePath)
|
||||
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspaces", workspaceHash)
|
||||
// Create the shared StorageContext — this owns all ClineFileStorage instances.
|
||||
// CLI, JetBrains, and VSCode all share this same file-backed implementation.
|
||||
let storageContext = createStorageContext({
|
||||
clineDir: CLINE_DIR,
|
||||
workspacePath: config.workspaceDir || process.cwd(),
|
||||
workspaceStorageDir: process.env.WORKSPACE_STORAGE_DIR || undefined,
|
||||
})
|
||||
storageContext = {
|
||||
...storageContext,
|
||||
// Storage — delegates to storageContext stores (with CLI overrides for globalState)
|
||||
globalState: new MementoAdapter(storageContext.globalState, CLI_STATE_OVERRIDES),
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
|
||||
const DATA_DIR = storageContext.dataDir
|
||||
const WORKSPACE_STORAGE_DIR = storageContext.workspaceStoragePath
|
||||
|
||||
// For CLI, extension dir is the package root (one level up from dist/)
|
||||
const EXTENSION_DIR = path.resolve(__dirname, "..")
|
||||
@@ -160,38 +132,30 @@ export function initializeCliContext(config: CliContextConfig = {}): CliContextR
|
||||
extensionKind: ExtensionKind.UI,
|
||||
}
|
||||
|
||||
// Build the ClineExtensionContext shell. All storage delegates to storageContext —
|
||||
// there are NO separate ClineFileStorage instances here.
|
||||
const extensionContext: ClineExtensionContext = {
|
||||
extension: extension,
|
||||
extensionMode: EXTENSION_MODE,
|
||||
|
||||
// Set up KV stores (globalState has CLI-specific overrides)
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json"), CLI_STATE_OVERRIDES),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
|
||||
// Set up URIs
|
||||
// URIs / paths
|
||||
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
|
||||
storagePath: WORKSPACE_STORAGE_DIR,
|
||||
globalStorageUri: URI.file(DATA_DIR),
|
||||
globalStoragePath: DATA_DIR,
|
||||
|
||||
// Logs
|
||||
logUri: URI.file(DATA_DIR),
|
||||
logPath: DATA_DIR,
|
||||
|
||||
extensionUri: URI.file(EXTENSION_DIR),
|
||||
extensionPath: EXTENSION_DIR,
|
||||
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
|
||||
|
||||
subscriptions: [],
|
||||
|
||||
environmentVariableCollection: new EnvironmentVariableCollection() as any,
|
||||
|
||||
// Workspace state
|
||||
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
|
||||
}
|
||||
|
||||
return {
|
||||
extensionContext,
|
||||
storageContext,
|
||||
DATA_DIR,
|
||||
EXTENSION_DIR,
|
||||
WORKSPACE_STORAGE_DIR,
|
||||
|
||||
@@ -24,6 +24,18 @@
|
||||
"useDefineForClassFields": true,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"paths": {
|
||||
"@cline/agents": [
|
||||
"../packages/agents/dist/index.d.ts"
|
||||
],
|
||||
"@cline/agents/*": [
|
||||
"../packages/agents/dist/*"
|
||||
],
|
||||
"@cline/llms": [
|
||||
"../packages/llms/dist/index.d.ts"
|
||||
],
|
||||
"@cline/llms/*": [
|
||||
"../packages/llms/dist/*"
|
||||
],
|
||||
"@/*": [
|
||||
"../src/*"
|
||||
],
|
||||
|
||||
@@ -73,6 +73,8 @@ Cline stores configuration in `~/.cline/data/`:
|
||||
├── data/ # Configuration directory
|
||||
│ ├── globalState.json # Global settings
|
||||
│ ├── secrets.json # API keys (encrypted)
|
||||
│ ├── settings/ # Settings files
|
||||
│ │ └── cline_mcp_settings.json # MCP server configuration
|
||||
│ ├── workspace/ # Workspace-specific state
|
||||
│ └── tasks/ # Task history and data
|
||||
└── log/ # Log files
|
||||
@@ -172,6 +174,46 @@ cline --config ~/.cline-work "review this PR"
|
||||
cline --config ~/.cline-personal "help me with this side project"
|
||||
```
|
||||
|
||||
## MCP Server Configuration
|
||||
|
||||
Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, giving you access to external tools and data sources directly from the terminal. The CLI uses the same MCP configuration format as the VS Code extension.
|
||||
|
||||
### Setting Up MCP Servers
|
||||
|
||||
To configure MCP servers for the CLI, create or edit the settings file at:
|
||||
|
||||
```
|
||||
~/.cline/data/settings/cline_mcp_settings.json
|
||||
```
|
||||
|
||||
The file uses the same JSON format as the VS Code extension:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/server.js"],
|
||||
"env": {
|
||||
"API_KEY": "your_api_key"
|
||||
},
|
||||
"alwaysAllow": ["tool1", "tool2"],
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For the full configuration reference including STDIO and SSE transport types, see [Adding and Configuring MCP Servers](/mcp/adding-and-configuring-servers).
|
||||
|
||||
<Note>
|
||||
The CLI does not yet have a `/mcp` slash command for managing MCP servers interactively. For now, you'll need to edit the `cline_mcp_settings.json` file directly.
|
||||
</Note>
|
||||
|
||||
### Custom Config Directory
|
||||
|
||||
If you use the `CLINE_DIR` environment variable or `--config` flag, the MCP settings file will be located at `<your-config-dir>/data/settings/cline_mcp_settings.json` instead.
|
||||
|
||||
## Configuration for Local Providers
|
||||
|
||||
### Ollama
|
||||
|
||||
@@ -207,6 +207,15 @@ Chains multiple Cline invocations together for creative multi-step workflows.
|
||||
| Session summary | ✓ | - |
|
||||
| JSON output | - | `--json` |
|
||||
| Piped input | - | ✓ |
|
||||
| [MCP servers](/cline-cli/configuration#mcp-server-configuration) | ✓ | ✓ |
|
||||
|
||||
## MCP Server Support
|
||||
|
||||
Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, the same extensibility system available in the VS Code extension. MCP servers give Cline access to external tools and data sources, from databases and APIs to browser automation and project management.
|
||||
|
||||
To use MCP servers with the CLI, add your server configuration to `~/.cline/data/settings/cline_mcp_settings.json`. The format is identical to the VS Code extension.
|
||||
|
||||
[Configure MCP servers for the CLI →](/cline-cli/configuration#mcp-server-configuration)
|
||||
|
||||
## Learn More
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ Cline does not come with any pre-installed MCP servers. You'll need to find and
|
||||
|
||||
## Integration with Cline
|
||||
|
||||
MCP servers work with both the **Cline VS Code extension** and the **[Cline CLI](/cline-cli/overview)**. If you use the CLI, see [MCP Server Configuration for the CLI](/cline-cli/configuration#mcp-server-configuration) to get set up.
|
||||
|
||||
Cline simplifies the building and use of MCP servers through its AI capabilities.
|
||||
|
||||
### Building MCP Servers
|
||||
|
||||
@@ -16,8 +16,13 @@ description: "Learn how to configure and use Anthropic Claude models with Cline.
|
||||
|
||||
Cline supports the following Anthropic Claude models:
|
||||
|
||||
#### Claude 4.6 Series
|
||||
- `claude-sonnet-4-6` - Latest Sonnet with extended thinking support
|
||||
- `claude-sonnet-4-6:1m` - 1M context window variant with tiered pricing
|
||||
|
||||
|
||||
#### Claude 4.5 Series
|
||||
- `claude-sonnet-4-5-20250929` (Recommended) - Latest Sonnet with extended thinking support
|
||||
- `claude-sonnet-4-5-20250929` (Recommended) - Stable default Sonnet with reasoning support
|
||||
- `claude-sonnet-4-5-20250929:1m` - 1M context window variant with tiered pricing
|
||||
|
||||
#### Claude 4 Series
|
||||
@@ -56,8 +61,8 @@ Cline users can leverage this by checking the `Enable Extended Thinking` box bel
|
||||
|
||||
**Key Aspects of Extended Thinking:**
|
||||
|
||||
- **Supported Models:** This feature is available for select models, including Claude Opus 4, Claude Sonnet 4.5, and Claude Sonnet 3.7.
|
||||
- **Summarized Thinking (Claude 4):** For Claude 4 and 4.5 models, the API returns a summary of the full thinking process to balance insight with efficiency and prevent misuse. You are billed for the full thinking tokens, not just the summary.
|
||||
- **Supported Models:** This feature is available for select models, including Claude Opus 4, Claude Sonnet 3.7+.
|
||||
- **Summarized Thinking (Claude 3.7+):** For Claude 3.7+ models, the API returns a summary of the full thinking process to balance insight with efficiency and prevent misuse. You are billed for the full thinking tokens, not just the summary.
|
||||
- **Streaming:** Extended thinking responses, including the `thinking` blocks, can be streamed.
|
||||
- **Tool Use & Prompt Caching:** Extended thinking interacts with tool use (requiring thinking blocks to be passed back) and prompt caching (with specific behaviors around cache invalidation and context).
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ Cline supports the following AskSage models (subscription-based pricing):
|
||||
|
||||
#### Claude Models
|
||||
- `claude-4-sonnet` (Default) - Claude 4 Sonnet via AskSage
|
||||
- `claude-4.6-sonnet` - Claude 4.6 Sonnet via AskSage
|
||||
- `claude-4-opus` - Claude 4 Opus via AskSage
|
||||
- `claude-37-sonnet` - Claude 3.7 Sonnet
|
||||
- `claude-35-sonnet` - Claude 3.5 Sonnet
|
||||
|
||||
@@ -16,13 +16,11 @@ Cerebras delivers the world's fastest AI inference through their revolutionary w
|
||||
|
||||
### Supported Models
|
||||
|
||||
Cline supports the following Cerebras models (all currently free):
|
||||
Cline supports the following Cerebras models:
|
||||
|
||||
- `zai-glm-4.7` (Default) - Highly capable general-purpose model (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks (131K context)
|
||||
- `gpt-oss-120b` - OpenAI's intelligent general purpose model with 3,000 tokens/s (128K context)
|
||||
- `qwen-3-235b-a22b-instruct-2507` - Intelligent model with ~1,400 tokens/s (64K context)
|
||||
- `llama-3.3-70b` - Powerful model with ~2,600 tokens/s (64K context)
|
||||
- `qwen-3-32b` - SOTA coding performance with ~2,500 tokens/s (64K context)
|
||||
- `zai-glm-4.7` - Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.
|
||||
- `gpt-oss-120b` - Intelligent general purpose model with 3,000 tokens/s
|
||||
- `qwen-3-235b-a22b-instruct-2507` - Advanced instruction-following model
|
||||
|
||||
### Configuration in Cline
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Google Gemini is Google's family of multimodal AI models, offering some of the l
|
||||
Cline supports the following Google Gemini models:
|
||||
|
||||
#### Gemini 3 Series (Latest)
|
||||
- `gemini-3-pro-preview` (Default) - Latest pro model with 1M context, thinking support, and tiered pricing ($2.00-$4.00/M input)
|
||||
- `gemini-3.1-pro-preview` (Default) - Latest pro model with 1M context, thinking support, and tiered pricing ($2.00-$4.00/M input)
|
||||
- `gemini-3-flash-preview` - Fast model with 1M context and thinking level support ($0.30-$0.50/M input)
|
||||
|
||||
#### Gemini 2.5 Series
|
||||
|
||||
@@ -19,6 +19,11 @@ const aliasResolverPlugin = {
|
||||
name: "alias-resolver",
|
||||
setup(build) {
|
||||
const aliases = {
|
||||
"@cline/agents/extensions": path.resolve(__dirname, "sdk-wip/packages/agents/dist/index.js"),
|
||||
"@cline/agents/hooks": path.resolve(__dirname, "sdk-wip/packages/agents/dist/index.js"),
|
||||
"@cline/agents": path.resolve(__dirname, "sdk-wip/packages/agents/dist"),
|
||||
"@cline/core": path.resolve(__dirname, "sdk-wip/packages/core/dist"),
|
||||
"@cline/llms": path.resolve(__dirname, "sdk-wip/packages/llms/dist"),
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
"@core": path.resolve(__dirname, "src/core"),
|
||||
"@integrations": path.resolve(__dirname, "src/integrations"),
|
||||
|
||||
Generated
+27
-23
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.63.0",
|
||||
"version": "3.66.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.63.0",
|
||||
"version": "3.66.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
"cli"
|
||||
],
|
||||
"dependencies": {
|
||||
@@ -45,8 +46,8 @@
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@sap-ai-sdk/ai-api": "^2.1.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.1.0",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.2.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
@@ -161,7 +162,7 @@
|
||||
},
|
||||
"cli": {
|
||||
"name": "cline",
|
||||
"version": "2.2.1",
|
||||
"version": "2.4.2",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
@@ -4032,7 +4033,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -6114,20 +6114,20 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/ai-api": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.6.0.tgz",
|
||||
"integrity": "sha512-HdQ3T6FSD/jlPnsDKEGsNg+SQONpHBvxfu6OVwW/5nLdlvPmWpysirYvOc0eqj7e+X3zBHGIgoGWKSxIcYOjNg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.7.0.tgz",
|
||||
"integrity": "sha512-dDc2Si5Mu62dVZkJ/f55fBIlbIjVjCKmPi9tt2jWix59o/f9z2Zkw7l7Il+H0RbgANyyQmZsCkXDHRUmzAKKDw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-ai-sdk/core": "^2.6.0",
|
||||
"@sap-ai-sdk/core": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.4.0",
|
||||
"@sap-cloud-sdk/util": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/core": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.6.0.tgz",
|
||||
"integrity": "sha512-HgB2xtjx6iFwAq+a5cEH6rss4sBXAkkW6Vas07OxoZihc7BijjNFm+H19rkEAhBYQmkA415zJG3imHd6/slooQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.7.0.tgz",
|
||||
"integrity": "sha512-NSuCrEFinMR8/EYKJcACh71a1kj1H3pNfpnJH1R4Z+cxnlr0CtiFDzz9hKwsXFtf+ixO6WnIf7xMVmXkoeGNSQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-cloud-sdk/connectivity": "^4.4.0",
|
||||
@@ -6137,25 +6137,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/orchestration": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.6.0.tgz",
|
||||
"integrity": "sha512-FGBZ0oiRbbZdL9UUytsJ7QBiVUmrg4eD/i61aHVeBGvaCvsgSlx7nJM07CAT5JHhJC8ss97+EYPXXzVNPATcyQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.7.0.tgz",
|
||||
"integrity": "sha512-/aCRwb3o5yJu5/8jCDVatMGBIxPO5rKtr0sT+zAl5w3hD/PqAqbFZXNztQ263xK1ooW8YAFite8PpXJgdjnFZg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-ai-sdk/ai-api": "^2.6.0",
|
||||
"@sap-ai-sdk/core": "^2.6.0",
|
||||
"@sap-ai-sdk/prompt-registry": "^2.6.0",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/core": "^2.7.0",
|
||||
"@sap-ai-sdk/prompt-registry": "^2.7.0",
|
||||
"@sap-cloud-sdk/util": "^4.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/prompt-registry": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.6.0.tgz",
|
||||
"integrity": "sha512-vSyBCx437Cba3AsBHf1G0KCldv4dPi60kNgp2rpF/kDZjf3Rga3VwZ6OnETaKtbJJbM8G8itR0USHgviMU8UUg==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.7.0.tgz",
|
||||
"integrity": "sha512-hbfb75CD67dgKl/hLLCXdzhsR80FHFqKCpVUjiULs3KW1Rauxc+i6ZKNRcc+uzdb8kgsdhKizFFCtsz90ay44g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@sap-ai-sdk/core": "^2.6.0",
|
||||
"@sap-ai-sdk/core": "^2.7.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
},
|
||||
@@ -10288,6 +10288,10 @@
|
||||
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/claude-dev": {
|
||||
"resolved": "",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
|
||||
+9
-4
@@ -2,9 +2,10 @@
|
||||
"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.63.0",
|
||||
"version": "3.66.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
".",
|
||||
"cli"
|
||||
],
|
||||
"engines": {
|
||||
@@ -443,7 +444,11 @@
|
||||
"docs:rename-file": "cd docs && npm run rename",
|
||||
"report-issue": "node scripts/report-issue.js",
|
||||
"storybook": "cd webview-ui && npm run storybook",
|
||||
"cli:unlink": "cd cli && npm run unlink"
|
||||
"cli:unlink": "cd cli && npm run unlink",
|
||||
"eval:smoke:build": "npm run cli:build && npm run cli:link",
|
||||
"eval:smoke:run": "npx tsx evals/smoke-tests/run-smoke-tests.ts",
|
||||
"eval:smoke": "npm run eval:smoke:build && npm run eval:smoke:run",
|
||||
"eval:smoke:ci": "npm run eval:smoke:build && npm run eval:smoke:run -- --trials 1 --parallel"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/shared/storage/state-keys.ts": [
|
||||
@@ -535,8 +540,8 @@
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@sap-ai-sdk/ai-api": "^2.1.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.1.0",
|
||||
"@sap-ai-sdk/ai-api": "^2.7.0",
|
||||
"@sap-ai-sdk/orchestration": "^2.7.0",
|
||||
"@sap-cloud-sdk/connectivity": "^4.2.0",
|
||||
"@streamparser/json": "^0.0.22",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
|
||||
@@ -448,6 +448,7 @@ enum ApiFormat {
|
||||
OPENAI_CHAT = 2;
|
||||
R1_CHAT = 3;
|
||||
OPENAI_RESPONSES = 4;
|
||||
OPENAI_RESPONSES_WEBSOCKET_MODE = 5;
|
||||
}
|
||||
|
||||
// Model info for OpenAI-compatible models
|
||||
|
||||
@@ -104,15 +104,13 @@ message Secrets {
|
||||
optional string oca_refresh_token = 42;
|
||||
optional string mcp_o_auth_secrets = 43;
|
||||
optional string cline_api_key = 44;
|
||||
optional string openai_codex_oauth_credentials = 46;
|
||||
optional string openai_codex_oauth_credentials = 48;
|
||||
}
|
||||
|
||||
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
|
||||
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
|
||||
// script to regenerate this list.
|
||||
message Settings {
|
||||
reserved 146; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
|
||||
|
||||
optional string lite_llm_base_url = 1;
|
||||
optional bool lite_llm_use_prompt_cache = 2;
|
||||
optional string anthropic_base_url = 4;
|
||||
@@ -251,7 +249,6 @@ message Settings {
|
||||
optional string default_terminal_profile = 137;
|
||||
optional int32 terminal_output_line_limit = 138;
|
||||
optional int32 max_consecutive_mistakes = 139;
|
||||
optional int32 subagent_terminal_output_line_limit = 140;
|
||||
optional bool strict_plan_mode_enabled = 141;
|
||||
optional bool yolo_mode_toggled = 142;
|
||||
optional bool use_auto_condense = 143;
|
||||
@@ -261,7 +258,6 @@ message Settings {
|
||||
optional DictationSettings dictation_settings = 148;
|
||||
optional FocusChainSettings focus_chain_settings = 149;
|
||||
optional string custom_prompt = 150;
|
||||
optional double auto_condense_threshold = 151;
|
||||
optional bool subagents_enabled = 153;
|
||||
optional bool enable_parallel_tool_calling = 154;
|
||||
optional bool background_edit_enabled = 155;
|
||||
@@ -282,8 +278,8 @@ message Settings {
|
||||
optional int32 open_telemetry_log_max_queue_size = 171;
|
||||
optional bool worktrees_enabled = 172;
|
||||
optional bool auto_approve_all_toggled = 174;
|
||||
map<string, string> open_ai_headers = 175;
|
||||
optional bool double_check_completion_enabled = 176;
|
||||
map<string, string> open_ai_headers = 177;
|
||||
}
|
||||
|
||||
message DictationSettings {
|
||||
@@ -415,7 +411,6 @@ message UpdateSettingsRequest {
|
||||
optional string default_terminal_profile = 21;
|
||||
optional bool yolo_mode_toggled = 22;
|
||||
optional DictationSettings dictation_settings = 23;
|
||||
optional double auto_condense_threshold = 24;
|
||||
optional bool multi_root_enabled = 25;
|
||||
optional string vscode_terminal_execution_mode = 27;
|
||||
optional int32 max_consecutive_mistakes = 28;
|
||||
|
||||
@@ -30,7 +30,7 @@ function toProtoFieldName(str) {
|
||||
const INT64_FIELDS = new Set(["planModeThinkingBudgetTokens", "actModeThinkingBudgetTokens"])
|
||||
|
||||
// Fields that should use double instead of int32
|
||||
const DOUBLE_FIELDS = new Set(["autoCondenseThreshold"])
|
||||
const DOUBLE_FIELDS = new Set()
|
||||
|
||||
/**
|
||||
* Infer proto type from TypeScript type expression
|
||||
@@ -254,7 +254,7 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
|
||||
|
||||
for (const fieldMatch of matches) {
|
||||
const snakeName = fieldMatch[1]
|
||||
const fieldNum = parseInt(fieldMatch[2], 10)
|
||||
const fieldNum = Number.parseInt(fieldMatch[2], 10)
|
||||
const camelName = snakeToCamel(snakeName)
|
||||
fieldNumbers[camelName] = fieldNum
|
||||
}
|
||||
@@ -354,11 +354,10 @@ function replaceMessage(protoContent, messageName, newMessageContent) {
|
||||
|
||||
if (messageRegex.test(protoContent)) {
|
||||
return protoContent.replace(messageRegex, newMessageContent)
|
||||
} else {
|
||||
// Message doesn't exist, append before the first message or at end
|
||||
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
|
||||
return protoContent + "\n\n" + newMessageContent
|
||||
}
|
||||
// Message doesn't exist, append before the first message or at end
|
||||
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
|
||||
return protoContent + "\n\n" + newMessageContent
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -56,6 +56,7 @@ const log = {
|
||||
const config = {
|
||||
// The name and display name for the nightly version
|
||||
nightlyName: "cline-nightly",
|
||||
originalName: "claude-dev",
|
||||
nightlyDisplayName: "Cline (Nightly)",
|
||||
projectRoot: path.join(__dirname, ".."),
|
||||
get packageJsonPath() {
|
||||
@@ -70,6 +71,15 @@ const config = {
|
||||
get vsixPath() {
|
||||
return path.join(this.distDir, "cline-nightly.vsix")
|
||||
},
|
||||
get nodeModulesPath() {
|
||||
return path.join(this.projectRoot, "node_modules")
|
||||
},
|
||||
get originalWorkspaceLinkPath() {
|
||||
return path.join(this.nodeModulesPath, this.originalName)
|
||||
},
|
||||
get nightlyWorkspaceLinkPath() {
|
||||
return path.join(this.nodeModulesPath, this.nightlyName)
|
||||
},
|
||||
}
|
||||
|
||||
// Utility class for managing the publish process
|
||||
@@ -77,6 +87,31 @@ class NightlyPublisher {
|
||||
constructor() {
|
||||
this.originalPackageJson = null
|
||||
this.hasBackup = false
|
||||
this.didRenameWorkspaceLink = false
|
||||
this.didCreateNightlyWorkspaceLink = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve symlink target to an absolute path.
|
||||
*/
|
||||
resolveSymlinkTarget(linkPath) {
|
||||
const target = fs.readlinkSync(linkPath)
|
||||
return path.resolve(path.dirname(linkPath), target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is the expected workspace self-link to project root.
|
||||
*/
|
||||
isExpectedWorkspaceSelfLink(linkPath) {
|
||||
try {
|
||||
if (!fs.lstatSync(linkPath).isSymbolicLink()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.resolveSymlinkTarget(linkPath) === path.resolve(config.projectRoot)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,6 +180,98 @@ class NightlyPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep workspace self-link consistent with package name during nightly packaging.
|
||||
*
|
||||
* The repo root is a workspace package ("."). When npm installs dependencies,
|
||||
* it creates a self-link at node_modules/<package-name>. Nightly packaging
|
||||
* changes package.json name from "claude-dev" to "cline-nightly". If we don't
|
||||
* align this link, vsce's dependency detection (`npm list --production`) fails
|
||||
* with ELSPROBLEMS (missing cline-nightly + extraneous claude-dev).
|
||||
*/
|
||||
reconcileWorkspaceSelfLinkForNightly() {
|
||||
const originalPath = config.originalWorkspaceLinkPath
|
||||
const nightlyPath = config.nightlyWorkspaceLinkPath
|
||||
|
||||
if (!fs.existsSync(config.nodeModulesPath)) {
|
||||
log.warn("node_modules not found, skipping workspace self-link reconciliation")
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(nightlyPath)) {
|
||||
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
|
||||
throw new Error(
|
||||
`Refusing to continue: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
|
||||
)
|
||||
}
|
||||
|
||||
log.info("Nightly workspace self-link already exists")
|
||||
return
|
||||
}
|
||||
|
||||
if (fs.existsSync(originalPath)) {
|
||||
if (!this.isExpectedWorkspaceSelfLink(originalPath)) {
|
||||
throw new Error(
|
||||
`Refusing to continue: unexpected path at ${originalPath}. Expected a workspace symlink to ${config.projectRoot}`,
|
||||
)
|
||||
}
|
||||
|
||||
log.info(`Renaming workspace self-link: ${config.originalName} -> ${config.nightlyName}`)
|
||||
fs.renameSync(originalPath, nightlyPath)
|
||||
this.didRenameWorkspaceLink = true
|
||||
return
|
||||
}
|
||||
|
||||
// In some environments npm may not have created the workspace self-link yet.
|
||||
// Create it explicitly so `npm list --production` can resolve the renamed
|
||||
// package name during vsce dependency detection.
|
||||
log.warn("Original workspace self-link not found, creating nightly workspace self-link")
|
||||
fs.symlinkSync(config.projectRoot, nightlyPath, "dir")
|
||||
|
||||
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
|
||||
throw new Error(`Failed to create expected workspace symlink at ${nightlyPath}`)
|
||||
}
|
||||
|
||||
this.didCreateNightlyWorkspaceLink = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore workspace self-link after packaging.
|
||||
*/
|
||||
restoreWorkspaceSelfLink() {
|
||||
if (!this.didRenameWorkspaceLink && !this.didCreateNightlyWorkspaceLink) {
|
||||
return
|
||||
}
|
||||
|
||||
const originalPath = config.originalWorkspaceLinkPath
|
||||
const nightlyPath = config.nightlyWorkspaceLinkPath
|
||||
|
||||
if (fs.existsSync(nightlyPath) && !fs.existsSync(originalPath)) {
|
||||
if (this.didRenameWorkspaceLink) {
|
||||
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
|
||||
throw new Error(
|
||||
`Refusing to restore: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
|
||||
)
|
||||
}
|
||||
|
||||
log.info(`Restoring workspace self-link: ${config.nightlyName} -> ${config.originalName}`)
|
||||
fs.renameSync(nightlyPath, originalPath)
|
||||
} else if (this.didCreateNightlyWorkspaceLink) {
|
||||
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
|
||||
throw new Error(
|
||||
`Refusing to remove: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
|
||||
)
|
||||
}
|
||||
|
||||
log.info(`Removing temporary workspace self-link: ${config.nightlyName}`)
|
||||
fs.unlinkSync(nightlyPath)
|
||||
}
|
||||
}
|
||||
|
||||
this.didRenameWorkspaceLink = false
|
||||
this.didCreateNightlyWorkspaceLink = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new version with timestamp
|
||||
* Format: major.minor.timestamp
|
||||
@@ -300,6 +427,9 @@ class NightlyPublisher {
|
||||
// Step 3: Update package.json
|
||||
const newVersion = this.updatePackageJson()
|
||||
|
||||
// Step 3.5: Keep npm workspace self-link aligned with nightly package name
|
||||
this.reconcileWorkspaceSelfLinkForNightly()
|
||||
|
||||
// Step 4: Package extension
|
||||
this.packageExtension()
|
||||
|
||||
@@ -326,6 +456,9 @@ class NightlyPublisher {
|
||||
log.error(`Publish failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
// Always restore workspace link first
|
||||
this.restoreWorkspaceSelfLink()
|
||||
|
||||
// Always restore package.json
|
||||
this.restorePackageJson()
|
||||
}
|
||||
@@ -336,17 +469,20 @@ class NightlyPublisher {
|
||||
const publisher = new NightlyPublisher()
|
||||
|
||||
process.on("exit", () => {
|
||||
publisher.restoreWorkspaceSelfLink()
|
||||
publisher.restorePackageJson()
|
||||
})
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
log.info("\nInterrupted, cleaning up...")
|
||||
publisher.restoreWorkspaceSelfLink()
|
||||
publisher.restorePackageJson()
|
||||
process.exit(130)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
log.info("\nTerminated, cleaning up...")
|
||||
publisher.restoreWorkspaceSelfLink()
|
||||
publisher.restorePackageJson()
|
||||
process.exit(143)
|
||||
})
|
||||
|
||||
Submodule
+1
Submodule sdk-wip added at 814a2e42b7
+9
-12
@@ -1,15 +1,14 @@
|
||||
import * as vscode from "vscode"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { StorageContext } from "@/shared/storage/storage-context"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
|
||||
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
|
||||
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
|
||||
import { StateManager } from "./core/storage/StateManager"
|
||||
import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
|
||||
import { ErrorService } from "./services/error"
|
||||
@@ -32,7 +31,7 @@ import { arePathsEqual } from "./utils/path"
|
||||
* @returns The webview provider
|
||||
* @throws ClineConfigurationError if endpoints.json exists but is invalid
|
||||
*/
|
||||
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
|
||||
export async function initialize(storageContext: StorageContext): Promise<WebviewProvider> {
|
||||
// Configure the shared Logging class to use HostProvider's output channels and debug logger
|
||||
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
|
||||
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
|
||||
@@ -44,7 +43,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
await ClineEndpoint.initialize(HostProvider.get().extensionFsPath)
|
||||
|
||||
try {
|
||||
await StateManager.initialize(context)
|
||||
await StateManager.initialize(storageContext)
|
||||
} catch (error) {
|
||||
Logger.error("[Cline] CRITICAL: Failed to initialize StateManager:", error)
|
||||
HostProvider.window.showMessage({
|
||||
@@ -55,8 +54,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
|
||||
// =============== External services ===============
|
||||
await ErrorService.initialize()
|
||||
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
|
||||
openAiCodexOAuthManager.initialize(context)
|
||||
// Initialize PostHog client provider (skip in self-hosted mode)
|
||||
if (!ClineEndpoint.isSelfHosted()) {
|
||||
PostHogClientProvider.getInstance()
|
||||
@@ -67,7 +64,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
// Non-blocking announcement check and display
|
||||
showVersionUpdateAnnouncement(context)
|
||||
showVersionUpdateAnnouncement(stateManager)
|
||||
// Check if this workspace was opened from worktree quick launch
|
||||
await checkWorktreeAutoOpen(stateManager)
|
||||
|
||||
@@ -78,24 +75,24 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
|
||||
ClineTempManager.startPeriodicCleanup()
|
||||
// Clean up orphaned file context warnings (startup cleanup)
|
||||
FileContextTracker.cleanupOrphanedWarnings(context)
|
||||
FileContextTracker.cleanupOrphanedWarnings(stateManager)
|
||||
|
||||
telemetryService.captureExtensionActivated()
|
||||
|
||||
return webview
|
||||
}
|
||||
|
||||
async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
|
||||
async function showVersionUpdateAnnouncement(stateManager: StateManager) {
|
||||
// Version checking for autoupdate notification
|
||||
const currentVersion = ExtensionRegistryInfo.version
|
||||
const previousVersion = context.globalState.get<string>("clineVersion")
|
||||
const previousVersion = stateManager.getGlobalStateKey("clineVersion")
|
||||
// Perform post-update actions if necessary
|
||||
try {
|
||||
if (!previousVersion || currentVersion !== previousVersion) {
|
||||
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
|
||||
|
||||
// Check if there's a new announcement to show
|
||||
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
|
||||
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
|
||||
const latestAnnouncementId = getLatestAnnouncementId()
|
||||
|
||||
if (lastShownAnnouncementId !== latestAnnouncementId) {
|
||||
@@ -109,7 +106,7 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
|
||||
})
|
||||
}
|
||||
// Always update the main version tracker for the next launch.
|
||||
await context.globalState.update("clineVersion", currentVersion)
|
||||
await stateManager.setGlobalState("clineVersion", currentVersion)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { type ApiHandler, buildApiHandler } from "@core/api"
|
||||
import type {
|
||||
ApiStream,
|
||||
ApiStreamChunk,
|
||||
ApiStreamTextChunk,
|
||||
ApiStreamThinkingChunk,
|
||||
ApiStreamToolCallsChunk,
|
||||
ApiStreamUsageChunk,
|
||||
} from "@core/api/transform/stream"
|
||||
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
|
||||
import { isNextGenModelFamily } from "@utils/model-utils"
|
||||
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
|
||||
import type { ApiConfiguration } from "@/shared/api"
|
||||
import type { Mode } from "@/shared/storage/types"
|
||||
|
||||
export interface InitialStreamRetryDecision {
|
||||
isAuthError: boolean
|
||||
isBalanceError: boolean
|
||||
shouldRetry: boolean
|
||||
}
|
||||
|
||||
export type CoreLoopStatus = "continue" | "complete" | "failed"
|
||||
|
||||
export interface CoreLoopTurnResult<TInput, TOutput> {
|
||||
status: CoreLoopStatus
|
||||
nextInput?: TInput
|
||||
output?: TOutput
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CoreLoopParams<TInput, TOutput> {
|
||||
initialInput: TInput
|
||||
runTurn: (input: TInput, iteration: number) => Promise<CoreLoopTurnResult<TInput, TOutput>>
|
||||
shouldAbort?: () => boolean
|
||||
}
|
||||
|
||||
export type CoreLoopResult<TOutput> =
|
||||
| { status: "complete"; output: TOutput }
|
||||
| { status: "failed"; error?: string }
|
||||
| { status: "aborted" }
|
||||
|
||||
export interface CoreStreamUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheWriteTokens: number
|
||||
cacheReadTokens: number
|
||||
totalCost?: number
|
||||
}
|
||||
|
||||
export interface CoreStreamResult {
|
||||
requestId?: string
|
||||
assistantText: string
|
||||
assistantTextSignature?: string
|
||||
usage: CoreStreamUsage
|
||||
aborted: boolean
|
||||
interrupted: boolean
|
||||
}
|
||||
|
||||
export interface CoreStreamParams {
|
||||
stream: ApiStream
|
||||
onUsageChunk?: (chunk: ApiStreamUsageChunk, state: CoreStreamResult) => Promise<void> | void
|
||||
onTextChunk?: (chunk: ApiStreamTextChunk, state: CoreStreamResult) => Promise<void> | void
|
||||
onToolCallChunk?: (chunk: ApiStreamToolCallsChunk, state: CoreStreamResult) => Promise<void> | void
|
||||
onReasoningChunk?: (chunk: ApiStreamThinkingChunk, state: CoreStreamResult) => Promise<void> | void
|
||||
onChunkProcessed?: (chunk: ApiStreamChunk, state: CoreStreamResult) => Promise<"break" | void> | "break" | void
|
||||
shouldAbort?: () => boolean
|
||||
onAbort?: (state: CoreStreamResult) => Promise<void> | void
|
||||
}
|
||||
|
||||
export class CoreAgent {
|
||||
static readonly AUTO_CONDENSE_THRESHOLD = 0.75
|
||||
private apiHandler?: ApiHandler
|
||||
|
||||
constructor(params?: { apiConfiguration: ApiConfiguration; mode: Mode }) {
|
||||
if (params) {
|
||||
this.apiHandler = buildApiHandler(params.apiConfiguration, params.mode)
|
||||
}
|
||||
}
|
||||
|
||||
initializeApiHandler(apiConfiguration: ApiConfiguration, mode: Mode): ApiHandler {
|
||||
this.apiHandler = buildApiHandler(apiConfiguration, mode)
|
||||
return this.apiHandler
|
||||
}
|
||||
|
||||
getApiHandler(): ApiHandler {
|
||||
if (!this.apiHandler) {
|
||||
throw new Error("CoreAgent API handler has not been initialized")
|
||||
}
|
||||
return this.apiHandler
|
||||
}
|
||||
|
||||
getModel(): ReturnType<ApiHandler["getModel"]> {
|
||||
return this.getApiHandler().getModel()
|
||||
}
|
||||
|
||||
createMessage(...args: Parameters<ApiHandler["createMessage"]>): ReturnType<ApiHandler["createMessage"]> {
|
||||
return this.getApiHandler().createMessage(...args)
|
||||
}
|
||||
|
||||
abortCurrentRequest(): void {
|
||||
this.getApiHandler().abort?.()
|
||||
}
|
||||
|
||||
getApiStreamUsage(): ReturnType<NonNullable<ApiHandler["getApiStreamUsage"]>> | undefined {
|
||||
return this.getApiHandler().getApiStreamUsage?.()
|
||||
}
|
||||
|
||||
getLastRequestIdSafe(): string | undefined {
|
||||
const apiLike = this.getApiHandler() as Partial<{
|
||||
getLastRequestId: () => string | undefined
|
||||
lastGenerationId?: string
|
||||
}>
|
||||
return apiLike.getLastRequestId?.() ?? apiLike.lastGenerationId
|
||||
}
|
||||
|
||||
async runLoop<TInput, TOutput>(params: CoreLoopParams<TInput, TOutput>): Promise<CoreLoopResult<TOutput>> {
|
||||
let iteration = 0
|
||||
let currentInput = params.initialInput
|
||||
|
||||
while (!params.shouldAbort?.()) {
|
||||
iteration += 1
|
||||
const turn = await params.runTurn(currentInput, iteration)
|
||||
if (turn.status === "complete") {
|
||||
return { status: "complete", output: turn.output as TOutput }
|
||||
}
|
||||
if (turn.status === "failed") {
|
||||
return { status: "failed", error: turn.error }
|
||||
}
|
||||
currentInput = turn.nextInput as TInput
|
||||
}
|
||||
|
||||
return { status: "aborted" }
|
||||
}
|
||||
|
||||
async consumeStream(params: CoreStreamParams): Promise<CoreStreamResult> {
|
||||
const state: CoreStreamResult = {
|
||||
requestId: undefined,
|
||||
assistantText: "",
|
||||
assistantTextSignature: undefined,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalCost: undefined,
|
||||
},
|
||||
aborted: false,
|
||||
interrupted: false,
|
||||
}
|
||||
|
||||
for await (const chunk of params.stream) {
|
||||
state.requestId = state.requestId ?? chunk.id
|
||||
|
||||
switch (chunk.type) {
|
||||
case "usage":
|
||||
state.usage.inputTokens += chunk.inputTokens
|
||||
state.usage.outputTokens += chunk.outputTokens
|
||||
state.usage.cacheWriteTokens += chunk.cacheWriteTokens ?? 0
|
||||
state.usage.cacheReadTokens += chunk.cacheReadTokens ?? 0
|
||||
state.usage.totalCost = chunk.totalCost ?? state.usage.totalCost
|
||||
await params.onUsageChunk?.(chunk, state)
|
||||
break
|
||||
case "text":
|
||||
state.assistantText += chunk.text || ""
|
||||
state.assistantTextSignature = chunk.signature || state.assistantTextSignature
|
||||
await params.onTextChunk?.(chunk, state)
|
||||
break
|
||||
case "tool_calls":
|
||||
await params.onToolCallChunk?.(chunk, state)
|
||||
break
|
||||
case "reasoning":
|
||||
await params.onReasoningChunk?.(chunk, state)
|
||||
break
|
||||
}
|
||||
|
||||
const shouldBreak = await params.onChunkProcessed?.(chunk, state)
|
||||
if (shouldBreak === "break") {
|
||||
state.interrupted = true
|
||||
break
|
||||
}
|
||||
|
||||
if (params.shouldAbort?.()) {
|
||||
state.aborted = true
|
||||
await params.onAbort?.(state)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
isAutoCondenseEnabledForModel(useAutoCondense: boolean, modelId: string): boolean {
|
||||
return useAutoCondense && isNextGenModelFamily(modelId)
|
||||
}
|
||||
|
||||
getAutoCondenseThresholdTokens(contextWindow: number, maxAllowedSize: number): number {
|
||||
const roundedThreshold = Math.floor(contextWindow * CoreAgent.AUTO_CONDENSE_THRESHOLD)
|
||||
return Math.min(roundedThreshold, maxAllowedSize)
|
||||
}
|
||||
|
||||
shouldCompactBeforeNextRequest(
|
||||
previousRequestTotalTokens: number,
|
||||
api: ApiHandler,
|
||||
modelId: string,
|
||||
useAutoCondense: boolean,
|
||||
): boolean {
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
if (this.isAutoCondenseEnabledForModel(useAutoCondense, modelId)) {
|
||||
const thresholdTokens = this.getAutoCondenseThresholdTokens(contextWindow, maxAllowedSize)
|
||||
return previousRequestTotalTokens >= thresholdTokens
|
||||
}
|
||||
|
||||
return previousRequestTotalTokens >= maxAllowedSize
|
||||
}
|
||||
|
||||
classifyInitialStreamRetry(error: unknown, providerId: string, modelId: string): InitialStreamRetryDecision {
|
||||
const parsedError = ClineError.transform(error, modelId, providerId)
|
||||
const isAuthError = parsedError.isErrorType(ClineErrorType.Auth)
|
||||
const isBalanceError = parsedError.isErrorType(ClineErrorType.Balance)
|
||||
return {
|
||||
isAuthError,
|
||||
isBalanceError,
|
||||
shouldRetry: !isAuthError && !isBalanceError,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,6 @@ export interface ApiProviderInfo {
|
||||
model: ApiHandlerModel
|
||||
mode: Mode
|
||||
customPrompt?: string // "compact"
|
||||
autoCondenseThreshold?: number // 0-1 range
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
|
||||
@@ -938,6 +938,19 @@ describe("AwsBedrockHandler", () => {
|
||||
modelId.should.equal("jp.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
})
|
||||
|
||||
it("should apply JP cross-region prefix for sonnet 4.6", async () => {
|
||||
const jpOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
apiModelId: "anthropic.claude-sonnet-4-6",
|
||||
awsRegion: "ap-northeast-1",
|
||||
}
|
||||
const jpHandler = new AwsBedrockHandler(jpOptions)
|
||||
|
||||
const modelId = await jpHandler.getModelId()
|
||||
modelId.should.equal("jp.anthropic.claude-sonnet-4-6")
|
||||
})
|
||||
|
||||
it("should apply global cross-region prefix for supported models", async () => {
|
||||
const globalOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
|
||||
@@ -276,6 +276,16 @@ describe("ClaudeCodeHandler", () => {
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Sonnet 4.6 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-sonnet-4-6[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-sonnet-4-6[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should return default model when not specified", () => {
|
||||
const handler = new ClaudeCodeHandler({})
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("SapAiCoreHandler", () => {
|
||||
|
||||
it("should support different Claude model variants", () => {
|
||||
const modelVariants = [
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
"anthropic--claude-4-sonnet",
|
||||
"anthropic--claude-4-opus",
|
||||
"anthropic--claude-3.7-sonnet",
|
||||
|
||||
@@ -11,22 +11,22 @@ describe("VercelAIGatewayHandler", () => {
|
||||
}
|
||||
|
||||
const handler = new VercelAIGatewayHandler({
|
||||
openRouterModelId: "google/gemini-3-pro-preview",
|
||||
openRouterModelId: "google/gemini-3.1-pro-preview",
|
||||
openRouterModelInfo: customModelInfo,
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
result.id.should.equal("google/gemini-3-pro-preview")
|
||||
result.id.should.equal("google/gemini-3.1-pro-preview")
|
||||
result.info.should.deepEqual(customModelInfo)
|
||||
})
|
||||
|
||||
it("should preserve configured model ID when model info is missing", () => {
|
||||
const handler = new VercelAIGatewayHandler({
|
||||
openRouterModelId: "google/gemini-3-pro-preview",
|
||||
openRouterModelId: "google/gemini-3.1-pro-preview",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
result.id.should.equal("google/gemini-3-pro-preview")
|
||||
result.id.should.equal("google/gemini-3.1-pro-preview")
|
||||
result.info.should.deepEqual(openRouterDefaultModelInfo)
|
||||
})
|
||||
|
||||
|
||||
@@ -127,9 +127,11 @@ interface ProviderChainOptions {
|
||||
profile?: string
|
||||
}
|
||||
|
||||
// a special jp inference profile was created for opus 4.6, sonnet 4.5 & haiku 4.5
|
||||
// a special jp inference profile was created for sonnet 4.6, opus 4.6, sonnet 4.5 & haiku 4.5
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
|
||||
const JP_SUPPORTED_CRIS_MODELS = [
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-sonnet-4-6:1m",
|
||||
"anthropic.claude-opus-4-6-v1",
|
||||
"anthropic.claude-opus-4-6-v1:1m",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
|
||||
@@ -84,7 +84,8 @@ export class CerebrasHandler implements ApiHandler {
|
||||
.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return block.text
|
||||
} else if (block.type === "image") {
|
||||
}
|
||||
if (block.type === "image") {
|
||||
return "[Image content not supported in Cerebras]"
|
||||
}
|
||||
return ""
|
||||
@@ -195,14 +196,18 @@ export class CerebrasHandler implements ApiHandler {
|
||||
// Rate limit error - will be handled by retry decorator with patient backoff
|
||||
const _limits = this.getRateLimits()
|
||||
throw new Error(`Cerebras API rate limit exceeded.`)
|
||||
} else if (error?.status === 401) {
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
throw new Error("Cerebras API authentication failed. Please check your API key.")
|
||||
} else if (error?.status === 403) {
|
||||
}
|
||||
if (error?.status === 403) {
|
||||
throw new Error("Cerebras API access denied. Please check your API key permissions.")
|
||||
} else if (error?.status >= 500) {
|
||||
}
|
||||
if (error?.status >= 500) {
|
||||
// Server errors - retryable
|
||||
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
|
||||
} else if (error?.status === 400) {
|
||||
}
|
||||
if (error?.status === 400) {
|
||||
// Client errors - not retryable
|
||||
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
|
||||
}
|
||||
@@ -249,9 +254,7 @@ export class CerebrasHandler implements ApiHandler {
|
||||
case "qwen-3-235b-a22b-instruct-2507":
|
||||
case "qwen-3-235b-a22b-thinking-2507":
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
|
||||
case "llama-3.3-70b":
|
||||
case "gpt-oss-120b":
|
||||
case "qwen-3-32b":
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 64_000 }
|
||||
default:
|
||||
// Default rate limits for unknown models
|
||||
|
||||
@@ -30,6 +30,8 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
|
||||
clineApiKey?: string
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ClineHandlerOptions
|
||||
private clineAccountService = ClineAccountService.getInstance()
|
||||
@@ -198,7 +200,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// @ts-expect-error-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5", "z-ai/glm-5"].includes(modelId)
|
||||
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
@@ -252,7 +254,7 @@ export class ClineHandler implements ApiHandler {
|
||||
const generation = response.data
|
||||
let totalCost = generation?.total_cost || 0
|
||||
const modelId = this.getModel().id
|
||||
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5", "z-ai/glm-5"].includes(modelId)
|
||||
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
|
||||
|
||||
if (isFreeModel) {
|
||||
totalCost = 0
|
||||
|
||||
@@ -157,7 +157,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
httpOptions: this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined,
|
||||
systemInstruction: systemPrompt,
|
||||
// Set temperature (default to 0)
|
||||
// Gemini 3.0 recommends 1.0
|
||||
// Gemini 3 recommends 1.0
|
||||
temperature: info.temperature ?? 1,
|
||||
}
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ export class OcaHandler implements ApiHandler {
|
||||
|
||||
async *createMessageResponsesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const inputMessages = convertToOpenAIResponsesInput(messages).input
|
||||
const inputMessages = convertToOpenAIResponsesInput(messages, { usePreviousResponseId: false }).input
|
||||
// Convert messages to Responses API input format
|
||||
const input: OpenAI.Responses.ResponseInputItem[] = [{ role: "system", content: systemPrompt }, ...inputMessages]
|
||||
|
||||
@@ -329,14 +329,18 @@ export class OcaHandler implements ApiHandler {
|
||||
tools: responseTools,
|
||||
}
|
||||
|
||||
if (this.options.ocaModelInfo && this.options.ocaModelInfo.supportsReasoning) {
|
||||
responsesParams["reasoning"] = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
|
||||
const ocaModelInfo = this.options.ocaModelInfo
|
||||
if (!ocaModelInfo) {
|
||||
throw new Error("Oracle Code Assist (OCA) model info is required for Responses API")
|
||||
}
|
||||
if (ocaModelInfo.supportsReasoning) {
|
||||
responsesParams.reasoning = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
|
||||
}
|
||||
|
||||
// Create the response using Responses API
|
||||
const stream = await client.responses.create(responsesParams)
|
||||
|
||||
yield* handleResponsesApiStreamResponse(stream, this.options.ocaModelInfo!, this.calculateCost.bind(this))
|
||||
yield* handleResponsesApiStreamResponse(stream, ocaModelInfo, this.calculateCost.bind(this))
|
||||
}
|
||||
|
||||
getModel() {
|
||||
|
||||
@@ -3,11 +3,16 @@ import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import * as os from "os"
|
||||
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
|
||||
import { v7 as uuidv7 } from "uuid"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
@@ -17,6 +22,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
* Routes to chatgpt.com/backend-api/codex
|
||||
*/
|
||||
const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
const CODEX_RESPONSES_WEBSOCKET_URL = "wss://chatgpt.com/backend-api/codex/responses"
|
||||
|
||||
interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
|
||||
reasoningEffort?: string
|
||||
@@ -36,6 +42,8 @@ interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
|
||||
export class OpenAiCodexHandler implements ApiHandler {
|
||||
private options: OpenAiCodexHandlerOptions
|
||||
private client?: OpenAI
|
||||
private responsesWs: UndiciWebSocket | undefined
|
||||
private websocketRequestInFlight = false
|
||||
// Session ID for the Codex API (persists for the lifetime of the handler)
|
||||
private readonly sessionId: string
|
||||
// Abort controller for cancelling ongoing requests
|
||||
@@ -49,7 +57,7 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
this.sessionId = uuidv7()
|
||||
}
|
||||
|
||||
private normalizeUsage(usage: any, model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
|
||||
private normalizeUsage(usage: any, _model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
|
||||
if (!usage) {
|
||||
return undefined
|
||||
}
|
||||
@@ -101,17 +109,18 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
if (!accessToken) {
|
||||
throw new Error("Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow in settings.")
|
||||
}
|
||||
|
||||
// Format conversation for Responses API
|
||||
const formattedInput = convertToOpenAIResponsesInput(messages).input
|
||||
const useWebsocketMode = this.useWebsocketMode(model.info.apiFormat)
|
||||
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages, { usePreviousResponseId: useWebsocketMode })
|
||||
const usePreviousResponseId = useWebsocketMode && !!previousResponseId
|
||||
|
||||
// Build request body
|
||||
const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, tools)
|
||||
const requestBody = this.buildRequestBody(model, input, systemPrompt, tools, previousResponseId)
|
||||
const fallbackRequestBody = this.buildRequestBody(model, input, systemPrompt, tools)
|
||||
|
||||
// Make the request with retry on auth failure
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
yield* this.executeRequest(requestBody, model, accessToken)
|
||||
yield* this.executeRequest(requestBody, fallbackRequestBody, model, accessToken, usePreviousResponseId)
|
||||
return
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@@ -133,11 +142,19 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private useWebsocketMode(apiFormat?: ApiFormat): boolean {
|
||||
if (featureFlagsService.getBooleanFlagEnabled(FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE)) {
|
||||
return apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private buildRequestBody(
|
||||
model: { id: string; info: ModelInfo },
|
||||
formattedInput: any,
|
||||
systemPrompt: string,
|
||||
tools?: ChatCompletionTool[],
|
||||
previousResponseId?: string,
|
||||
): any {
|
||||
// Determine reasoning effort
|
||||
const reasoningEffort = normalizeOpenaiReasoningEffort(this.options.reasoningEffort)
|
||||
@@ -147,8 +164,9 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
model: model.id,
|
||||
input: formattedInput,
|
||||
stream: true,
|
||||
store: false,
|
||||
store: !previousResponseId,
|
||||
instructions: systemPrompt,
|
||||
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
|
||||
...(includeReasoning ? { include: ["reasoning.encrypted_content"] } : {}),
|
||||
...(includeReasoning
|
||||
? {
|
||||
@@ -177,7 +195,13 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
return body
|
||||
}
|
||||
|
||||
private async *executeRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
|
||||
private async *executeRequest(
|
||||
requestBody: any,
|
||||
fallbackRequestBody: any,
|
||||
model: { id: string; info: ModelInfo },
|
||||
accessToken: string,
|
||||
useWebsocketMode: boolean,
|
||||
): ApiStream {
|
||||
// Create AbortController for cancellation
|
||||
this.abortController = new AbortController()
|
||||
|
||||
@@ -194,6 +218,16 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
...buildExternalBasicHeaders(),
|
||||
}
|
||||
|
||||
if (useWebsocketMode) {
|
||||
try {
|
||||
yield* this.createResponseStreamWebsocket(requestBody, fallbackRequestBody, accessToken, codexHeaders, model)
|
||||
return
|
||||
} catch (error) {
|
||||
Logger.error("OpenAI Codex websocket mode failed, falling back to HTTP Responses API:", error)
|
||||
this.closeResponsesWebsocket()
|
||||
}
|
||||
}
|
||||
|
||||
// Try using OpenAI SDK first
|
||||
try {
|
||||
const client =
|
||||
@@ -232,6 +266,223 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async *createResponseStreamWebsocket(
|
||||
primaryParams: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
fallbackParams: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
accessToken: string,
|
||||
codexHeaders: Record<string, string>,
|
||||
model: { id: string; info: ModelInfo },
|
||||
): ApiStream {
|
||||
try {
|
||||
for await (const event of this.createResponseEventsViaWebsocket(primaryParams, accessToken, codexHeaders)) {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
return
|
||||
}
|
||||
yield* this.processEvent(event, model)
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.shouldRetryWebsocketWithFullContext(error, !!primaryParams.previous_response_id)) {
|
||||
Logger.log(
|
||||
"Retrying Codex websocket response with full context after previous_response_not_found or socket reset",
|
||||
)
|
||||
this.closeResponsesWebsocket()
|
||||
for await (const event of this.createResponseEventsViaWebsocket(fallbackParams, accessToken, codexHeaders)) {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
return
|
||||
}
|
||||
yield* this.processEvent(event, model)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetryWebsocketWithFullContext(error: unknown, hadPreviousResponseId: boolean): boolean {
|
||||
const errorCode =
|
||||
typeof error === "object" && error && "code" in error && typeof (error as { code: unknown }).code === "string"
|
||||
? (error as { code: string }).code
|
||||
: undefined
|
||||
|
||||
if (hadPreviousResponseId && errorCode === "previous_response_not_found") {
|
||||
return true
|
||||
}
|
||||
if (errorCode === "websocket_closed" || errorCode === "websocket_error") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async ensureResponsesWebsocket(accessToken: string, codexHeaders: Record<string, string>): Promise<UndiciWebSocket> {
|
||||
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
|
||||
return this.responsesWs
|
||||
}
|
||||
|
||||
this.closeResponsesWebsocket()
|
||||
|
||||
const ws = new UndiciWebSocket(CODEX_RESPONSES_WEBSOCKET_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"OpenAI-Beta": "responses_websockets=2026-02-06",
|
||||
...codexHeaders,
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
ws.removeEventListener("open", handleOpen)
|
||||
ws.removeEventListener("error", handleError)
|
||||
ws.removeEventListener("close", handleClose)
|
||||
}
|
||||
const handleOpen = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const handleError = () => {
|
||||
cleanup()
|
||||
reject(new Error("Failed to open Codex Responses websocket"))
|
||||
}
|
||||
const handleClose = () => {
|
||||
cleanup()
|
||||
reject(new Error("Codex Responses websocket closed before opening"))
|
||||
}
|
||||
ws.addEventListener("open", handleOpen)
|
||||
ws.addEventListener("error", handleError)
|
||||
ws.addEventListener("close", handleClose)
|
||||
})
|
||||
|
||||
this.responsesWs = ws
|
||||
return ws
|
||||
}
|
||||
|
||||
private closeResponsesWebsocket() {
|
||||
if (this.responsesWs) {
|
||||
try {
|
||||
this.responsesWs.close()
|
||||
} catch {}
|
||||
this.responsesWs = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async *createResponseEventsViaWebsocket(
|
||||
params: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
accessToken: string,
|
||||
codexHeaders: Record<string, string>,
|
||||
): AsyncGenerator<OpenAI.Responses.ResponseStreamEvent> {
|
||||
if (this.websocketRequestInFlight) {
|
||||
const error: Error & { code?: string } = new Error("Websocket response.create is already in progress")
|
||||
error.code = "websocket_concurrency_limit"
|
||||
throw error
|
||||
}
|
||||
|
||||
const ws = await this.ensureResponsesWebsocket(accessToken, codexHeaders)
|
||||
this.websocketRequestInFlight = true
|
||||
|
||||
const eventQueue: OpenAI.Responses.ResponseStreamEvent[] = []
|
||||
let resolver: (() => void) | undefined
|
||||
let completed = false
|
||||
let failure: (Error & { code?: string }) | undefined
|
||||
|
||||
const wake = () => {
|
||||
const next = resolver
|
||||
resolver = undefined
|
||||
next?.()
|
||||
}
|
||||
|
||||
const handleMessage = (evt: UndiciMessageEvent) => {
|
||||
try {
|
||||
let raw = ""
|
||||
if (typeof evt.data === "string") {
|
||||
raw = evt.data
|
||||
} else if (evt.data instanceof ArrayBuffer) {
|
||||
raw = new TextDecoder().decode(new Uint8Array(evt.data))
|
||||
} else if (ArrayBuffer.isView(evt.data)) {
|
||||
raw = new TextDecoder().decode(new Uint8Array(evt.data.buffer, evt.data.byteOffset, evt.data.byteLength))
|
||||
} else {
|
||||
raw = String(evt.data)
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed?.type === "error" && parsed?.error) {
|
||||
const error: Error & { code?: string } = new Error(parsed.error.message || "Codex Responses websocket error")
|
||||
error.code = parsed.error.code
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
return
|
||||
}
|
||||
|
||||
eventQueue.push(parsed as OpenAI.Responses.ResponseStreamEvent)
|
||||
if (parsed?.type === "response.completed" || parsed?.type === "response.failed") {
|
||||
completed = true
|
||||
}
|
||||
wake()
|
||||
} catch (error) {
|
||||
const parseError: Error & { code?: string } = new Error(
|
||||
`Failed to parse websocket event: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
parseError.code = "websocket_parse_error"
|
||||
failure = parseError
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
const error: Error & { code?: string } = new Error("Codex Responses websocket emitted an error event")
|
||||
error.code = "websocket_error"
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!completed) {
|
||||
const error: Error & { code?: string } = new Error("Codex Responses websocket closed during response stream")
|
||||
error.code = "websocket_closed"
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener("message", handleMessage)
|
||||
ws.addEventListener("error", handleError)
|
||||
ws.addEventListener("close", handleClose)
|
||||
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "response.create",
|
||||
...params,
|
||||
}),
|
||||
)
|
||||
|
||||
while (!completed || eventQueue.length > 0) {
|
||||
if (eventQueue.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const event = eventQueue.shift()
|
||||
if (event) {
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
} finally {
|
||||
ws.removeEventListener("message", handleMessage)
|
||||
ws.removeEventListener("error", handleError)
|
||||
ws.removeEventListener("close", handleClose)
|
||||
this.websocketRequestInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
private async *makeCodexRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
|
||||
const url = `${CODEX_API_BASE_URL}/responses`
|
||||
|
||||
@@ -465,6 +716,7 @@ export class OpenAiCodexHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.closeResponsesWebsocket()
|
||||
this.abortController?.abort()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,18 @@ import {
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { calculateApiCostOpenAI } from "@utils/cost"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import type {
|
||||
ChatCompletionFunctionTool,
|
||||
ChatCompletionReasoningEffort,
|
||||
ChatCompletionTool,
|
||||
} from "openai/resources/chat/completions"
|
||||
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isGPT5ModelFamily } from "@/utils/model-utils"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
@@ -26,12 +34,15 @@ interface OpenAiNativeHandlerOptions extends CommonApiHandlerOptions {
|
||||
reasoningEffort?: string
|
||||
thinkingBudgetTokens?: number
|
||||
apiModelId?: string
|
||||
store?: boolean
|
||||
openAiNativeUseResponsesWebsocket?: boolean
|
||||
}
|
||||
|
||||
export class OpenAiNativeHandler implements ApiHandler {
|
||||
private options: OpenAiNativeHandlerOptions
|
||||
private client: OpenAI | undefined
|
||||
private responsesWs: UndiciWebSocket | undefined
|
||||
private websocketRequestInFlight = false
|
||||
private abortController?: AbortController
|
||||
|
||||
constructor(options: OpenAiNativeHandlerOptions) {
|
||||
this.options = options
|
||||
@@ -73,7 +84,8 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ChatCompletionTool[]): ApiStream {
|
||||
// Responses API requires tool format to be set to OPENAI_RESPONSES with native tools calling enabled
|
||||
if (this.getModel()?.info?.apiFormat === ApiFormat.OPENAI_RESPONSES) {
|
||||
const apiFormat = this.getModel()?.info?.apiFormat
|
||||
if (apiFormat === ApiFormat.OPENAI_RESPONSES || apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE) {
|
||||
if (!tools?.length) {
|
||||
throw new Error("Native Tool Call must be enabled in your setting for OpenAI Responses API")
|
||||
}
|
||||
@@ -91,13 +103,17 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
this.abortController = new AbortController()
|
||||
|
||||
// Handle o1 models separately as they don't support streaming
|
||||
if (model.info.supportsStreaming === false) {
|
||||
const response = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
})
|
||||
const response = await client.chat.completions.create(
|
||||
{
|
||||
model: model.id,
|
||||
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
},
|
||||
{ signal: this.abortController?.signal },
|
||||
)
|
||||
yield {
|
||||
type: "text",
|
||||
text: response.choices[0]?.message.content || "",
|
||||
@@ -152,26 +168,66 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
messages: ClineStorageMessage[],
|
||||
tools: ChatCompletionTool[],
|
||||
): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
const usePreviousResponseId = this.useWebsocketMode(model.info.apiFormat)
|
||||
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages, { usePreviousResponseId })
|
||||
const responseTools = this.mapResponseTools(tools)
|
||||
this.abortController = new AbortController()
|
||||
|
||||
// Convert messages to Responses API input format
|
||||
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages)
|
||||
const params = this.buildResponseCreateParams({
|
||||
modelId: model.id,
|
||||
systemPrompt,
|
||||
input,
|
||||
previousResponseId,
|
||||
tools: responseTools,
|
||||
})
|
||||
|
||||
// Convert ChatCompletion tools to Responses API format if provided
|
||||
const responseTools = tools
|
||||
?.filter((tool) => tool?.type === "function")
|
||||
.map((tool: any) => ({
|
||||
const fallbackParams = this.buildResponseCreateParams({
|
||||
modelId: model.id,
|
||||
systemPrompt,
|
||||
input,
|
||||
tools: responseTools,
|
||||
})
|
||||
|
||||
if (usePreviousResponseId && previousResponseId) {
|
||||
try {
|
||||
yield* this.createResponseStreamWebsocket(model.info, params, fallbackParams)
|
||||
return
|
||||
} catch (error) {
|
||||
Logger.error("OpenAI websocket mode failed, falling back to HTTP Responses API:", error)
|
||||
this.closeResponsesWebsocket()
|
||||
}
|
||||
}
|
||||
|
||||
yield* this.createResponseStreamHttp(model.info, params)
|
||||
}
|
||||
|
||||
private useWebsocketMode(apiFormat?: ApiFormat): boolean {
|
||||
if (featureFlagsService.getBooleanFlagEnabled(FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE)) {
|
||||
return apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private mapResponseTools(tools: ChatCompletionTool[]): OpenAI.Responses.Tool[] {
|
||||
return tools
|
||||
?.filter((tool): tool is ChatCompletionFunctionTool => tool?.type === "function")
|
||||
.map((tool) => ({
|
||||
type: "function" as const,
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
|
||||
parameters: tool.function.parameters ?? null,
|
||||
strict: tool.function.strict ?? true,
|
||||
}))
|
||||
}
|
||||
|
||||
Logger.debug(`OpenAI Responses Input: ${JSON.stringify(input)}`)
|
||||
|
||||
// Create the response using Responses API
|
||||
private buildResponseCreateParams(args: {
|
||||
modelId: string
|
||||
systemPrompt: string
|
||||
input: OpenAI.Responses.ResponseInput
|
||||
tools: OpenAI.Responses.Tool[]
|
||||
previousResponseId?: string
|
||||
}): OpenAI.Responses.ResponseCreateParamsStreaming {
|
||||
const requestedEffort = normalizeOpenaiReasoningEffort(this.options.reasoningEffort)
|
||||
const reasoning: { effort: ChatCompletionReasoningEffort; summary: "auto" } | undefined =
|
||||
requestedEffort === "none"
|
||||
@@ -181,25 +237,243 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
summary: "auto",
|
||||
}
|
||||
|
||||
const stream = await client.responses.create({
|
||||
model: model.id,
|
||||
instructions: systemPrompt,
|
||||
input,
|
||||
return {
|
||||
model: args.modelId,
|
||||
instructions: args.systemPrompt,
|
||||
input: args.input,
|
||||
stream: true,
|
||||
tools: responseTools,
|
||||
store: this.options.store ?? false,
|
||||
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
|
||||
tools: args.tools,
|
||||
store: !args.previousResponseId, // Do not use store when websocket mode is enabled.
|
||||
...(args.previousResponseId ? { previous_response_id: args.previousResponseId } : {}),
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
// include: ["reasoning.encrypted_content"],
|
||||
}
|
||||
}
|
||||
|
||||
private async *createResponseStreamHttp(
|
||||
modelInfo: ModelInfo,
|
||||
params: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
Logger.debug(`OpenAI Responses Input (HTTP): ${JSON.stringify(params.input)}`)
|
||||
const stream = await client.responses.create(params, { signal: this.abortController?.signal })
|
||||
yield* this.processResponsesEvents(stream, modelInfo)
|
||||
}
|
||||
|
||||
private async *createResponseStreamWebsocket(
|
||||
modelInfo: ModelInfo,
|
||||
primaryParams: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
fallbackParams: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
): ApiStream {
|
||||
Logger.debug(`OpenAI Responses Input (WebSocket): ${JSON.stringify(primaryParams.input)}`)
|
||||
try {
|
||||
yield* this.processResponsesEvents(this.createResponseEventsViaWebsocket(primaryParams), modelInfo)
|
||||
} catch (error) {
|
||||
if (this.shouldRetryWebsocketWithFullContext(error, !!primaryParams.previous_response_id)) {
|
||||
Logger.log("Retrying websocket response with full context after previous_response_not_found or socket reset")
|
||||
this.closeResponsesWebsocket()
|
||||
yield* this.processResponsesEvents(this.createResponseEventsViaWebsocket(fallbackParams), modelInfo)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetryWebsocketWithFullContext(error: unknown, hadPreviousResponseId: boolean): boolean {
|
||||
const errorCode =
|
||||
typeof error === "object" && error && "code" in error && typeof (error as { code: unknown }).code === "string"
|
||||
? (error as { code: string }).code
|
||||
: undefined
|
||||
|
||||
if (hadPreviousResponseId && errorCode === "previous_response_not_found") {
|
||||
return true
|
||||
}
|
||||
if (errorCode === "websocket_closed" || errorCode === "websocket_error") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async ensureResponsesWebsocket(): Promise<UndiciWebSocket> {
|
||||
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
|
||||
return this.responsesWs
|
||||
}
|
||||
|
||||
this.closeResponsesWebsocket()
|
||||
|
||||
if (!this.options.openAiNativeApiKey) {
|
||||
throw new Error("OpenAI API key is required")
|
||||
}
|
||||
|
||||
const ws = new UndiciWebSocket("wss://api.openai.com/v1/responses", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.openAiNativeApiKey}`,
|
||||
"OpenAI-Beta": "responses_websockets=2026-02-06",
|
||||
...buildExternalBasicHeaders(),
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
ws.removeEventListener("open", handleOpen)
|
||||
ws.removeEventListener("error", handleError)
|
||||
ws.removeEventListener("close", handleClose)
|
||||
}
|
||||
const handleOpen = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const handleError = () => {
|
||||
cleanup()
|
||||
reject(new Error("Failed to open Responses websocket"))
|
||||
}
|
||||
const handleClose = () => {
|
||||
cleanup()
|
||||
reject(new Error("Responses websocket closed before opening"))
|
||||
}
|
||||
ws.addEventListener("open", handleOpen)
|
||||
ws.addEventListener("error", handleError)
|
||||
ws.addEventListener("close", handleClose)
|
||||
})
|
||||
|
||||
this.responsesWs = ws
|
||||
return ws
|
||||
}
|
||||
|
||||
private closeResponsesWebsocket() {
|
||||
if (this.responsesWs) {
|
||||
try {
|
||||
this.responsesWs.close()
|
||||
} catch {}
|
||||
this.responsesWs = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async *createResponseEventsViaWebsocket(
|
||||
params: OpenAI.Responses.ResponseCreateParamsStreaming,
|
||||
): AsyncGenerator<OpenAI.Responses.ResponseStreamEvent> {
|
||||
if (this.websocketRequestInFlight) {
|
||||
const error: Error & { code?: string } = new Error("Websocket response.create is already in progress")
|
||||
error.code = "websocket_concurrency_limit"
|
||||
throw error
|
||||
}
|
||||
|
||||
const ws = await this.ensureResponsesWebsocket()
|
||||
this.websocketRequestInFlight = true
|
||||
|
||||
const eventQueue: OpenAI.Responses.ResponseStreamEvent[] = []
|
||||
let resolver: (() => void) | undefined
|
||||
let completed = false
|
||||
let failure: (Error & { code?: string }) | undefined
|
||||
|
||||
const wake = () => {
|
||||
const next = resolver
|
||||
resolver = undefined
|
||||
next?.()
|
||||
}
|
||||
|
||||
const handleMessage = (evt: UndiciMessageEvent) => {
|
||||
try {
|
||||
let raw = ""
|
||||
if (typeof evt.data === "string") {
|
||||
raw = evt.data
|
||||
} else if (evt.data instanceof ArrayBuffer) {
|
||||
raw = new TextDecoder().decode(new Uint8Array(evt.data))
|
||||
} else if (ArrayBuffer.isView(evt.data)) {
|
||||
raw = new TextDecoder().decode(new Uint8Array(evt.data.buffer, evt.data.byteOffset, evt.data.byteLength))
|
||||
} else {
|
||||
raw = String(evt.data)
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (parsed?.type === "error" && parsed?.error) {
|
||||
const error: Error & { code?: string } = new Error(parsed.error.message || "Responses websocket error")
|
||||
error.code = parsed.error.code
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
return
|
||||
}
|
||||
|
||||
eventQueue.push(parsed as OpenAI.Responses.ResponseStreamEvent)
|
||||
if (parsed?.type === "response.completed" || parsed?.type === "response.failed") {
|
||||
completed = true
|
||||
}
|
||||
wake()
|
||||
} catch (error) {
|
||||
const parseError: Error & { code?: string } = new Error(
|
||||
`Failed to parse websocket event: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
parseError.code = "websocket_parse_error"
|
||||
failure = parseError
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
const error: Error & { code?: string } = new Error("Responses websocket emitted an error event")
|
||||
error.code = "websocket_error"
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!completed) {
|
||||
const error: Error & { code?: string } = new Error("Responses websocket closed during response stream")
|
||||
error.code = "websocket_closed"
|
||||
failure = error
|
||||
completed = true
|
||||
wake()
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener("message", handleMessage)
|
||||
ws.addEventListener("error", handleError)
|
||||
ws.addEventListener("close", handleClose)
|
||||
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "response.create",
|
||||
...params,
|
||||
}),
|
||||
)
|
||||
|
||||
while (!completed || eventQueue.length > 0) {
|
||||
if (eventQueue.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const event = eventQueue.shift()
|
||||
if (event) {
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
} finally {
|
||||
ws.removeEventListener("message", handleMessage)
|
||||
ws.removeEventListener("error", handleError)
|
||||
ws.removeEventListener("close", handleClose)
|
||||
this.websocketRequestInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
private async *processResponsesEvents(
|
||||
stream: AsyncIterable<OpenAI.Responses.ResponseStreamEvent>,
|
||||
modelInfo: ModelInfo,
|
||||
): ApiStream {
|
||||
const functionCallByItemId = new Map<string, { call_id?: string; name?: string; id?: string }>()
|
||||
|
||||
// Process the response stream
|
||||
for await (const chunk of stream) {
|
||||
Logger.debug(`OpenAI Responses Chunk: ${JSON.stringify(chunk)}`)
|
||||
|
||||
// Handle different event types from Responses API
|
||||
if (chunk.type === "response.output_item.added") {
|
||||
const item = chunk.item
|
||||
if (item.type === "function_call" && item.id) {
|
||||
@@ -277,7 +551,6 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
if (chunk.type === "response.output_text.delta") {
|
||||
// Handle text content deltas
|
||||
if (chunk.delta) {
|
||||
yield {
|
||||
id: chunk.item_id,
|
||||
@@ -287,7 +560,6 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
if (chunk.type === "response.reasoning_text.delta") {
|
||||
// Handle reasoning content deltas
|
||||
if (chunk.delta) {
|
||||
yield {
|
||||
id: chunk.item_id,
|
||||
@@ -315,7 +587,6 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
if (chunk.type === "response.function_call_arguments.done") {
|
||||
// Handle completed function call
|
||||
if (chunk.item_id && chunk.name && chunk.arguments) {
|
||||
const pendingCall = functionCallByItemId.get(chunk.item_id)
|
||||
const callId = pendingCall?.call_id
|
||||
@@ -348,7 +619,6 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (chunk.type === "response.completed" && chunk.response?.usage) {
|
||||
// Handle usage information when response is complete
|
||||
const usage = chunk.response.usage
|
||||
const inputTokens = usage.input_tokens || 0
|
||||
const outputTokens = usage.output_tokens || 0
|
||||
@@ -357,7 +627,13 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0
|
||||
const totalTokens = usage.total_tokens || 0
|
||||
Logger.log(`Total tokens from Responses API usage: ${totalTokens}`)
|
||||
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens + reasoningTokens, cacheWriteTokens, cacheReadTokens)
|
||||
const totalCost = calculateApiCostOpenAI(
|
||||
modelInfo,
|
||||
inputTokens,
|
||||
outputTokens + reasoningTokens,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
)
|
||||
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
|
||||
yield {
|
||||
type: "usage",
|
||||
@@ -373,6 +649,12 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.closeResponsesWebsocket()
|
||||
this.abortController?.abort()
|
||||
this.abortController = undefined
|
||||
}
|
||||
|
||||
getModel(): { id: OpenAiNativeModelId; info: OpenAiCompatibleModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in openAiNativeModels) {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { providers } from "@cline/llms"
|
||||
import type { ClineContent, ClineImageContentBlock, ClineStorageMessage, ClineTextContentBlock } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { SELECTOR_SEPARATOR } from "@/shared/vsCodeSelectorUtils"
|
||||
import { VsCodeLmHandler } from "./vscode-lm"
|
||||
|
||||
export const VSCODE_LM_SELECTOR_HEADER = "x-cline-vscode-lm-selector"
|
||||
|
||||
let createHandlersPatched = false
|
||||
let vscodeLmProviderRegistered = false
|
||||
let originalCreateHandler: ((config: providers.ProviderConfig) => providers.ApiHandler) | undefined
|
||||
let originalCreateHandlerAsync: ((config: providers.ProviderConfig) => Promise<providers.ApiHandler>) | undefined
|
||||
const extensionProviderFactories = new Map<string, (config: providers.ProviderConfig) => providers.ApiHandler>()
|
||||
|
||||
function ensureProvidersCreateHandlerPatched() {
|
||||
if (createHandlersPatched) {
|
||||
return
|
||||
}
|
||||
const mutableProviders = providers as typeof providers & {
|
||||
createHandler: (config: providers.ProviderConfig) => providers.ApiHandler
|
||||
createHandlerAsync: (config: providers.ProviderConfig) => Promise<providers.ApiHandler>
|
||||
}
|
||||
|
||||
originalCreateHandler = mutableProviders.createHandler.bind(providers)
|
||||
originalCreateHandlerAsync = mutableProviders.createHandlerAsync.bind(providers)
|
||||
|
||||
mutableProviders.createHandler = (config: providers.ProviderConfig): providers.ApiHandler => {
|
||||
const extensionFactory = extensionProviderFactories.get(config.providerId)
|
||||
if (extensionFactory) {
|
||||
return extensionFactory(config)
|
||||
}
|
||||
if (!originalCreateHandler) {
|
||||
throw new Error("LLMS createHandler has not been initialized")
|
||||
}
|
||||
return originalCreateHandler(config)
|
||||
}
|
||||
|
||||
mutableProviders.createHandlerAsync = async (config: providers.ProviderConfig): Promise<providers.ApiHandler> => {
|
||||
const extensionFactory = extensionProviderFactories.get(config.providerId)
|
||||
if (extensionFactory) {
|
||||
return extensionFactory(config)
|
||||
}
|
||||
if (!originalCreateHandlerAsync) {
|
||||
throw new Error("LLMS createHandlerAsync has not been initialized")
|
||||
}
|
||||
return originalCreateHandlerAsync(config)
|
||||
}
|
||||
|
||||
createHandlersPatched = true
|
||||
}
|
||||
|
||||
function normalizeImageMediaType(mediaType: string): "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
|
||||
switch (mediaType) {
|
||||
case "image/jpeg":
|
||||
case "image/png":
|
||||
case "image/gif":
|
||||
case "image/webp":
|
||||
return mediaType
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
function toClineContentBlock(block: providers.ContentBlock): ClineContent {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return { type: "text", text: block.text }
|
||||
case "image":
|
||||
return {
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: normalizeImageMediaType(block.mediaType),
|
||||
data: block.data,
|
||||
},
|
||||
}
|
||||
case "file":
|
||||
return {
|
||||
type: "text",
|
||||
text: `File: ${block.path}\n${block.content}`,
|
||||
}
|
||||
case "tool_use":
|
||||
return {
|
||||
type: "tool_use",
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
}
|
||||
case "tool_result":
|
||||
return {
|
||||
type: "tool_result",
|
||||
tool_use_id: block.tool_use_id,
|
||||
content:
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: block.content.map((contentBlock): ClineTextContentBlock | ClineImageContentBlock => {
|
||||
switch (contentBlock.type) {
|
||||
case "text":
|
||||
return { type: "text", text: contentBlock.text }
|
||||
case "image":
|
||||
return {
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: normalizeImageMediaType(contentBlock.mediaType),
|
||||
data: contentBlock.data,
|
||||
},
|
||||
}
|
||||
case "file":
|
||||
return { type: "text", text: `File: ${contentBlock.path}\n${contentBlock.content}` }
|
||||
default: {
|
||||
const exhaustiveCheck: never = contentBlock
|
||||
throw new Error(
|
||||
`Unsupported provider tool_result content block type: ${JSON.stringify(exhaustiveCheck)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
is_error: block.is_error,
|
||||
}
|
||||
case "thinking":
|
||||
return {
|
||||
type: "thinking",
|
||||
thinking: block.thinking,
|
||||
signature: block.signature ?? "",
|
||||
}
|
||||
case "redacted_thinking":
|
||||
return {
|
||||
type: "redacted_thinking",
|
||||
data: block.data,
|
||||
}
|
||||
default: {
|
||||
const exhaustiveCheck: never = block
|
||||
throw new Error(`Unsupported provider content block type: ${JSON.stringify(exhaustiveCheck)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toClineMessages(messages: providers.Message[]): ClineStorageMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (typeof message.content === "string") {
|
||||
return {
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role,
|
||||
content: message.content.map((block) => toClineContentBlock(block)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseVsCodeLmSelector(modelId?: string): Record<string, string> | undefined {
|
||||
if (!modelId) {
|
||||
return undefined
|
||||
}
|
||||
const parts = modelId.split(SELECTOR_SEPARATOR).filter(Boolean)
|
||||
if (parts.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const [vendor, family, version, id] = parts
|
||||
const selector: Record<string, string> = {}
|
||||
if (vendor) {
|
||||
selector.vendor = vendor
|
||||
}
|
||||
if (family) {
|
||||
selector.family = family
|
||||
}
|
||||
if (version) {
|
||||
selector.version = version
|
||||
}
|
||||
if (id) {
|
||||
selector.id = id
|
||||
}
|
||||
return Object.keys(selector).length > 0 ? selector : undefined
|
||||
}
|
||||
|
||||
function toRegisteredModelInfo(modelId: string, modelInfo: ReturnType<VsCodeLmHandler["getModel"]>["info"]): providers.ModelInfo {
|
||||
const capabilities: providers.ModelInfo["capabilities"] = []
|
||||
if (modelInfo.supportsImages) {
|
||||
capabilities.push("images")
|
||||
}
|
||||
if (modelInfo.supportsPromptCache) {
|
||||
capabilities.push("prompt-cache")
|
||||
}
|
||||
if (modelInfo.supportsReasoning) {
|
||||
capabilities.push("reasoning")
|
||||
}
|
||||
if (modelInfo.supportsGlobalEndpoint) {
|
||||
capabilities.push("global-endpoint")
|
||||
}
|
||||
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelInfo.name,
|
||||
description: modelInfo.description,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
temperature: modelInfo.temperature,
|
||||
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
||||
pricing:
|
||||
modelInfo.inputPrice !== undefined ||
|
||||
modelInfo.outputPrice !== undefined ||
|
||||
modelInfo.cacheWritesPrice !== undefined ||
|
||||
modelInfo.cacheReadsPrice !== undefined
|
||||
? {
|
||||
input: modelInfo.inputPrice,
|
||||
output: modelInfo.outputPrice,
|
||||
cacheWrite: modelInfo.cacheWritesPrice,
|
||||
cacheRead: modelInfo.cacheReadsPrice,
|
||||
}
|
||||
: undefined,
|
||||
thinkingConfig: modelInfo.thinkingConfig
|
||||
? {
|
||||
maxBudget: modelInfo.thinkingConfig.maxBudget,
|
||||
outputPrice: modelInfo.thinkingConfig.outputPrice,
|
||||
thinkingLevel: modelInfo.thinkingConfig.geminiThinkingLevel,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureVsCodeLmProviderRegistered() {
|
||||
if (vscodeLmProviderRegistered) {
|
||||
return
|
||||
}
|
||||
ensureProvidersCreateHandlerPatched()
|
||||
|
||||
extensionProviderFactories.set("vscode-lm", (config: providers.ProviderConfig): providers.ApiHandler => {
|
||||
const selectorFromHeader = config?.headers?.[VSCODE_LM_SELECTOR_HEADER]
|
||||
let selector: Record<string, string> | undefined
|
||||
|
||||
if (selectorFromHeader) {
|
||||
try {
|
||||
selector = JSON.parse(selectorFromHeader)
|
||||
} catch {
|
||||
selector = undefined
|
||||
}
|
||||
}
|
||||
selector = selector ?? parseVsCodeLmSelector(config?.modelId)
|
||||
|
||||
const handler = new VsCodeLmHandler({
|
||||
vsCodeLmModelSelector: selector,
|
||||
})
|
||||
|
||||
return {
|
||||
getMessages: (systemPrompt: string, messages: providers.Message[]) => ({
|
||||
systemPrompt,
|
||||
messages: toClineMessages(messages),
|
||||
}),
|
||||
createMessage: (
|
||||
systemPrompt: string,
|
||||
messages: providers.Message[],
|
||||
_tools?: providers.ToolDefinition[],
|
||||
): providers.ApiStream =>
|
||||
handler.createMessage(systemPrompt, toClineMessages(messages)) as unknown as providers.ApiStream,
|
||||
getModel: () => {
|
||||
const model = handler.getModel()
|
||||
return {
|
||||
id: model.id,
|
||||
info: toRegisteredModelInfo(model.id, model.info),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vscodeLmProviderRegistered = true
|
||||
Logger.debug("Registered extension provider handler for vscode-lm")
|
||||
}
|
||||
@@ -78,6 +78,8 @@ export class RequestyHandler implements ApiHandler {
|
||||
: { thinking: { type: "disabled" } }
|
||||
const thinkingArgs =
|
||||
model.id.includes("claude-opus-4-6") ||
|
||||
model.id.includes("claude-sonnet-4-6") ||
|
||||
model.id.includes("claude-4.6-sonnet") ||
|
||||
model.id.includes("claude-3-7-sonnet") ||
|
||||
model.id.includes("claude-sonnet-4") ||
|
||||
model.id.includes("claude-opus-4") ||
|
||||
|
||||
@@ -372,12 +372,12 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
private chunkToString(chunk: any): string {
|
||||
if (Buffer.isBuffer(chunk)) {
|
||||
return chunk.toString("utf-8")
|
||||
} else if (typeof chunk === "string") {
|
||||
return chunk
|
||||
} else {
|
||||
// Handle comma-separated byte values or other array-like formats
|
||||
return Buffer.from(chunk).toString("utf-8")
|
||||
}
|
||||
if (typeof chunk === "string") {
|
||||
return chunk
|
||||
}
|
||||
// Handle comma-separated byte values or other array-like formats
|
||||
return Buffer.from(chunk).toString("utf-8")
|
||||
}
|
||||
|
||||
private validateCredentials(): void {
|
||||
@@ -526,7 +526,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
if (!expiresIn) {
|
||||
throw new Error("Destination is missing required authTokens with expiresIn")
|
||||
}
|
||||
this.destinationExpiresAt = Date.now() + parseInt(expiresIn, 10) * 1000
|
||||
this.destinationExpiresAt = Date.now() + Number.parseInt(expiresIn, 10) * 1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,6 +610,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
const anthropicModels = [
|
||||
"anthropic--claude-4.5-haiku",
|
||||
"anthropic--claude-4.5-opus",
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
"anthropic--claude-4.5-sonnet",
|
||||
"anthropic--claude-4-sonnet",
|
||||
"anthropic--claude-4-opus",
|
||||
@@ -659,6 +660,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
if (
|
||||
model.id === "anthropic--claude-4.5-opus" ||
|
||||
model.id === "anthropic--claude-4.6-sonnet" ||
|
||||
model.id === "anthropic--claude-4.5-sonnet" ||
|
||||
model.id === "anthropic--claude-4.5-haiku" ||
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
@@ -791,6 +793,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
yield* this.streamCompletionGPT(response.data, model)
|
||||
} else if (
|
||||
model.id === "anthropic--claude-4.5-opus" ||
|
||||
model.id === "anthropic--claude-4.6-sonnet" ||
|
||||
model.id === "anthropic--claude-4.5-sonnet" ||
|
||||
model.id === "anthropic--claude-4.5-haiku" ||
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
@@ -846,20 +849,21 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
if (error.response.status === 404) {
|
||||
throw new Error(`404 Not Found: ${errorMessage}`)
|
||||
} else if (error.response.status === 400) {
|
||||
}
|
||||
if (error.response.status === 400) {
|
||||
throw new Error(`400 Bad Request: ${errorMessage}`)
|
||||
}
|
||||
|
||||
throw new Error(`HTTP ${error.response.status}: ${errorMessage}`)
|
||||
} else if (error.request) {
|
||||
}
|
||||
if (error.request) {
|
||||
// The request was made but no response was received
|
||||
Logger.error("Error request:", error.request)
|
||||
throw new Error("No response received from server")
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
Logger.error("Error message:", error.message)
|
||||
throw new Error(`Error setting up request: ${error.message}`)
|
||||
}
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
Logger.error("Error message:", error.message)
|
||||
throw new Error(`Error setting up request: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages)
|
||||
|
||||
// Accumulate the text and count at the end of the stream to reduce token counting overhead.
|
||||
let accumulatedText: string = ""
|
||||
let accumulatedText = ""
|
||||
|
||||
try {
|
||||
// Create the response stream with minimal required options
|
||||
@@ -498,17 +498,17 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
|
||||
// Return original error if it's already an Error instance
|
||||
throw error
|
||||
} else if (typeof error === "object" && error !== null) {
|
||||
}
|
||||
if (typeof error === "object" && error !== null) {
|
||||
// Handle error-like objects
|
||||
const errorDetails = JSON.stringify(error, null, 2)
|
||||
Logger.error("Cline <Language Model API>: Stream error object:", errorDetails)
|
||||
throw new Error(`Cline <Language Model API>: Response stream error: ${errorDetails}`)
|
||||
} else {
|
||||
// Fallback for unknown error types
|
||||
const errorMessage = String(error)
|
||||
Logger.error("Cline <Language Model API>: Unknown stream error:", errorMessage)
|
||||
throw new Error(`Cline <Language Model API>: Response stream error: ${errorMessage}`)
|
||||
}
|
||||
// Fallback for unknown error types
|
||||
const errorMessage = String(error)
|
||||
Logger.error("Cline <Language Model API>: Unknown stream error:", errorMessage)
|
||||
throw new Error(`Cline <Language Model API>: Response stream error: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,10 @@ export function convertToOpenAIResponsesInput(
|
||||
if (options?.usePreviousResponseId) {
|
||||
for (let i = _messages.length - 1; i >= 0; i--) {
|
||||
const msg = _messages[i]
|
||||
if (msg.role === "assistant" && msg.id) {
|
||||
// Must be less than 24 hours old to be considered for chaining as the previous Id is only valid for 24 hours.
|
||||
// Set to 23 hours to account for any potential delays in processing.
|
||||
const isLessThan23HoursOld = msg.ts ? Date.now() - msg.ts < 23 * 60 * 60 * 1000 : false
|
||||
if (msg.role === "assistant" && msg.id && isLessThan23HoursOld) {
|
||||
previousResponseId = msg.id
|
||||
messages = _messages.slice(i + 1)
|
||||
break
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
|
||||
@@ -34,6 +35,7 @@ export async function createOpenRouterStream(
|
||||
const isClaude1m =
|
||||
model.id === openRouterClaudeSonnet41mModelId ||
|
||||
model.id === openRouterClaudeSonnet451mModelId ||
|
||||
model.id === openRouterClaudeSonnet461mModelId ||
|
||||
model.id === openRouterClaudeOpus461mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id openrouter API expects
|
||||
@@ -50,6 +52,8 @@ export async function createOpenRouterStream(
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet": // OpenRouter accidentally included this in model list for a brief moment, and users may be using this model id. And to support prompt caching, we need to add it here.
|
||||
case "anthropic/claude-sonnet-4":
|
||||
@@ -118,6 +122,8 @@ export async function createOpenRouterStream(
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet":
|
||||
case "anthropic/claude-sonnet-4":
|
||||
@@ -154,7 +160,7 @@ export async function createOpenRouterStream(
|
||||
topP = 0.95
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
|
||||
if (model.id.startsWith("google/gemini-3")) {
|
||||
// Recommended value from google
|
||||
temperature = 1.0
|
||||
}
|
||||
@@ -166,6 +172,8 @@ export async function createOpenRouterStream(
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet":
|
||||
case "anthropic/claude-sonnet-4":
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
|
||||
@@ -32,6 +33,7 @@ export async function createVercelAIGatewayStream(
|
||||
const isClaude1m =
|
||||
model.id === openRouterClaudeSonnet41mModelId ||
|
||||
model.id === openRouterClaudeSonnet451mModelId ||
|
||||
model.id === openRouterClaudeSonnet461mModelId ||
|
||||
model.id === openRouterClaudeOpus461mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id the API expects
|
||||
@@ -98,7 +100,7 @@ export async function createVercelAIGatewayStream(
|
||||
topP = 0.95
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
|
||||
if (model.id.startsWith("google/gemini-3")) {
|
||||
// Recommended value from google
|
||||
temperature = 1.0
|
||||
}
|
||||
|
||||
@@ -154,15 +154,21 @@ export class ContextManager {
|
||||
thresholdPercentage?: number,
|
||||
): boolean {
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
const previousRequestText = clineMessages[previousApiReqIndex]?.text
|
||||
if (previousRequestText) {
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const roundedThreshold = thresholdPercentage ? Math.floor(contextWindow * thresholdPercentage) : maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return totalTokens >= thresholdTokens
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const roundedThreshold = thresholdPercentage
|
||||
? Math.floor(contextWindow * thresholdPercentage)
|
||||
: maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return totalTokens >= thresholdTokens
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -195,10 +201,10 @@ export class ContextManager {
|
||||
}
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
const targetRequest = clineMessages[targetIndex]
|
||||
if (targetRequest && targetRequest.text) {
|
||||
const targetRequestText = clineMessages[targetIndex]?.text
|
||||
if (targetRequestText) {
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequest.text)
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequestText)
|
||||
const tokensUsed = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
|
||||
const { contextWindow } = getContextWindowInfo(api)
|
||||
@@ -232,10 +238,10 @@ export class ContextManager {
|
||||
if (!useAutoCondense) {
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const timestamp = previousRequest.ts
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const previousRequestText = clineMessages[previousApiReqIndex]?.text
|
||||
if (previousRequestText) {
|
||||
const timestamp = clineMessages[previousApiReqIndex].ts
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
const { maxAllowedSize } = getContextWindowInfo(api)
|
||||
|
||||
@@ -846,9 +852,8 @@ export class ContextManager {
|
||||
}
|
||||
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
|
||||
// to do so we need to keep track of which files we've already replaced so we don't replace them again
|
||||
else {
|
||||
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
|
||||
}
|
||||
|
||||
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
|
||||
}
|
||||
} else {
|
||||
// for all other cases we can assume that we dont need to check this again
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { expect } from "chai"
|
||||
import { ContextManager } from "../ContextManager"
|
||||
|
||||
// Minimal mock for ApiHandler — only getModel().info.contextWindow is used by shouldCompactContextWindow
|
||||
function createMockApi(contextWindow: number) {
|
||||
return {
|
||||
getModel: () => ({ id: "test-model", info: { contextWindow } }),
|
||||
} as any
|
||||
}
|
||||
|
||||
function createApiReqMessage(tokens: {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
}): ClineMessage {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
@@ -381,4 +403,93 @@ describe("ContextManager", () => {
|
||||
expect((content[0] as Anthropic.Messages.TextBlockParam).text).to.equal("Additional user text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldCompactContextWindow", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("does not compact at 33K tokens with default 0.75 threshold on 200K context", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 30_000, tokensOut: 3_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("compacts when tokens exceed 0.75 threshold on 200K context", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 140_000, tokensOut: 15_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("compacts at only 10K tokens when threshold is accidentally set to 0.05", () => {
|
||||
const contextWindow = 200_000
|
||||
const accidentalThreshold = 0.05
|
||||
// floor(200000 * 0.05) = 10000 — this is the bug case from PR #9348.
|
||||
// Accidental clicks on the progress bar set threshold to ~5%, triggering
|
||||
// compaction at 10K tokens instead of the intended 150K (0.75 * 200K).
|
||||
const compactionTriggersAt = Math.floor(contextWindow * accidentalThreshold) // 10,000
|
||||
const totalTokens = compactionTriggersAt + 500 // 10,500 — just above the trigger
|
||||
|
||||
const api = createMockApi(contextWindow)
|
||||
const tokensIn = totalTokens - 1_500
|
||||
const tokensOut = 1_500
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn, tokensOut })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, accidentalThreshold)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("falls back to maxAllowedSize when threshold is undefined", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// 155K tokens — above 0.75 threshold (150K) but below maxAllowedSize (160K)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, undefined)
|
||||
// undefined → uses maxAllowedSize (160K), so 155K < 160K → false
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("falls back to maxAllowedSize when threshold is 0", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
|
||||
|
||||
// 0 is falsy, so ternary falls back to maxAllowedSize (160K)
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("includes cacheWrites and cacheReads in total token count", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// Low direct tokens but high cache reads push total over threshold
|
||||
const clineMessages: ClineMessage[] = [
|
||||
createApiReqMessage({ tokensIn: 5_000, tokensOut: 500, cacheWrites: 0, cacheReads: 150_000 }),
|
||||
]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
|
||||
it("returns false when previousApiReqIndex is negative", () => {
|
||||
const api = createMockApi(200_000)
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 200_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, -1, 0.75)
|
||||
expect(result).to.equal(false)
|
||||
})
|
||||
|
||||
it("threshold is capped at maxAllowedSize even when percentage is very high", () => {
|
||||
const api = createMockApi(200_000)
|
||||
// threshold of 1.0 → floor(200000 * 1.0) = 200000, but min(200000, 160000) = 160000
|
||||
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 165_000 })]
|
||||
|
||||
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 1.0)
|
||||
expect(result).to.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { getTaskMetadata, readTaskHistoryFromState, saveTaskMetadata } from "@co
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getCwd } from "@/utils/path"
|
||||
import type { FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
@@ -250,6 +250,8 @@ export class FileContextTracker {
|
||||
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
|
||||
try {
|
||||
const key = `pendingFileContextWarning_${this.taskId}`
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
const files = this.controller.stateManager.getWorkspaceStateKey(key as any) as string[]
|
||||
return files
|
||||
} catch (error) {
|
||||
@@ -265,6 +267,8 @@ export class FileContextTracker {
|
||||
try {
|
||||
const files = await this.retrievePendingFileContextWarning()
|
||||
if (files) {
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
this.controller.stateManager.setWorkspaceState(`pendingFileContextWarning_${this.taskId}` as any, undefined)
|
||||
return files
|
||||
}
|
||||
@@ -278,12 +282,12 @@ export class FileContextTracker {
|
||||
* Static method to clean up orphaned pending file context warnings at startup
|
||||
* This removes warnings for tasks that may no longer exist
|
||||
*/
|
||||
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
|
||||
static async cleanupOrphanedWarnings(stateManager: StateManager): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const taskHistory = await readTaskHistoryFromState()
|
||||
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
|
||||
const allStateKeys = context.workspaceState.keys()
|
||||
const allStateKeys = Object.keys(stateManager.getAllWorkspaceStateEntries())
|
||||
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
|
||||
|
||||
const orphanedPendingContextTasks: string[] = []
|
||||
@@ -296,8 +300,9 @@ export class FileContextTracker {
|
||||
|
||||
if (orphanedPendingContextTasks.length > 0) {
|
||||
for (const key of orphanedPendingContextTasks) {
|
||||
// eslint-disable-next-line eslint-rules/no-direct-vscode-state-api
|
||||
await context.workspaceState.update(key, undefined)
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
await stateManager.setWorkspaceState(key as any, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { buildApiHandler } from "@core/api"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { tryAcquireTaskLockWithRetry } from "@core/task/TaskLockUtils"
|
||||
import { detectWorkspaceRoots } from "@core/workspace/detection"
|
||||
@@ -13,7 +12,7 @@ import type { ChatContent } from "@shared/ChatContent"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import type { McpMarketplaceCatalog, McpMarketplaceItem } from "@shared/mcp"
|
||||
import type { Settings } from "@shared/storage/state-keys"
|
||||
import { type Settings } from "@shared/storage/state-keys"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import type { UserInfo } from "@shared/UserInfo"
|
||||
@@ -23,7 +22,6 @@ import fs from "fs/promises"
|
||||
import open from "open"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import type * as vscode from "vscode"
|
||||
import { ClineEnv } from "@/config"
|
||||
import type { FolderLockWithRetryResult } from "@/core/locks/types"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
@@ -35,6 +33,7 @@ import { BannerService } from "@/services/banner/BannerService"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -117,7 +116,7 @@ export class Controller {
|
||||
this.remoteConfigTimer = setInterval(() => fetchRemoteConfig(this), 3600000) // 1 hour
|
||||
}
|
||||
|
||||
constructor(readonly context: vscode.ExtensionContext) {
|
||||
constructor(readonly context: ClineExtensionContext) {
|
||||
Session.reset() // Reset session on controller initialization
|
||||
PromptRegistry.getInstance() // Ensure prompts and tools are registered
|
||||
this.stateManager = StateManager.get()
|
||||
@@ -370,10 +369,10 @@ export class Controller {
|
||||
// Switch to act mode
|
||||
this.stateManager.setGlobalState("mode", modeToSwitchTo)
|
||||
|
||||
// Update API handler with new mode (buildApiHandler now selects provider based on mode)
|
||||
// Update API handler with new mode
|
||||
if (this.task) {
|
||||
const apiConfiguration = this.stateManager.getApiConfiguration()
|
||||
this.task.api = buildApiHandler({ ...apiConfiguration, ulid: this.task.ulid }, modeToSwitchTo)
|
||||
this.task.updateApiHandler(apiConfiguration, modeToSwitchTo)
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
@@ -394,10 +393,10 @@ export class Controller {
|
||||
// Capture mode switch telemetry | Capture regardless of if we know the taskId
|
||||
telemetryService.captureModeSwitch(this.task?.ulid ?? "0", modeToSwitchTo)
|
||||
|
||||
// Update API handler with new mode (buildApiHandler now selects provider based on mode)
|
||||
// Update API handler with new mode
|
||||
if (this.task) {
|
||||
const apiConfiguration = this.stateManager.getApiConfiguration()
|
||||
this.task.api = buildApiHandler({ ...apiConfiguration, ulid: this.task.ulid }, modeToSwitchTo)
|
||||
this.task.updateApiHandler(apiConfiguration, modeToSwitchTo)
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
@@ -547,7 +546,7 @@ export class Controller {
|
||||
await fetchRemoteConfig(this)
|
||||
|
||||
if (this.task) {
|
||||
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
|
||||
this.task.updateApiHandler(updatedConfig, currentMode)
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
@@ -598,7 +597,7 @@ export class Controller {
|
||||
this.stateManager.setGlobalState("welcomeViewCompleted", true)
|
||||
|
||||
if (this.task) {
|
||||
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
|
||||
this.task.updateApiHandler(updatedConfig, currentMode)
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
@@ -716,7 +715,7 @@ export class Controller {
|
||||
|
||||
await this.postStateToWebview()
|
||||
if (this.task) {
|
||||
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
|
||||
this.task.updateApiHandler(updatedConfig, currentMode)
|
||||
}
|
||||
// Dont send settingsButtonClicked because its bad ux if user is on welcome
|
||||
}
|
||||
@@ -736,7 +735,7 @@ export class Controller {
|
||||
this.stateManager.setApiConfiguration(updatedConfig)
|
||||
await this.postStateToWebview()
|
||||
if (this.task) {
|
||||
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
|
||||
this.task.updateApiHandler(updatedConfig, currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,7 +775,7 @@ export class Controller {
|
||||
await this.postStateToWebview()
|
||||
this.accountService
|
||||
if (this.task) {
|
||||
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
|
||||
this.task.updateApiHandler(updatedConfig, currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,7 +893,6 @@ export class Controller {
|
||||
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
|
||||
const localAgentsRulesToggles = this.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
|
||||
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
// Spread to create new array reference - React needs this to detect changes in useEffect dependencies
|
||||
@@ -914,6 +912,7 @@ export class Controller {
|
||||
const clineConfig = ClineEnv.config()
|
||||
const environment = clineConfig.environment
|
||||
const banners = BannerService.get().getActiveBanners() ?? []
|
||||
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
|
||||
|
||||
// Check OpenAI Codex authentication status
|
||||
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
|
||||
@@ -976,7 +975,6 @@ export class Controller {
|
||||
taskHistory: processedTaskHistory,
|
||||
shouldShowAnnouncement,
|
||||
favoritedModelIds,
|
||||
autoCondenseThreshold,
|
||||
backgroundCommandRunning: this.backgroundCommandRunning,
|
||||
backgroundCommandTaskId: this.backgroundCommandTaskId,
|
||||
// NEW: Add workspace information
|
||||
@@ -1007,6 +1005,7 @@ export class Controller {
|
||||
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
|
||||
doubleCheckCompletionEnabled,
|
||||
banners,
|
||||
welcomeBanners,
|
||||
openAiCodexIsAuthenticated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,21 +12,34 @@ export function getClineOnboardingModels(): OnboardingModelGroup {
|
||||
}
|
||||
|
||||
const remoteOverrides = featureFlagsService.getOnboardingOverrides()
|
||||
const models = new Map<string, OnboardingModel>(CLINE_ONBOARDING_MODELS.map((model) => [model.id, model]))
|
||||
const models = [...CLINE_ONBOARDING_MODELS]
|
||||
|
||||
// Apply remote overrides if available
|
||||
if (remoteOverrides) {
|
||||
for (const [id, override] of Object.entries(remoteOverrides) as [string, OnboardingModelOverride][]) {
|
||||
if (override.hidden) {
|
||||
models.delete(id)
|
||||
for (let i = models.length - 1; i >= 0; i--) {
|
||||
if (models[i].id === id) {
|
||||
models.splice(i, 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const baseModel = models.get(id)
|
||||
models.set(id, mergeModelWithOverride(baseModel, override))
|
||||
let found = false
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
if (models[i].id === id) {
|
||||
models[i] = mergeModelWithOverride(models[i], override)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
models.push(mergeModelWithOverride(undefined, override))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cached = { models: Array.from(models.values()) }
|
||||
cached = { models }
|
||||
return cached
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeSonnet41mModelId,
|
||||
openRouterClaudeSonnet451mModelId,
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@/shared/api"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -118,7 +119,7 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
const rawModels = response.data.data
|
||||
const parsePrice = (price: any) => {
|
||||
if (price) {
|
||||
return parseFloat(price) * 1_000_000
|
||||
return Number.parseFloat(price) * 1_000_000
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -144,6 +145,8 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
}
|
||||
|
||||
switch (rawModel.id) {
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet":
|
||||
case "anthropic/claude-sonnet-4":
|
||||
@@ -264,14 +267,28 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
models[rawModel.id] = modelInfo
|
||||
|
||||
// add custom :1m model variant for sonnet
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4" || rawModel.id === "anthropic/claude-sonnet-4.5") {
|
||||
if (
|
||||
rawModel.id === "anthropic/claude-sonnet-4" ||
|
||||
rawModel.id === "anthropic/claude-sonnet-4.5" ||
|
||||
rawModel.id === "anthropic/claude-4.5-sonnet" ||
|
||||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
|
||||
rawModel.id === "anthropic/claude-4.6-sonnet"
|
||||
) {
|
||||
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeSonnet1mModelInfo.contextWindow = 1_000_000 // limiting providers to those that support 1m context window
|
||||
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
|
||||
// sonnet 4
|
||||
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4") {
|
||||
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
|
||||
}
|
||||
// sonnet 4.5
|
||||
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4.5" || rawModel.id === "anthropic/claude-4.5-sonnet") {
|
||||
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
|
||||
}
|
||||
// sonnet 4.6
|
||||
if (rawModel.id === "anthropic/claude-sonnet-4.6" || rawModel.id === "anthropic/claude-4.6-sonnet") {
|
||||
models[openRouterClaudeSonnet461mModelId] = claudeSonnet1mModelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// add custom :1m model variant for opus 4.6
|
||||
|
||||
@@ -68,8 +68,8 @@ function deriveTemperature(modelId: string): number | undefined {
|
||||
return 0.7
|
||||
}
|
||||
|
||||
// Gemini 3.0 recommends temperature 1.0
|
||||
if (modelId.startsWith("google/gemini-3.0") || modelId === "google/gemini-3.0") {
|
||||
// Gemini 3 models recommend temperature 1.0
|
||||
if (modelId.startsWith("google/gemini-3")) {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import { ApiHandlerOptions, ApiProvider } from "@/shared/api"
|
||||
import { UpdateApiConfigurationRequestNew } from "@/shared/proto/index.cline"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
@@ -41,7 +40,8 @@ function parseFieldMask(updateMask: string[]): {
|
||||
function getAlternateModeField(fieldName: string): string | null {
|
||||
if (fieldName.startsWith("planMode")) {
|
||||
return fieldName.replace("planMode", "actMode")
|
||||
} else if (fieldName.startsWith("actMode")) {
|
||||
}
|
||||
if (fieldName.startsWith("actMode")) {
|
||||
return fieldName.replace("actMode", "planMode")
|
||||
}
|
||||
return null
|
||||
@@ -141,14 +141,7 @@ export async function updateApiConfiguration(controller: Controller, request: Up
|
||||
// Update the task's API handler if there's an active task
|
||||
if (controller.task) {
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
// Build updated config
|
||||
controller.task.api = buildApiHandler(
|
||||
{
|
||||
...controller.stateManager.getApiConfiguration(),
|
||||
ulid: controller.task.ulid,
|
||||
},
|
||||
currentMode,
|
||||
)
|
||||
controller.task.updateApiHandler(controller.stateManager.getApiConfiguration(), currentMode)
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildApiHandler } from "@core/api"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { UpdateApiConfigurationPartialRequest } from "@shared/proto/cline/models"
|
||||
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
@@ -44,7 +43,7 @@ export async function updateApiConfigurationPartial(
|
||||
controller.stateManager.setApiConfiguration(updatedConfig)
|
||||
if (controller.task) {
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
controller.task.api = buildApiHandler({ ...updatedConfig, ulid: controller.task.ulid }, currentMode)
|
||||
controller.task.updateApiHandler(updatedConfig, currentMode)
|
||||
}
|
||||
|
||||
// Notify webview
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
fromProtobufOpenAiCompatibleModelInfo,
|
||||
} from "@shared/proto-conversions/models/typeConversion"
|
||||
import { OpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
@@ -124,10 +123,7 @@ export async function updateApiConfigurationProto(
|
||||
// Update the task's API handler if there's an active task
|
||||
if (controller.task) {
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
controller.task.api = buildApiHandler(
|
||||
{ ...convertedApiConfigurationFromProto, ulid: controller.task.ulid },
|
||||
currentMode,
|
||||
)
|
||||
controller.task.updateApiHandler(convertedApiConfigurationFromProto, currentMode)
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
|
||||
@@ -20,13 +20,13 @@ export async function resetState(controller: Controller, request: ResetStateRequ
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Resetting global state...",
|
||||
})
|
||||
await resetGlobalState(controller)
|
||||
await resetGlobalState()
|
||||
} else {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Resetting workspace state...",
|
||||
})
|
||||
await resetWorkspaceState(controller)
|
||||
await resetWorkspaceState()
|
||||
}
|
||||
|
||||
if (controller.task) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildApiHandler } from "@core/api"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, McpDisplayMode as ProtoMcpDisplayMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
@@ -49,11 +48,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
|
||||
if (controller.task) {
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfigForHandler = {
|
||||
...convertedApiConfigurationFromProto,
|
||||
ulid: controller.task.ulid,
|
||||
}
|
||||
controller.task.api = buildApiHandler(apiConfigForHandler, currentMode)
|
||||
controller.task.updateApiHandler(convertedApiConfigurationFromProto, currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +180,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
telemetryService.captureAutoCondenseToggle(
|
||||
controller.task.ulid,
|
||||
request.useAutoCondense,
|
||||
controller.task.api.getModel().id,
|
||||
controller.task.getModel().id,
|
||||
)
|
||||
}
|
||||
controller.stateManager.setGlobalState("useAutoCondense", request.useAutoCondense)
|
||||
@@ -301,11 +296,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("backgroundEditEnabled", !!request.backgroundEditEnabled)
|
||||
}
|
||||
|
||||
if (request.autoCondenseThreshold !== undefined) {
|
||||
const threshold = Math.min(1, Math.max(0, request.autoCondenseThreshold)) // Clamp to 0-1 range
|
||||
controller.stateManager.setGlobalState("autoCondenseThreshold", threshold)
|
||||
}
|
||||
|
||||
if (request.multiRootEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("multiRootEnabled", !!request.multiRootEnabled)
|
||||
}
|
||||
@@ -317,7 +307,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.task.ulid,
|
||||
"native-tool-call",
|
||||
request.nativeToolCallEnabled,
|
||||
controller.task.api.getModel().id,
|
||||
controller.task.getModel().id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { buildApiHandler } from "@core/api"
|
||||
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, UpdateSettingsRequestCli } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
@@ -118,11 +116,7 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
|
||||
|
||||
if (controller.task) {
|
||||
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfigForHandler = {
|
||||
...controller.stateManager.getApiConfiguration(),
|
||||
ulid: controller.task.ulid,
|
||||
}
|
||||
controller.task.api = buildApiHandler(apiConfigForHandler, currentMode)
|
||||
controller.task.updateApiHandler(controller.stateManager.getApiConfiguration(), currentMode)
|
||||
}
|
||||
|
||||
// Update telemetry setting
|
||||
@@ -144,7 +138,7 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
|
||||
telemetryService.captureAutoCondenseToggle(
|
||||
controller.task.ulid,
|
||||
useAutoCondense,
|
||||
controller.task.api.getModel().id,
|
||||
controller.task.getModel().id,
|
||||
)
|
||||
}
|
||||
controller.stateManager.setGlobalState("useAutoCondense", useAutoCondense)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { formatResponse } from "../responses"
|
||||
|
||||
describe("formatResponse.writeToFileMissingContentError", () => {
|
||||
describe("first failure (tier 1)", () => {
|
||||
it("should include the file path in the error message", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
|
||||
result.should.containEql("src/index.ts")
|
||||
})
|
||||
|
||||
it("should include the base error explanation", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
|
||||
result.should.containEql("'content' parameter was empty")
|
||||
result.should.containEql("output token limits")
|
||||
})
|
||||
|
||||
it("should include helpful suggestions", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
|
||||
result.should.containEql("Suggestions")
|
||||
result.should.containEql("replace_in_file")
|
||||
result.should.containEql("skeleton")
|
||||
})
|
||||
|
||||
it("should include the tool use instructions reminder", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
|
||||
result.should.containEql("Reminder: Instructions for Tool Use")
|
||||
})
|
||||
|
||||
it("should mention breaking down the task into smaller steps", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
|
||||
result.should.containEql("breaking down the task into smaller steps")
|
||||
})
|
||||
|
||||
it("should suggest using replace_in_file for existing files", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
|
||||
result.should.containEql("prefer replace_in_file to make targeted edits")
|
||||
})
|
||||
|
||||
it("should not include CRITICAL language on first failure", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
|
||||
result.should.not.containEql("CRITICAL")
|
||||
})
|
||||
|
||||
it("should work with different file paths", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("components/MyComponent.tsx", 1)
|
||||
result.should.containEql("components/MyComponent.tsx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("second failure (tier 2)", () => {
|
||||
it("should indicate this is the 2nd failed attempt", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
|
||||
result.should.containEql("2nd failed attempt")
|
||||
})
|
||||
|
||||
it("should strongly suggest alternative approaches", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
|
||||
result.should.containEql("must use a different strategy")
|
||||
result.should.containEql("Recommended approaches")
|
||||
})
|
||||
|
||||
it("should tell model not to retry full write again", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
|
||||
result.should.containEql("Do NOT attempt to write the full file content")
|
||||
})
|
||||
|
||||
it("should not include CRITICAL language on second failure", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
|
||||
result.should.not.containEql("CRITICAL")
|
||||
})
|
||||
})
|
||||
|
||||
describe("third+ failure (tier 3)", () => {
|
||||
it("should include CRITICAL language on third failure", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3)
|
||||
result.should.containEql("CRITICAL")
|
||||
result.should.containEql("3 times in a row")
|
||||
})
|
||||
|
||||
it("should tell model to NOT retry write_to_file", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3)
|
||||
result.should.containEql("do NOT retry write_to_file")
|
||||
})
|
||||
|
||||
it("should include required action strategies", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3)
|
||||
result.should.containEql("Required action")
|
||||
result.should.containEql("replace_in_file")
|
||||
result.should.containEql("50-100 lines")
|
||||
})
|
||||
|
||||
it("should show correct count for higher failure counts", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 5)
|
||||
result.should.containEql("5 times in a row")
|
||||
})
|
||||
})
|
||||
|
||||
describe("context window awareness", () => {
|
||||
it("should include context warning when usage exceeds 50%", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, 60)
|
||||
result.should.containEql("60% full")
|
||||
result.should.containEql("MUST use a strategy that produces smaller outputs")
|
||||
})
|
||||
|
||||
it("should not include context warning when usage is 50% or below", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, 50)
|
||||
result.should.not.containEql("% full")
|
||||
})
|
||||
|
||||
it("should not include context warning when percent is undefined", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, undefined)
|
||||
result.should.not.containEql("% full")
|
||||
})
|
||||
|
||||
it("should show high context window usage correctly", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, 85)
|
||||
result.should.containEql("85% full")
|
||||
})
|
||||
|
||||
it("should include context warning in tier 3 messages", () => {
|
||||
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3, 75)
|
||||
result.should.containEql("75% full")
|
||||
result.should.containEql("CRITICAL")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,8 @@ import * as path from "path"
|
||||
import { Mode } from "@/shared/storage/types"
|
||||
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
|
||||
|
||||
const CONTEXT_WINDOW_WARNING_THRESHOLD_PERCENT = 50
|
||||
|
||||
export const formatResponse = {
|
||||
duplicateFileReadNotice: () =>
|
||||
`[[NOTE] This file read has been removed to save space in the context window. Refer to the latest file read for the most up to date version of this file.]`,
|
||||
@@ -46,6 +48,54 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
missingToolParameterError: (paramName: string) =>
|
||||
`Missing value for required parameter '${paramName}'. Please retry with complete response.\n\n${toolUseInstructionsReminder}`,
|
||||
|
||||
/**
|
||||
* Specialized error for write_to_file when the 'content' parameter is missing.
|
||||
* Provides progressive guidance based on how many times this has happened consecutively,
|
||||
* and includes token budget awareness to help the model understand output constraints.
|
||||
*/
|
||||
writeToFileMissingContentError: (relPath: string, consecutiveFailures: number, contextUsagePercent?: number): string => {
|
||||
const baseError = `Failed to write to '${relPath}': The 'content' parameter was empty. This typically happens when the file content is too large to generate in a single response, or when output token limits are reached before the content parameter is fully written.`
|
||||
|
||||
const contextWarning =
|
||||
contextUsagePercent !== undefined && contextUsagePercent > CONTEXT_WINDOW_WARNING_THRESHOLD_PERCENT
|
||||
? `\n\nWarning: Context window is ${contextUsagePercent}% full. The remaining output budget may be insufficient for large file writes. You MUST use a strategy that produces smaller outputs.`
|
||||
: ""
|
||||
|
||||
if (consecutiveFailures >= 3) {
|
||||
// After 3+ failures, be very directive — stop trying write_to_file entirely
|
||||
return (
|
||||
`${baseError}${contextWarning}\n\n` +
|
||||
`CRITICAL: You have failed to write this file ${consecutiveFailures} times in a row. You MUST change your approach — do NOT retry write_to_file for this file again.\n\n` +
|
||||
`Required action — choose ONE of these strategies:\n` +
|
||||
`1. **Create an empty file first, then use replace_in_file** to add content in small sections (recommended)\n` +
|
||||
`2. **Break the file into multiple smaller files** if architecturally appropriate\n` +
|
||||
`3. **Write a minimal skeleton** using write_to_file (just imports, class/function signatures, no implementations), then use replace_in_file to fill in each section one at a time\n\n` +
|
||||
`Each replace_in_file call should add no more than 50-100 lines of content at a time.`
|
||||
)
|
||||
}
|
||||
if (consecutiveFailures >= 2) {
|
||||
// After 2 failures, strongly suggest alternative approaches
|
||||
return (
|
||||
`${baseError}${contextWarning}\n\n` +
|
||||
`This is your ${consecutiveFailures}${consecutiveFailures === 2 ? "nd" : "rd"} failed attempt. The file content is likely too large to generate in one response. You must use a different strategy:\n\n` +
|
||||
`Recommended approaches:\n` +
|
||||
`1. **Use write_to_file with a minimal skeleton** (just the structure — imports, class/function signatures, no implementations), then use replace_in_file to fill in each section incrementally\n` +
|
||||
`2. **Use replace_in_file with smaller chunks** — if the file already exists, make targeted edits instead of rewriting the entire file\n` +
|
||||
`3. **Break the task into smaller steps** — write one function or section at a time\n\n` +
|
||||
`Do NOT attempt to write the full file content in a single write_to_file call again.`
|
||||
)
|
||||
}
|
||||
// First failure — provide helpful guidance
|
||||
return (
|
||||
`${baseError}${contextWarning}\n\n` +
|
||||
`Suggestions:\n` +
|
||||
`- If the file is large, try breaking down the task into smaller steps. Write a skeleton first, then fill in sections using replace_in_file.\n` +
|
||||
`- If the file already exists, prefer replace_in_file to make targeted edits instead of rewriting the entire file.\n` +
|
||||
`- Ensure the 'content' parameter contains the complete file content before closing the tool tag.\n\n` +
|
||||
toolUseInstructionsReminder
|
||||
)
|
||||
},
|
||||
|
||||
invalidMcpToolArgumentError: (serverName: string, toolName: string) =>
|
||||
`Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`,
|
||||
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { ApiConfiguration, ModelInfo } from "@shared/api"
|
||||
import type { ApiConfiguration, ModelInfo } from "@shared/api"
|
||||
import {
|
||||
ApiHandlerSettingsKeys,
|
||||
GlobalState,
|
||||
GlobalStateAndSettings,
|
||||
GlobalStateAndSettingsKey,
|
||||
type GlobalState,
|
||||
type GlobalStateAndSettings,
|
||||
type GlobalStateAndSettingsKey,
|
||||
isSecretKey,
|
||||
isSettingsKey,
|
||||
LocalState,
|
||||
LocalStateKey,
|
||||
RemoteConfigFields,
|
||||
SecretKey,
|
||||
type LocalState,
|
||||
type LocalStateKey,
|
||||
type RemoteConfigFields,
|
||||
type SecretKey,
|
||||
SecretKeys,
|
||||
Secrets,
|
||||
Settings,
|
||||
SettingsKey,
|
||||
type Secrets,
|
||||
type Settings,
|
||||
type SettingsKey,
|
||||
} from "@shared/storage/state-keys"
|
||||
import type { StorageContext } from "@shared/storage/storage-context"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { initializeDistinctId } from "@/services/logging/distinctId"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
|
||||
import {
|
||||
getTaskHistoryStateFilePath,
|
||||
readTaskHistoryFromState,
|
||||
@@ -29,7 +28,7 @@ import {
|
||||
} from "./disk"
|
||||
import { STATE_MANAGER_NOT_INITIALIZED } from "./error-messages"
|
||||
import { filterAllowedRemoteConfigFields } from "./remote-config/utils"
|
||||
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
|
||||
import { readGlobalStateFromStorage, readSecretsFromStorage, readWorkspaceStateFromStorage } from "./utils/state-helpers"
|
||||
export interface PersistenceErrorEvent {
|
||||
error: Error
|
||||
}
|
||||
@@ -38,6 +37,9 @@ export interface PersistenceErrorEvent {
|
||||
* In-memory state manager for fast state access.
|
||||
* Provides immediate reads/writes with async disk persistence.
|
||||
*
|
||||
* All persistent storage is backed by file-based stores via StorageContext.
|
||||
* This is shared across all platforms (VSCode, CLI, JetBrains).
|
||||
*
|
||||
* MULTI-INSTANCE BEHAVIOR:
|
||||
* StateManager reads from disk ONLY during initialize(). After that, all reads come from
|
||||
* the in-memory cache. Writes update both the cache and disk, but other running instances
|
||||
@@ -57,10 +59,16 @@ export class StateManager {
|
||||
|
||||
private globalStateCache: GlobalStateAndSettings = {} as GlobalStateAndSettings
|
||||
private taskStateCache: Partial<Settings> = {}
|
||||
private sessionOverrideCache: Partial<Settings> = {}
|
||||
private remoteConfigCache: Partial<RemoteConfigFields> = {} as RemoteConfigFields
|
||||
private secretsCache: Secrets = {} as Secrets
|
||||
private workspaceStateCache: LocalState = {} as LocalState
|
||||
private context: ExtensionContext
|
||||
|
||||
/**
|
||||
* File-backed storage context. All reads/writes to persistent state go through here.
|
||||
* Do NOT access VSCode's ExtensionContext for storage — use this instead.
|
||||
*/
|
||||
private storage: StorageContext
|
||||
private isInitialized = false
|
||||
|
||||
// Cache TTL: 1 hour - long enough to prevent duplicate fetches, short enough to see new models
|
||||
@@ -107,17 +115,16 @@ export class StateManager {
|
||||
// Callback to sync external state changes with the UI client
|
||||
onSyncExternalChange?: () => void | Promise<void>
|
||||
|
||||
private constructor(context: ExtensionContext) {
|
||||
this.context = context
|
||||
secretStorage.init(context.secrets)
|
||||
private constructor(storage: StorageContext) {
|
||||
this.storage = storage
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cache by loading data from disk
|
||||
* Initialize the cache by loading data from the file-backed StorageContext.
|
||||
*/
|
||||
public static async initialize(context: ExtensionContext): Promise<StateManager> {
|
||||
public static async initialize(storage: StorageContext): Promise<StateManager> {
|
||||
if (!StateManager.instance) {
|
||||
StateManager.instance = new StateManager(context)
|
||||
StateManager.instance = new StateManager(storage)
|
||||
}
|
||||
|
||||
if (StateManager.instance.isInitialized) {
|
||||
@@ -125,11 +132,12 @@ export class StateManager {
|
||||
}
|
||||
|
||||
try {
|
||||
await initializeDistinctId(context)
|
||||
// Load all extension state from disk
|
||||
const globalState = await readGlobalStateFromDisk(context)
|
||||
const secrets = await readSecretsFromDisk()
|
||||
const workspaceState = await readWorkspaceStateFromDisk(context)
|
||||
await initializeDistinctId(storage)
|
||||
|
||||
// Load all extension state from file-backed stores
|
||||
const globalState = await readGlobalStateFromStorage(storage.globalState)
|
||||
const secrets = readSecretsFromStorage(storage.secrets)
|
||||
const workspaceState = readWorkspaceStateFromStorage(storage.workspaceState)
|
||||
|
||||
// Populate the cache with all extension state and secrets fields
|
||||
// Use populate method to avoid triggering persistence during initialization
|
||||
@@ -291,8 +299,6 @@ export class StateManager {
|
||||
this.pendingTaskState.clear()
|
||||
} catch (error) {
|
||||
Logger.error("[StateManager] Failed to persist task settings before clearing:", error)
|
||||
// If persistence fails, we just move on with clearing the in-memory state.
|
||||
// clearTaskSettings realistically probably won't be called in the small window of time between task settings being set and their persistence anyways
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +380,21 @@ export class StateManager {
|
||||
this.scheduleDebouncedPersistence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a session-scoped override for a settings key.
|
||||
* Session overrides are in-memory only and are NEVER persisted to disk.
|
||||
* They take precedence after remote config but before task-specific and global settings.
|
||||
*
|
||||
* Use this for CLI flags like --yolo that should apply for the current
|
||||
* process lifetime only, without modifying the user's saved settings.
|
||||
*/
|
||||
setSessionOverride<K extends keyof Settings>(key: K, value: Settings[K]): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
|
||||
}
|
||||
this.sessionOverrideCache[key] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Set method for remote config field - updates cache immediately (no persistence)
|
||||
* Remote config is read-only from the extension's perspective and only stored in memory
|
||||
@@ -599,17 +620,18 @@ export class StateManager {
|
||||
|
||||
/**
|
||||
* Get method for global settings keys - reads from in-memory cache
|
||||
* Precedence: remote config > task settings > global settings
|
||||
* Precedence: remote config > session override > task settings > global settings
|
||||
*/
|
||||
getGlobalSettingsKey<K extends keyof Settings>(key: K): Settings[K] {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
|
||||
}
|
||||
if (this.remoteConfigCache[key] !== undefined) {
|
||||
// type casting here, TS cannot infer that the key will ONLY be one of Settings
|
||||
|
||||
return this.remoteConfigCache[key] as Settings[K]
|
||||
}
|
||||
if (this.sessionOverrideCache[key] !== undefined) {
|
||||
return this.sessionOverrideCache[key] as Settings[K]
|
||||
}
|
||||
if (this.taskStateCache[key] !== undefined) {
|
||||
return this.taskStateCache[key]
|
||||
}
|
||||
@@ -624,7 +646,6 @@ export class StateManager {
|
||||
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
|
||||
}
|
||||
if (this.remoteConfigCache[key] !== undefined) {
|
||||
// type casting here, TS cannot infer that the key will ONLY be one of GlobalState
|
||||
return this.remoteConfigCache[key] as GlobalState[K]
|
||||
}
|
||||
return this.globalStateCache[key]
|
||||
@@ -655,11 +676,14 @@ export class StateManager {
|
||||
* Used for error recovery when write operations fail
|
||||
*/
|
||||
async reInitialize(currentTaskId?: string): Promise<void> {
|
||||
if (this.persistenceTimeout) {
|
||||
await this.persistPendingState()
|
||||
}
|
||||
// Clear all cached data and pending state
|
||||
this.dispose()
|
||||
|
||||
// Reinitialize from disk
|
||||
await StateManager.initialize(this.context)
|
||||
// Reinitialize from the same storage context
|
||||
await StateManager.initialize(this.storage)
|
||||
|
||||
// If there's an active task, reload its settings
|
||||
if (currentTaskId) {
|
||||
@@ -691,6 +715,7 @@ export class StateManager {
|
||||
this.workspaceStateCache = {} as LocalState
|
||||
this.taskStateCache = {}
|
||||
this.remoteConfigCache = {} as GlobalStateAndSettings
|
||||
this.sessionOverrideCache = {}
|
||||
|
||||
this.isInitialized = false
|
||||
}
|
||||
@@ -765,21 +790,25 @@ export class StateManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to batch persist global state keys with Promise.all
|
||||
* Persist global state keys to the file-backed store.
|
||||
* Uses setBatch for efficiency (single disk write).
|
||||
*/
|
||||
private async persistGlobalStateBatch(keys: Set<GlobalStateAndSettingsKey>): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(keys).map((key) => {
|
||||
if (key === "taskHistory") {
|
||||
// Route task history persistence to file, not VS Code globalState
|
||||
return writeTaskHistoryToState(this.globalStateCache[key])
|
||||
}
|
||||
return this.context.globalState.update(key, this.globalStateCache[key])
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
throw error
|
||||
// Separate taskHistory (goes to its own file) from regular global state
|
||||
const regularEntries: Record<string, any> = {}
|
||||
|
||||
for (const key of keys) {
|
||||
if (key === "taskHistory") {
|
||||
// Route task history persistence to its own file
|
||||
await writeTaskHistoryToState(this.globalStateCache[key])
|
||||
} else {
|
||||
regularEntries[key] = this.globalStateCache[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Batch write all regular keys in a single disk operation
|
||||
if (Object.keys(regularEntries).length > 0) {
|
||||
this.storage.globalStateBackingStore.setBatch(regularEntries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,61 +819,47 @@ export class StateManager {
|
||||
if (pendingTaskStates.size === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Persist each task's settings
|
||||
await Promise.all(
|
||||
Array.from(pendingTaskStates.entries()).map(([taskId, keys]) => {
|
||||
if (keys.size === 0) {
|
||||
return Promise.resolve()
|
||||
// Persist each task's settings
|
||||
await Promise.all(
|
||||
Array.from(pendingTaskStates.entries()).map(([taskId, keys]) => {
|
||||
if (keys.size === 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const settingsToWrite: Record<string, any> = {}
|
||||
for (const key of keys) {
|
||||
const value = this.taskStateCache[key]
|
||||
if (value !== undefined) {
|
||||
settingsToWrite[key] = value
|
||||
}
|
||||
const settingsToWrite: Record<string, any> = {}
|
||||
for (const key of keys) {
|
||||
const value = this.taskStateCache[key]
|
||||
if (value !== undefined) {
|
||||
settingsToWrite[key] = value
|
||||
}
|
||||
}
|
||||
return writeTaskSettingsToStorage(taskId, settingsToWrite)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return writeTaskSettingsToStorage(taskId, settingsToWrite)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to batch persist secrets with Promise.all
|
||||
* Persist secrets to the file-backed store.
|
||||
* Uses setBatch for efficiency (single disk write).
|
||||
*/
|
||||
private async persistSecretsBatch(keys: Set<SecretKey>): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(keys).map((key) => {
|
||||
const value = this.secretsCache[key]
|
||||
if (value) {
|
||||
return this.context.secrets.store(key, value)
|
||||
}
|
||||
return this.context.secrets.delete(key)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
throw error
|
||||
const entries: Record<string, string | undefined> = {}
|
||||
for (const key of keys) {
|
||||
const value = this.secretsCache[key]
|
||||
entries[key] = value || undefined // Convert empty strings to undefined (delete)
|
||||
}
|
||||
this.storage.secrets.setBatch(entries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to batch persist workspace state keys with Promise.all
|
||||
* Persist workspace state to the file-backed store.
|
||||
* Uses setBatch for efficiency (single disk write).
|
||||
*/
|
||||
private async persistWorkspaceStateBatch(keys: Set<LocalStateKey>): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(keys).map((key) => {
|
||||
const value = this.workspaceStateCache[key]
|
||||
return this.context.workspaceState.update(key, value)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
throw error
|
||||
const entries: Record<string, any> = {}
|
||||
for (const key of keys) {
|
||||
entries[key] = this.workspaceStateCache[key]
|
||||
}
|
||||
this.storage.workspaceState.setBatch(entries)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -261,14 +261,13 @@ export async function getSavedClineMessages(taskId: string): Promise<ClineMessag
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.uiMessages)
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
} else {
|
||||
// check old location
|
||||
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
|
||||
if (await fileExistsAtPath(oldPath)) {
|
||||
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
|
||||
await fs.unlink(oldPath) // remove old file
|
||||
return data
|
||||
}
|
||||
}
|
||||
// check old location
|
||||
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
|
||||
if (await fileExistsAtPath(oldPath)) {
|
||||
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
|
||||
await fs.unlink(oldPath) // remove old file
|
||||
return data
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as fs from "fs/promises"
|
||||
import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
const REMOTE_CONFIG_WATCHER_GRACE_MS = 300
|
||||
|
||||
/**
|
||||
* Synchronizes remote MCP servers from remote config to the local MCP settings file
|
||||
* This allows admins to centrally configure MCP servers that are automatically deployed to users
|
||||
@@ -33,6 +35,7 @@ export async function syncRemoteMcpServersToSettings(
|
||||
// Read current settings
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
const initialSerializedConfig = JSON.stringify(config)
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
||||
@@ -81,9 +84,18 @@ export async function syncRemoteMcpServersToSettings(
|
||||
}
|
||||
|
||||
try {
|
||||
const nextSerializedConfig = JSON.stringify(config)
|
||||
if (initialSerializedConfig === nextSerializedConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
|
||||
} finally {
|
||||
// Keep the flag set briefly to outlive chokidar's awaitWriteFinish delay.
|
||||
// Otherwise the watcher may process our own remote-config write and reconnect servers.
|
||||
await new Promise((resolve) => setTimeout(resolve, REMOTE_CONFIG_WATCHER_GRACE_MS))
|
||||
|
||||
// Always clear flag, even if write fails
|
||||
if (mcpHub) {
|
||||
mcpHub.setIsUpdatingFromRemoteConfig(false)
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as vscode from "vscode"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ensureRulesDirectoryExists, readTaskHistoryFromState, writeTaskHistoryToState } from "./disk"
|
||||
import { StateManager } from "./StateManager"
|
||||
|
||||
export async function migrateWorkspaceToGlobalStorage(context: vscode.ExtensionContext) {
|
||||
// Keys to migrate from workspace storage back to global storage
|
||||
@@ -665,12 +664,12 @@ export async function cleanupMcpMarketplaceCatalogFromGlobalState(context: vscod
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupOldApiKey() {
|
||||
export async function cleanupOldApiKey(context: vscode.ExtensionContext) {
|
||||
try {
|
||||
// Old API Keys were introduced in March 2025 and later replaced with tokens
|
||||
// Now that we have new API keys that are prefixed with `sk_`,
|
||||
// we need to clean up the old ones to free the secret storage
|
||||
StateManager.get().setSecret("clineApiKey", undefined)
|
||||
await context.secrets.delete("clineApiKey")
|
||||
} catch (error) {
|
||||
Logger.error("Failed to cleanup old clineApiKey", error)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProvider } from "@shared/api"
|
||||
import type { ClineFileStorage } from "@shared/storage/ClineFileStorage"
|
||||
import {
|
||||
applyTransform,
|
||||
GlobalStateAndSettingKeys,
|
||||
@@ -11,88 +12,81 @@ import {
|
||||
SecretKeys,
|
||||
Secrets,
|
||||
} from "@shared/storage/state-keys"
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { ClineRulesToggles } from "@/shared/cline-rules"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { secretStorage } from "@/shared/storage"
|
||||
import { ClineMemento } from "@/shared/storage"
|
||||
import { readTaskHistoryFromState } from "../disk"
|
||||
import { StateManager } from "../StateManager"
|
||||
|
||||
export async function readSecretsFromDisk(): Promise<Secrets> {
|
||||
const secrets = await Promise.all(SecretKeys.map((key) => secretStorage.get(key)))
|
||||
// ─── File-backed storage readers (used by StateManager) ────────────────────
|
||||
|
||||
return SecretKeys.reduce((acc, key, index) => {
|
||||
acc[key] = secrets[index]
|
||||
/**
|
||||
* Read secrets from a ClineFileStorage instance.
|
||||
*/
|
||||
export function readSecretsFromStorage(store: ClineFileStorage<string>): Secrets {
|
||||
return SecretKeys.reduce((acc, key) => {
|
||||
acc[key] = store.get(key)
|
||||
return acc
|
||||
}, {} as Secrets)
|
||||
}
|
||||
|
||||
export async function readWorkspaceStateFromDisk(context: ExtensionContext): Promise<LocalState> {
|
||||
const states = LocalStateKeys.map((key) => context.workspaceState.get<ClineRulesToggles | undefined>(key))
|
||||
|
||||
return LocalStateKeys.reduce((acc, key, index) => {
|
||||
acc[key] = states[index] || {}
|
||||
/**
|
||||
* Read workspace state from a ClineFileStorage instance.
|
||||
*/
|
||||
export function readWorkspaceStateFromStorage(store: ClineFileStorage): LocalState {
|
||||
return LocalStateKeys.reduce((acc, key) => {
|
||||
acc[key] = store.get(key) || {}
|
||||
return acc
|
||||
}, {} as LocalState)
|
||||
}
|
||||
|
||||
export async function readGlobalStateFromDisk(context: ExtensionContext): Promise<GlobalStateAndSettings> {
|
||||
/**
|
||||
* Read global state from a ClineFileStorage instance.
|
||||
*/
|
||||
export async function readGlobalStateFromStorage(store: ClineMemento): Promise<GlobalStateAndSettings> {
|
||||
try {
|
||||
// Batch read all state values in a single optimized pass
|
||||
const stateValues = new Map<string, any>()
|
||||
// Read all values at once for better performance
|
||||
for (const key of GlobalStateAndSettingKeys) {
|
||||
const value = context.globalState.get(key as string)
|
||||
const value = store.get(key as string)
|
||||
stateValues.set(key, value)
|
||||
}
|
||||
|
||||
// Build result object with proper typing
|
||||
const result = {} as any // Use any for assignment, but return proper type
|
||||
const result = {} as any
|
||||
|
||||
// Process each state property using optimized approach
|
||||
for (const key of GlobalStateAndSettingKeys) {
|
||||
const stateKey = key as keyof GlobalStateAndSettings
|
||||
let value = stateValues.get(stateKey)
|
||||
|
||||
// Skip async properties - they need special handling
|
||||
if (isAsyncProperty(stateKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip computed properties - they need special handling
|
||||
if (isComputedProperty(stateKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply default value if needed
|
||||
if (value === undefined) {
|
||||
const defaultValue = getDefaultValue(stateKey)
|
||||
if (defaultValue !== undefined) {
|
||||
value = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// Apply transformation if provided
|
||||
if (value !== undefined) {
|
||||
value = applyTransform(stateKey, value)
|
||||
}
|
||||
// Set the processed value
|
||||
result[stateKey] = value
|
||||
}
|
||||
|
||||
// Handle computed properties with special logic
|
||||
await handleComputedProperties(result, stateValues)
|
||||
|
||||
// Handle async properties
|
||||
await handleAsyncProperties(result)
|
||||
|
||||
return result as GlobalStateAndSettings
|
||||
} catch (error) {
|
||||
Logger.error("[StateHelpers] Failed to read global state:", error)
|
||||
Logger.error("[StateHelpers] Failed to read global state from storage:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Legacy readers (for VSCode migration — reads from ExtensionContext) ────
|
||||
|
||||
/**
|
||||
* Handle properties that require computed logic
|
||||
*/
|
||||
@@ -120,19 +114,16 @@ async function handleAsyncProperties(result: any): Promise<void> {
|
||||
result.taskHistory = await readTaskHistoryFromState()
|
||||
}
|
||||
|
||||
export async function resetWorkspaceState(controller: Controller) {
|
||||
await Promise.all(LocalStateKeys.map((key) => controller.context.workspaceState.update(key, undefined)))
|
||||
|
||||
await controller.stateManager.reInitialize()
|
||||
export async function resetWorkspaceState() {
|
||||
const stateManager = StateManager.get()
|
||||
LocalStateKeys.map((key) => stateManager.setWorkspaceState(key, {}))
|
||||
await stateManager.reInitialize()
|
||||
}
|
||||
|
||||
export async function resetGlobalState(controller: Controller) {
|
||||
export async function resetGlobalState() {
|
||||
// TODO: Reset all workspace states?
|
||||
const context = controller.context
|
||||
|
||||
await Promise.all(GlobalStateAndSettingKeys.map((key) => context.globalState.update(key, undefined)))
|
||||
|
||||
await Promise.all(SecretKeys.map((key) => secretStorage.delete(key)))
|
||||
|
||||
await controller.stateManager.reInitialize()
|
||||
const stateManager = StateManager.get()
|
||||
GlobalStateAndSettingKeys.map((key) => stateManager.setGlobalState(key, undefined))
|
||||
SecretKeys.map((key) => stateManager.setSecret(key, undefined))
|
||||
await stateManager.reInitialize()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import { Agent, type AgentEvent, type AgentHooks, getClineDefaultSystemPrompt, type Tool } from "@cline/agents"
|
||||
import { createBuiltinTools } from "@cline/core"
|
||||
import { providers } from "@cline/llms"
|
||||
import type { Hooks as HookInputs } from "@core/hooks/hook-factory"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { formatContentBlockToMarkdown } from "@integrations/misc/export-markdown"
|
||||
import { telemetryService } from "@services/telemetry"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import type { ClineSay } from "@shared/ExtensionMessage"
|
||||
import { buildClineExtraHeaders } from "@/services/EnvUtils"
|
||||
import { type ClineContent, ClineMessageModelInfo } from "@/shared/messages"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ApiProviderInfo } from "../api"
|
||||
import { ensureVsCodeLmProviderRegistered, VSCODE_LM_SELECTOR_HEADER } from "../api/providers/registry"
|
||||
import type { MessageStateHandler } from "./message-state"
|
||||
import type { CreateTaskAgentHooksOptions } from "./TaskHookExtensionAdapter"
|
||||
import type { HookExecution } from "./types/HookExecution"
|
||||
|
||||
type ProviderConnectionConfig = {
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
headers?: Record<string, string>
|
||||
knownModels?: Record<string, providers.ModelInfo>
|
||||
}
|
||||
|
||||
type RegisteredModelInfo = providers.ModelInfo
|
||||
|
||||
function toRegisteredModelInfo(modelId: string, modelInfo: ApiProviderInfo["model"]["info"]): RegisteredModelInfo {
|
||||
const capabilities: RegisteredModelInfo["capabilities"] = []
|
||||
if (modelInfo.supportsImages) {
|
||||
capabilities.push("images")
|
||||
}
|
||||
if (modelInfo.supportsPromptCache) {
|
||||
capabilities.push("prompt-cache")
|
||||
}
|
||||
if (modelInfo.supportsReasoning) {
|
||||
capabilities.push("reasoning")
|
||||
}
|
||||
if (modelInfo.supportsGlobalEndpoint) {
|
||||
capabilities.push("global-endpoint")
|
||||
}
|
||||
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelInfo.name,
|
||||
description: modelInfo.description,
|
||||
maxTokens: modelInfo.maxTokens,
|
||||
contextWindow: modelInfo.contextWindow,
|
||||
temperature: modelInfo.temperature,
|
||||
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
||||
pricing:
|
||||
modelInfo.inputPrice !== undefined ||
|
||||
modelInfo.outputPrice !== undefined ||
|
||||
modelInfo.cacheWritesPrice !== undefined ||
|
||||
modelInfo.cacheReadsPrice !== undefined
|
||||
? {
|
||||
input: modelInfo.inputPrice,
|
||||
output: modelInfo.outputPrice,
|
||||
cacheWrite: modelInfo.cacheWritesPrice,
|
||||
cacheRead: modelInfo.cacheReadsPrice,
|
||||
}
|
||||
: undefined,
|
||||
thinkingConfig: modelInfo.thinkingConfig
|
||||
? {
|
||||
maxBudget: modelInfo.thinkingConfig.maxBudget,
|
||||
outputPrice: modelInfo.thinkingConfig.outputPrice,
|
||||
thinkingLevel: modelInfo.thinkingConfig.geminiThinkingLevel,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
type AgentRuntimeHost = {
|
||||
taskId: string
|
||||
ulid: string
|
||||
cwd: string
|
||||
taskState: {
|
||||
apiRequestCount: number
|
||||
apiRequestsSinceLastTodoUpdate: number
|
||||
abort: boolean
|
||||
}
|
||||
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
postStateToWebview: () => Promise<void>
|
||||
getCurrentProviderInfo: () => ApiProviderInfo
|
||||
getApiConfiguration: () => ApiConfiguration
|
||||
loadContext: (
|
||||
userContent: ClineContent[],
|
||||
includeFileDetails: boolean,
|
||||
useCompactPrompt: boolean,
|
||||
) => Promise<[ClineContent[], string, boolean]>
|
||||
messageStateHandler: MessageStateHandler
|
||||
setActiveHookExecution: (hookExecution: HookExecution) => Promise<void>
|
||||
clearActiveHookExecution: () => Promise<void>
|
||||
runTaskLifecycleHook: <K extends "TaskStart" | "TaskResume">(params: {
|
||||
hookName: K
|
||||
hookInput: HookInputs[K]
|
||||
}) => Promise<{ cancel?: boolean; contextModification?: string; errorMessage?: string; wasCancelled: boolean }>
|
||||
runUserPromptSubmitHook: (
|
||||
userContent: ClineContent[],
|
||||
) => Promise<{ cancel?: boolean; contextModification?: string; errorMessage?: string; wasCancelled: boolean }>
|
||||
handleHookCancellation: (hookName: string, wasCancelled: boolean) => Promise<void>
|
||||
createAgentHooks: (options: CreateTaskAgentHooksOptions) => AgentHooks
|
||||
cancelTask: () => Promise<void>
|
||||
}
|
||||
|
||||
export type TaskAgentRunContext = {
|
||||
phase: "initial_task" | "resume" | "continue"
|
||||
initialTask?: string
|
||||
resumePreviousState?: {
|
||||
lastMessageTs: string
|
||||
messageCount: string
|
||||
conversationHistoryDeleted: string
|
||||
}
|
||||
userPromptHookContent?: ClineContent[]
|
||||
}
|
||||
|
||||
export class TaskAgentRuntime {
|
||||
private agent: InstanceType<typeof Agent> | undefined
|
||||
private agentSignature: string | undefined
|
||||
private readonly builtInTools: Tool[]
|
||||
private readonly host: AgentRuntimeHost
|
||||
private pendingRunContext: TaskAgentRunContext | undefined
|
||||
private streamedReasoning = ""
|
||||
|
||||
constructor(host: AgentRuntimeHost) {
|
||||
this.host = host
|
||||
this.builtInTools = createBuiltinTools({ cwd: host.cwd })
|
||||
}
|
||||
|
||||
async run(userContent: ClineContent[], runContext: TaskAgentRunContext): Promise<void> {
|
||||
this.host.taskState.apiRequestCount++
|
||||
this.host.taskState.apiRequestsSinceLastTodoUpdate++
|
||||
this.streamedReasoning = ""
|
||||
|
||||
// const [parsedUserContent, environmentDetails] = await this.host.loadContext(userContent, includeFileDetails, false)
|
||||
// const normalizedUserContent = [...parsedUserContent]
|
||||
// if (environmentDetails) {
|
||||
// normalizedUserContent.push({ type: "text", text: environmentDetails })
|
||||
// }
|
||||
const normalizedUserContent = [...userContent]
|
||||
|
||||
const userMessage = normalizedUserContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n")
|
||||
await this.host.say("api_req_started", JSON.stringify({ request: userMessage }))
|
||||
|
||||
const providerInfo = this.host.getCurrentProviderInfo()
|
||||
const modelInfo: ClineMessageModelInfo = {
|
||||
modelId: providerInfo.model.id,
|
||||
providerId: providerInfo.providerId,
|
||||
mode: providerInfo.mode,
|
||||
}
|
||||
|
||||
await this.host.messageStateHandler.addToApiConversationHistory({
|
||||
role: "user",
|
||||
content: normalizedUserContent,
|
||||
ts: Date.now(),
|
||||
})
|
||||
|
||||
const customHeader = await buildClineExtraHeaders()
|
||||
|
||||
this.pendingRunContext = runContext
|
||||
const { agent, reusedExistingAgent } = this.getOrCreateAgent(providerInfo, customHeader)
|
||||
const result = await (async () => {
|
||||
try {
|
||||
return reusedExistingAgent ? await agent.continue(userMessage) : await agent.run(userMessage)
|
||||
} finally {
|
||||
this.pendingRunContext = undefined
|
||||
}
|
||||
})()
|
||||
this.agent = agent
|
||||
|
||||
if (result.text) {
|
||||
await this.host.say("text", result.text, undefined, undefined, false)
|
||||
}
|
||||
|
||||
await this.host.messageStateHandler.addToApiConversationHistory({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: result.text || "" }],
|
||||
modelInfo,
|
||||
metrics: {
|
||||
tokens: {
|
||||
prompt: result.usage.inputTokens || 0,
|
||||
completion: result.usage.outputTokens || 0,
|
||||
cached: (result.usage.cacheWriteTokens || 0) + (result.usage.cacheReadTokens || 0),
|
||||
},
|
||||
cost: result.usage.totalCost,
|
||||
},
|
||||
ts: Date.now(),
|
||||
})
|
||||
|
||||
telemetryService.captureConversationTurnEvent(
|
||||
this.host.ulid,
|
||||
modelInfo.providerId,
|
||||
modelInfo.modelId,
|
||||
"assistant",
|
||||
modelInfo.mode,
|
||||
{
|
||||
tokensIn: result.usage.inputTokens || 0,
|
||||
tokensOut: result.usage.outputTokens || 0,
|
||||
cacheWriteTokens: result.usage.cacheWriteTokens || 0,
|
||||
cacheReadTokens: result.usage.cacheReadTokens || 0,
|
||||
totalCost: result.usage.totalCost,
|
||||
},
|
||||
)
|
||||
|
||||
await this.host.postStateToWebview()
|
||||
}
|
||||
|
||||
public abort(): void {
|
||||
this.agent?.abort()
|
||||
}
|
||||
|
||||
private getOrCreateAgent(
|
||||
providerInfo: ReturnType<AgentRuntimeHost["getCurrentProviderInfo"]>,
|
||||
customHeader: Record<string, string>,
|
||||
): {
|
||||
agent: InstanceType<typeof Agent>
|
||||
reusedExistingAgent: boolean
|
||||
} {
|
||||
const apiConfig = this.host.getApiConfiguration()
|
||||
if (providerInfo.providerId === "vscode-lm") {
|
||||
ensureVsCodeLmProviderRegistered()
|
||||
}
|
||||
const providerConfig = this.resolveProviderConfig(providerInfo, apiConfig)
|
||||
const nextSignature = JSON.stringify({
|
||||
providerId: providerInfo.providerId,
|
||||
modelId: providerInfo.model.id,
|
||||
apiKey: providerConfig.apiKey ?? "",
|
||||
baseUrl: providerConfig.baseUrl ?? "",
|
||||
headers: providerConfig.headers ?? {},
|
||||
knownModels: providerConfig.knownModels ?? {},
|
||||
})
|
||||
|
||||
if (this.agent && this.agentSignature === nextSignature) {
|
||||
return { agent: this.agent, reusedExistingAgent: true }
|
||||
}
|
||||
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"X-Title": "Cline",
|
||||
"X-Task-ID": this.host.ulid || "",
|
||||
}
|
||||
Object.assign(defaultHeaders, customHeader)
|
||||
|
||||
const hooks = this.createAgentHooks()
|
||||
const agent = new Agent({
|
||||
providerId: providerInfo.providerId,
|
||||
modelId: providerInfo.model.id,
|
||||
apiKey: providerConfig.apiKey,
|
||||
baseUrl: providerConfig.baseUrl,
|
||||
headers: providerConfig.headers,
|
||||
knownModels: providerConfig.knownModels,
|
||||
systemPrompt: getClineDefaultSystemPrompt("VS Code", this.host.cwd),
|
||||
tools: this.builtInTools,
|
||||
hooks,
|
||||
maxIterations: 50,
|
||||
onEvent: async (event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "content_start":
|
||||
if (event.contentType === "text") {
|
||||
const nextText = event.accumulated ?? event.text
|
||||
if (nextText !== undefined) {
|
||||
await this.host.say("text", nextText, undefined, undefined, true)
|
||||
}
|
||||
}
|
||||
if (event.contentType === "reasoning" && event.reasoning !== undefined) {
|
||||
this.streamedReasoning += event.reasoning
|
||||
await this.host.say("reasoning", this.streamedReasoning, undefined, undefined, true)
|
||||
}
|
||||
if (event.contentType === "tool" && event.toolName) {
|
||||
Logger.debug(`[Task ${this.host.taskId}] Agent tool call start: ${event.toolName}`)
|
||||
}
|
||||
break
|
||||
case "content_end":
|
||||
if (event.contentType === "reasoning") {
|
||||
const finalReasoning = event.reasoning ?? this.streamedReasoning
|
||||
if (finalReasoning) {
|
||||
await this.host.say("reasoning", finalReasoning, undefined, undefined, false)
|
||||
}
|
||||
}
|
||||
break
|
||||
case "error":
|
||||
Logger.error(`[Task ${this.host.taskId}] Agent runtime error`, event.error)
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
this.agent = agent
|
||||
this.agentSignature = nextSignature
|
||||
return { agent, reusedExistingAgent: false }
|
||||
}
|
||||
|
||||
private createAgentHooks(): AgentHooks {
|
||||
return this.host.createAgentHooks({
|
||||
onRunStart: () => this.handleRunStartHook(),
|
||||
buildPendingToolInfo: (toolName, toolInput) => this.buildPendingToolInfo(toolName, toolInput),
|
||||
toHookStringParameters: (input) => this.toHookStringParameters(input),
|
||||
createHookContextBlock: (hookName, contextModification) => this.createHookContextBlock(hookName, contextModification),
|
||||
safeJsonStringify: (value) => this.safeJsonStringify(value),
|
||||
})
|
||||
}
|
||||
|
||||
private async handleRunStartHook(): Promise<{ cancel?: boolean; context?: string } | undefined> {
|
||||
const hooksEnabled = getHooksEnabledSafe()
|
||||
if (!hooksEnabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const runContext = this.pendingRunContext
|
||||
if (!runContext) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const contextBlocks: string[] = []
|
||||
|
||||
if (runContext.phase === "initial_task") {
|
||||
const taskStartResult = await this.host.runTaskLifecycleHook({
|
||||
hookName: "TaskStart",
|
||||
hookInput: {
|
||||
taskStart: {
|
||||
taskMetadata: {
|
||||
taskId: this.host.taskId,
|
||||
ulid: this.host.ulid,
|
||||
initialTask: runContext.initialTask || "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (taskStartResult.cancel) {
|
||||
await this.host.handleHookCancellation("TaskStart", taskStartResult.wasCancelled)
|
||||
await this.host.cancelTask()
|
||||
return { cancel: true }
|
||||
}
|
||||
|
||||
const taskStartContext = this.createHookContextBlock("TaskStart", taskStartResult.contextModification)
|
||||
if (taskStartContext) {
|
||||
contextBlocks.push(taskStartContext)
|
||||
}
|
||||
}
|
||||
|
||||
if (runContext.phase === "resume") {
|
||||
const taskResumeResult = await this.host.runTaskLifecycleHook({
|
||||
hookName: "TaskResume",
|
||||
hookInput: {
|
||||
taskResume: {
|
||||
taskMetadata: {
|
||||
taskId: this.host.taskId,
|
||||
ulid: this.host.ulid,
|
||||
},
|
||||
previousState: runContext.resumePreviousState || {
|
||||
lastMessageTs: "",
|
||||
messageCount: "0",
|
||||
conversationHistoryDeleted: "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (taskResumeResult.cancel) {
|
||||
await this.host.handleHookCancellation("TaskResume", taskResumeResult.wasCancelled)
|
||||
await this.host.cancelTask()
|
||||
return { cancel: true }
|
||||
}
|
||||
|
||||
const taskResumeContext = this.createHookContextBlock("TaskResume", taskResumeResult.contextModification)
|
||||
if (taskResumeContext) {
|
||||
contextBlocks.push(taskResumeContext)
|
||||
}
|
||||
}
|
||||
|
||||
const userPromptHookContent = runContext.userPromptHookContent ?? []
|
||||
const userPromptResult = await this.host.runUserPromptSubmitHook(userPromptHookContent)
|
||||
|
||||
if (userPromptResult.cancel) {
|
||||
await this.host.handleHookCancellation("UserPromptSubmit", userPromptResult.wasCancelled)
|
||||
await this.host.cancelTask()
|
||||
return { cancel: true }
|
||||
}
|
||||
|
||||
const userPromptContext = this.createHookContextBlock("UserPromptSubmit", userPromptResult.contextModification)
|
||||
if (userPromptContext) {
|
||||
contextBlocks.push(userPromptContext)
|
||||
}
|
||||
|
||||
if (contextBlocks.length > 0) {
|
||||
return { context: contextBlocks.join("\n") }
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private createHookContextBlock(source: string, contextModification?: string): string | undefined {
|
||||
if (!contextModification) {
|
||||
return
|
||||
}
|
||||
|
||||
const contextText = contextModification.trim()
|
||||
if (!contextText) {
|
||||
return
|
||||
}
|
||||
|
||||
const lines = contextText.split("\n")
|
||||
const firstLine = lines[0]
|
||||
let contextType = "general"
|
||||
let content = contextText
|
||||
const typeMatch = /^([A-Z_]+):\s*(.*)/.exec(firstLine)
|
||||
if (typeMatch) {
|
||||
contextType = typeMatch[1].toLowerCase()
|
||||
const remainingLines = lines.slice(1).filter((line) => line.trim())
|
||||
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
|
||||
}
|
||||
|
||||
if (contextType === "general") {
|
||||
return `<hook_context source="${source}">\n${content}\n</hook_context>`
|
||||
}
|
||||
|
||||
return `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`
|
||||
}
|
||||
|
||||
private toHookParameters(input: unknown): Record<string, unknown> {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return {}
|
||||
}
|
||||
return { ...(input as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
private toHookStringParameters(input: unknown): Record<string, string> {
|
||||
const params = this.toHookParameters(input)
|
||||
const entries = Object.entries(params).map(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
return [key, value] as const
|
||||
}
|
||||
return [key, this.safeJsonStringify(value)] as const
|
||||
})
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
private buildPendingToolInfo(toolName: string, input: unknown): Record<string, unknown> {
|
||||
const pendingToolInfo: Record<string, unknown> = { tool: toolName }
|
||||
const params = this.toHookParameters(input)
|
||||
|
||||
for (const key of ["path", "command", "regex", "url", "tool_name", "server_name", "uri"]) {
|
||||
if (params[key] !== undefined) {
|
||||
pendingToolInfo[key] = params[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof params.content === "string") {
|
||||
pendingToolInfo.content = params.content.slice(0, 200)
|
||||
}
|
||||
if (typeof params.diff === "string") {
|
||||
pendingToolInfo.diff = params.diff.slice(0, 200)
|
||||
}
|
||||
|
||||
return pendingToolInfo
|
||||
}
|
||||
|
||||
private safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveProviderConfig(providerInfo: ApiProviderInfo, apiConfig: ApiConfiguration): ProviderConnectionConfig {
|
||||
const { providerId, mode, model } = providerInfo
|
||||
switch (providerId) {
|
||||
case "anthropic":
|
||||
return { apiKey: apiConfig.apiKey, baseUrl: apiConfig.anthropicBaseUrl }
|
||||
case "openrouter":
|
||||
return { apiKey: apiConfig.openRouterApiKey }
|
||||
case "cline":
|
||||
return {
|
||||
apiKey: apiConfig.clineAccountId,
|
||||
knownModels:
|
||||
model?.id && model?.info ? { [model.id]: toRegisteredModelInfo(model.id, model.info) } : undefined,
|
||||
}
|
||||
case "openai":
|
||||
return {
|
||||
apiKey: apiConfig.openAiApiKey,
|
||||
baseUrl: apiConfig.openAiBaseUrl,
|
||||
headers: apiConfig.openAiHeaders,
|
||||
}
|
||||
case "gemini":
|
||||
return { apiKey: apiConfig.geminiApiKey, baseUrl: apiConfig.geminiBaseUrl }
|
||||
case "vertex":
|
||||
return { apiKey: apiConfig.geminiApiKey, baseUrl: apiConfig.geminiBaseUrl }
|
||||
case "ollama":
|
||||
return { apiKey: apiConfig.ollamaApiKey, baseUrl: apiConfig.ollamaBaseUrl }
|
||||
case "lmstudio":
|
||||
return { baseUrl: apiConfig.lmStudioBaseUrl }
|
||||
case "vscode-lm": {
|
||||
const selector =
|
||||
mode === "plan" ? apiConfig.planModeVsCodeLmModelSelector : apiConfig.actModeVsCodeLmModelSelector
|
||||
return {
|
||||
headers: selector ? { [VSCODE_LM_SELECTOR_HEADER]: JSON.stringify(selector) } : undefined,
|
||||
}
|
||||
}
|
||||
case "deepseek":
|
||||
return { apiKey: apiConfig.deepSeekApiKey }
|
||||
case "together":
|
||||
return { apiKey: apiConfig.togetherApiKey }
|
||||
case "litellm":
|
||||
return { apiKey: apiConfig.liteLlmApiKey, baseUrl: apiConfig.liteLlmBaseUrl }
|
||||
case "nebius":
|
||||
return { apiKey: apiConfig.nebiusApiKey }
|
||||
case "sambanova":
|
||||
return { apiKey: apiConfig.sambanovaApiKey }
|
||||
case "cerebras":
|
||||
return { apiKey: apiConfig.cerebrasApiKey }
|
||||
case "baseten":
|
||||
return { apiKey: apiConfig.basetenApiKey }
|
||||
case "huggingface":
|
||||
return { apiKey: apiConfig.huggingFaceApiKey }
|
||||
case "huawei-cloud-maas":
|
||||
return { apiKey: apiConfig.huaweiCloudMaasApiKey }
|
||||
case "vercel-ai-gateway":
|
||||
return { apiKey: apiConfig.vercelAiGatewayApiKey }
|
||||
case "aihubmix":
|
||||
return {
|
||||
apiKey: apiConfig.aihubmixApiKey,
|
||||
baseUrl: apiConfig.aihubmixBaseUrl,
|
||||
headers: apiConfig.aihubmixAppCode ? { "APP-Code": apiConfig.aihubmixAppCode } : undefined,
|
||||
}
|
||||
case "hicap":
|
||||
return {
|
||||
apiKey: apiConfig.hicapApiKey,
|
||||
headers: apiConfig.hicapApiKey ? { "api-key": apiConfig.hicapApiKey } : undefined,
|
||||
}
|
||||
case "nousResearch":
|
||||
return { apiKey: apiConfig.nousResearchApiKey }
|
||||
case "requesty":
|
||||
return { apiKey: apiConfig.requestyApiKey, baseUrl: apiConfig.requestyBaseUrl }
|
||||
case "xai":
|
||||
return { apiKey: apiConfig.xaiApiKey }
|
||||
case "groq":
|
||||
return { apiKey: apiConfig.groqApiKey }
|
||||
case "fireworks":
|
||||
return { apiKey: apiConfig.fireworksApiKey }
|
||||
default:
|
||||
Logger.warn(`[Task ${this.host.taskId}] No explicit provider config mapping for provider "${providerId}"`)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import type { AgentHooks } from "@cline/agents"
|
||||
import { executeHook } from "@core/hooks/hook-executor"
|
||||
import type { Hooks as HookInputs } from "@core/hooks/hook-factory"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { executePreCompactHookWithCleanup, type HookExecution } from "@core/hooks/precompact-executor"
|
||||
import type { ClineMessage, ClineSay } from "@shared/ExtensionMessage"
|
||||
import type { ClineContent, ClineStorageMessage } from "@shared/messages/content"
|
||||
import type { ContextManager } from "@/core/context/context-management/ContextManager"
|
||||
import type { MessageStateHandler } from "@/core/task/message-state"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
type WithStateLock = <T>(fn: () => T | Promise<T>) => Promise<T>
|
||||
|
||||
export interface TaskHookResult {
|
||||
cancel?: boolean
|
||||
contextModification?: string
|
||||
errorMessage?: string
|
||||
wasCancelled: boolean
|
||||
}
|
||||
|
||||
export interface TaskHookAdapterContext {
|
||||
taskId: string
|
||||
ulid: string
|
||||
taskState: {
|
||||
abort: boolean
|
||||
didFinishAbortingStream: boolean
|
||||
activeHookExecution?: HookExecution
|
||||
}
|
||||
messageStateHandler: MessageStateHandler
|
||||
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
postStateToWebview: () => Promise<void>
|
||||
cancelTask: () => Promise<void>
|
||||
withStateLock?: WithStateLock
|
||||
}
|
||||
|
||||
export interface CreateTaskAgentHooksOptions {
|
||||
onRunStart: () => Promise<{ cancel?: boolean; context?: string } | undefined>
|
||||
buildPendingToolInfo: (toolName: string, toolInput: unknown) => Record<string, unknown> | undefined
|
||||
toHookStringParameters: (input: unknown) => Record<string, string>
|
||||
createHookContextBlock: (hookName: string, contextModification?: string) => string | undefined
|
||||
safeJsonStringify: (value: unknown) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that ports task-coupled hook behavior to the agents-extension boundary.
|
||||
* This keeps hook semantics in one place while task/runtime migration completes.
|
||||
*/
|
||||
export class TaskHookExtensionAdapter {
|
||||
constructor(private readonly ctx: TaskHookAdapterContext) {}
|
||||
|
||||
async setActiveHookExecution(hookExecution: HookExecution): Promise<void> {
|
||||
if (this.ctx.withStateLock) {
|
||||
await this.ctx.withStateLock(() => {
|
||||
this.ctx.taskState.activeHookExecution = hookExecution
|
||||
})
|
||||
return
|
||||
}
|
||||
this.ctx.taskState.activeHookExecution = hookExecution
|
||||
}
|
||||
|
||||
async clearActiveHookExecution(): Promise<void> {
|
||||
if (this.ctx.withStateLock) {
|
||||
await this.ctx.withStateLock(() => {
|
||||
this.ctx.taskState.activeHookExecution = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
this.ctx.taskState.activeHookExecution = undefined
|
||||
}
|
||||
|
||||
async getActiveHookExecution(): Promise<HookExecution | undefined> {
|
||||
if (this.ctx.withStateLock) {
|
||||
return await this.ctx.withStateLock(() => this.ctx.taskState.activeHookExecution)
|
||||
}
|
||||
return this.ctx.taskState.activeHookExecution
|
||||
}
|
||||
|
||||
async runTaskLifecycleHook<K extends "TaskStart" | "TaskResume">(params: {
|
||||
hookName: K
|
||||
hookInput: HookInputs[K]
|
||||
}): Promise<TaskHookResult> {
|
||||
const hooksEnabled = getHooksEnabledSafe()
|
||||
if (!hooksEnabled) {
|
||||
return { wasCancelled: false }
|
||||
}
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: params.hookName,
|
||||
hookInput: params.hookInput,
|
||||
isCancellable: true,
|
||||
say: this.ctx.say,
|
||||
setActiveHookExecution: async (execution) => this.setActiveHookExecution(execution as HookExecution),
|
||||
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
|
||||
messageStateHandler: this.ctx.messageStateHandler,
|
||||
taskId: this.ctx.taskId,
|
||||
hooksEnabled,
|
||||
})
|
||||
|
||||
return {
|
||||
cancel: result.cancel,
|
||||
contextModification: result.contextModification,
|
||||
errorMessage: result.errorMessage,
|
||||
wasCancelled: result.wasCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
async runUserPromptSubmitHook(userContent: ClineContent[]): Promise<TaskHookResult> {
|
||||
const hooksEnabled = getHooksEnabledSafe()
|
||||
if (!hooksEnabled) {
|
||||
return { wasCancelled: false }
|
||||
}
|
||||
|
||||
const { extractUserPromptFromContent } = await import("@/core/task/utils/extractUserPromptFromContent")
|
||||
const promptText = extractUserPromptFromContent(userContent)
|
||||
|
||||
const result = await executeHook({
|
||||
hookName: "UserPromptSubmit",
|
||||
hookInput: {
|
||||
userPromptSubmit: {
|
||||
prompt: promptText,
|
||||
attachments: [],
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: this.ctx.say,
|
||||
setActiveHookExecution: async (execution) => this.setActiveHookExecution(execution as HookExecution),
|
||||
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
|
||||
messageStateHandler: this.ctx.messageStateHandler,
|
||||
taskId: this.ctx.taskId,
|
||||
hooksEnabled,
|
||||
})
|
||||
|
||||
if (result.cancel === true && result.wasCancelled) {
|
||||
this.ctx.taskState.didFinishAbortingStream = true
|
||||
await this.ctx.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
await this.ctx.messageStateHandler.overwriteApiConversationHistory(
|
||||
this.ctx.messageStateHandler.getApiConversationHistory(),
|
||||
)
|
||||
await this.ctx.postStateToWebview()
|
||||
}
|
||||
|
||||
return {
|
||||
cancel: result.cancel,
|
||||
contextModification: result.contextModification,
|
||||
errorMessage: result.errorMessage,
|
||||
wasCancelled: result.wasCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
async runTaskCancelHook(taskMetadata: { taskId: string; ulid: string; completionStatus: string }): Promise<void> {
|
||||
const hooksEnabled = getHooksEnabledSafe()
|
||||
if (!hooksEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
await executeHook({
|
||||
hookName: "TaskCancel",
|
||||
hookInput: {
|
||||
taskCancel: {
|
||||
taskMetadata,
|
||||
},
|
||||
},
|
||||
isCancellable: false,
|
||||
say: this.ctx.say,
|
||||
messageStateHandler: this.ctx.messageStateHandler,
|
||||
taskId: this.ctx.taskId,
|
||||
hooksEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
async runPreCompactHookWithCleanup(params: {
|
||||
apiConversationHistory: ClineStorageMessage[]
|
||||
conversationHistoryDeletedRange: [number, number] | undefined
|
||||
contextManager: ContextManager
|
||||
clineMessages: ClineMessage[]
|
||||
deletedRange?: [number, number]
|
||||
}): Promise<{ contextModification?: string }> {
|
||||
const hooksEnabled = getHooksEnabledSafe()
|
||||
if (!hooksEnabled) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return await executePreCompactHookWithCleanup({
|
||||
taskId: this.ctx.taskId,
|
||||
ulid: this.ctx.ulid,
|
||||
apiConversationHistory: params.apiConversationHistory,
|
||||
conversationHistoryDeletedRange: params.conversationHistoryDeletedRange,
|
||||
contextManager: params.contextManager,
|
||||
clineMessages: params.clineMessages,
|
||||
messageStateHandler: this.ctx.messageStateHandler,
|
||||
compactionStrategy: "standard-truncation-lastquarter",
|
||||
deletedRange: params.deletedRange,
|
||||
say: this.ctx.say,
|
||||
setActiveHookExecution: async (hookExecution: HookExecution | undefined) => {
|
||||
if (hookExecution) {
|
||||
await this.setActiveHookExecution(hookExecution)
|
||||
}
|
||||
},
|
||||
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
|
||||
postStateToWebview: this.ctx.postStateToWebview,
|
||||
taskState: this.ctx.taskState,
|
||||
cancelTask: this.ctx.cancelTask,
|
||||
hooksEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
async cancelHookExecution(logError: (message: string, error: unknown) => void): Promise<boolean> {
|
||||
const activeHook = await this.getActiveHookExecution()
|
||||
if (!activeHook) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { hookName, toolName, messageTs, abortController } = activeHook
|
||||
try {
|
||||
abortController.abort()
|
||||
|
||||
const clineMessages = this.ctx.messageStateHandler.getClineMessages()
|
||||
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === messageTs)
|
||||
if (hookMessageIndex !== -1) {
|
||||
await this.ctx.messageStateHandler.updateClineMessage(hookMessageIndex, {
|
||||
text: JSON.stringify({
|
||||
hookName,
|
||||
toolName,
|
||||
status: "cancelled",
|
||||
exitCode: 130,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
await this.ctx.say("hook_output_stream", "\nHook execution cancelled by user")
|
||||
return true
|
||||
} catch (error) {
|
||||
logError("Failed to cancel hook execution", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async handleHookCancellation(hookName: string, wasCancelled: boolean): Promise<void> {
|
||||
this.ctx.taskState.didFinishAbortingStream = true
|
||||
await this.ctx.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
await this.ctx.messageStateHandler.overwriteApiConversationHistory(
|
||||
this.ctx.messageStateHandler.getApiConversationHistory(),
|
||||
)
|
||||
await this.ctx.postStateToWebview()
|
||||
Logger.log(`[Task ${this.ctx.taskId}] ${hookName} hook cancelled (userInitiated: ${wasCancelled})`)
|
||||
}
|
||||
|
||||
createAgentHooks(options: CreateTaskAgentHooksOptions): AgentHooks {
|
||||
return {
|
||||
onRunStart: async () => options.onRunStart(),
|
||||
onToolCallStart: async (ctx) => {
|
||||
if (this.ctx.taskState.abort) {
|
||||
return { cancel: true }
|
||||
}
|
||||
|
||||
const hooksEnabled = getHooksEnabledSafe()
|
||||
if (!hooksEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingToolInfo = options.buildPendingToolInfo(ctx.call.name, ctx.call.input)
|
||||
const result = await executeHook({
|
||||
hookName: "PreToolUse",
|
||||
hookInput: {
|
||||
preToolUse: {
|
||||
toolName: ctx.call.name,
|
||||
parameters: options.toHookStringParameters(ctx.call.input),
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: this.ctx.say,
|
||||
setActiveHookExecution: async (execution) => this.setActiveHookExecution(execution as HookExecution),
|
||||
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
|
||||
messageStateHandler: this.ctx.messageStateHandler,
|
||||
taskId: this.ctx.taskId,
|
||||
hooksEnabled,
|
||||
toolName: ctx.call.name,
|
||||
pendingToolInfo,
|
||||
})
|
||||
|
||||
if (result.cancel) {
|
||||
await this.clearActiveHookExecution()
|
||||
if (result.errorMessage) {
|
||||
await this.ctx.say("error", result.errorMessage)
|
||||
}
|
||||
await this.ctx.cancelTask()
|
||||
return { cancel: true }
|
||||
}
|
||||
|
||||
const context = options.createHookContextBlock("PreToolUse", result.contextModification)
|
||||
return context ? { context } : undefined
|
||||
},
|
||||
onToolCallEnd: async (ctx) => {
|
||||
if (this.ctx.taskState.abort) {
|
||||
return { cancel: true }
|
||||
}
|
||||
|
||||
const hooksEnabled = getHooksEnabledSafe()
|
||||
if (!hooksEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const toolResult = ctx.record.error
|
||||
? { error: ctx.record.error }
|
||||
: typeof ctx.record.output === "string"
|
||||
? ctx.record.output
|
||||
: options.safeJsonStringify(ctx.record.output)
|
||||
const result = await executeHook({
|
||||
hookName: "PostToolUse",
|
||||
hookInput: {
|
||||
postToolUse: {
|
||||
toolName: ctx.record.name,
|
||||
parameters: options.toHookStringParameters(ctx.record.input),
|
||||
result: typeof toolResult === "string" ? toolResult : options.safeJsonStringify(toolResult),
|
||||
success: !ctx.record.error,
|
||||
executionTimeMs: ctx.record.durationMs,
|
||||
},
|
||||
},
|
||||
isCancellable: true,
|
||||
say: this.ctx.say,
|
||||
setActiveHookExecution: async (execution) => this.setActiveHookExecution(execution as HookExecution),
|
||||
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
|
||||
messageStateHandler: this.ctx.messageStateHandler,
|
||||
taskId: this.ctx.taskId,
|
||||
hooksEnabled,
|
||||
toolName: ctx.record.name,
|
||||
})
|
||||
|
||||
if (result.cancel) {
|
||||
if (result.errorMessage) {
|
||||
await this.ctx.say("error", result.errorMessage)
|
||||
}
|
||||
await this.ctx.cancelTask()
|
||||
return { cancel: true }
|
||||
}
|
||||
|
||||
const context = options.createHookContextBlock("PostToolUse", result.contextModification)
|
||||
return context ? { context } : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import type { HookExecution } from "./types/HookExecution"
|
||||
|
||||
export class TaskState {
|
||||
// Task-level timing
|
||||
taskStartTimeMs = Date.now()
|
||||
taskFirstTokenTimeMs?: number
|
||||
|
||||
// Streaming flags
|
||||
isStreaming = false
|
||||
isWaitingForFirstChunk = false
|
||||
|
||||
@@ -12,7 +12,6 @@ import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { ClineContent } from "@shared/messages/content"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import * as vscode from "vscode"
|
||||
import { isParallelToolCallingEnabled, modelDoesntSupportWebp } from "@/utils/model-utils"
|
||||
import { ToolUse } from "../assistant-message"
|
||||
import { ContextManager } from "../context/context-management/ContextManager"
|
||||
@@ -67,6 +66,10 @@ export class ToolExecutor {
|
||||
private autoApprover: AutoApprove
|
||||
private coordinator: ToolExecutorCoordinator
|
||||
|
||||
public setApiHandler(api: ApiHandler): void {
|
||||
this.api = api
|
||||
}
|
||||
|
||||
// Auto-approval methods using the AutoApprove class
|
||||
private shouldAutoApproveTool(toolName: ClineDefaultTool): boolean | [boolean, boolean] {
|
||||
return this.autoApprover.shouldAutoApproveTool(toolName)
|
||||
@@ -81,7 +84,6 @@ export class ToolExecutor {
|
||||
|
||||
constructor(
|
||||
// Core Services & Managers
|
||||
private context: vscode.ExtensionContext,
|
||||
private taskState: TaskState,
|
||||
private messageStateHandler: MessageStateHandler,
|
||||
private api: ApiHandler,
|
||||
@@ -160,7 +162,6 @@ export class ToolExecutor {
|
||||
const config: TaskConfig = {
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
context: this.context,
|
||||
mode: this.stateManager.getGlobalSettingsKey("mode"),
|
||||
strictPlanModeEnabled: this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled"),
|
||||
yoloModeToggled: this.stateManager.getGlobalSettingsKey("yoloModeToggled"),
|
||||
|
||||
+99
-2359
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import { buildUserFeedbackContent } from "../../utils/buildUserFeedbackContent"
|
||||
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
import { getTaskCompletionTelemetry } from "../utils"
|
||||
import { ToolResultUtils } from "../utils/ToolResultUtils"
|
||||
|
||||
const TASK_PREVIEW_MAX_CHARS = 8000
|
||||
@@ -152,7 +153,7 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
|
||||
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
|
||||
await config.callbacks.saveCheckpoint(true, completionMessageTs)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(config.ulid)
|
||||
telemetryService.captureTaskCompleted(config.ulid, getTaskCompletionTelemetry(config))
|
||||
} else {
|
||||
// we already sent a command message, meaning the complete completion message has also been sent
|
||||
await config.callbacks.saveCheckpoint(true)
|
||||
@@ -199,7 +200,7 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
|
||||
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
|
||||
await config.callbacks.saveCheckpoint(true, completionMessageTs)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(config.ulid)
|
||||
telemetryService.captureTaskCompleted(config.ulid, getTaskCompletionTelemetry(config))
|
||||
}
|
||||
|
||||
// we already sent completion_result says, an empty string asks relinquishes control over button and field
|
||||
|
||||
@@ -9,12 +9,11 @@ import type { ToolResponse } from "../../index"
|
||||
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
import { getTaskCompletionTelemetry } from "../utils"
|
||||
|
||||
export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandler {
|
||||
readonly name = ClineDefaultTool.PLAN_MODE
|
||||
|
||||
constructor() {}
|
||||
|
||||
getDescription(block: ToolUse): string {
|
||||
return `[${block.name}]`
|
||||
}
|
||||
@@ -85,9 +84,8 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
|
||||
|
||||
// we dont need to process any text, options, files or other content here
|
||||
return formatResponse.toolResult(`[The user has switched to ACT MODE, so you may now proceed with the task.]`)
|
||||
} else {
|
||||
Logger.warn("YOLO MODE: Failed to switch to ACT MODE, continuing with normal plan mode")
|
||||
}
|
||||
Logger.warn("YOLO MODE: Failed to switch to ACT MODE, continuing with normal plan mode")
|
||||
}
|
||||
|
||||
// Set awaiting plan response state
|
||||
@@ -134,6 +132,8 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
|
||||
fileContentString = await processFilesIntoText(planResponseFiles)
|
||||
}
|
||||
|
||||
telemetryService.captureTaskCompleted(config.ulid, getTaskCompletionTelemetry(config))
|
||||
|
||||
// Handle mode switching response
|
||||
if (config.taskState.didRespondToPlanAskBySwitchingMode) {
|
||||
const result = formatResponse.toolResult(
|
||||
@@ -147,9 +147,8 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
|
||||
// Reset the flag after using it to prevent it from persisting
|
||||
config.taskState.didRespondToPlanAskBySwitchingMode = false
|
||||
return result
|
||||
} else {
|
||||
// if we didn't switch to ACT MODE, then we can just send the user_feedback message
|
||||
return formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images, fileContentString)
|
||||
}
|
||||
// if we didn't switch to ACT MODE, then we can just send the user_feedback message
|
||||
return formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images, fileContentString)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { executePreCompactHookWithCleanup, HookCancellationError } from "@core/hooks/precompact-executor"
|
||||
import { continuationPrompt } from "@core/prompts/contextManagement"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { ensureTaskDirectoryExists } from "@core/storage/disk"
|
||||
import { StateManager } from "@core/storage/StateManager"
|
||||
import { TaskHookExtensionAdapter } from "@core/task/TaskHookExtensionAdapter"
|
||||
import { resolveWorkspacePath } from "@core/workspace"
|
||||
import { extractFileContent } from "@integrations/misc/extract-file-content"
|
||||
import { ClineSayTool } from "@shared/ExtensionMessage"
|
||||
@@ -41,63 +39,43 @@ export class SummarizeTaskHandler implements IToolHandler, IPartialBlockHandler
|
||||
// Variable to store context modification from PreCompact hook
|
||||
let hookContextModification: string | undefined
|
||||
|
||||
// Run PreCompact hook right before showing the condensing message
|
||||
const hooksEnabled = getHooksEnabledSafe()
|
||||
if (hooksEnabled) {
|
||||
try {
|
||||
// Determine compaction strategy
|
||||
const useAutoCondense = StateManager.get().getGlobalSettingsKey("useAutoCondense")
|
||||
const strategy = useAutoCondense ? "auto-condense" : "standard-truncation-firstpair"
|
||||
|
||||
const apiHistory = config.messageState.getApiConversationHistory()
|
||||
|
||||
const result = await executePreCompactHookWithCleanup({
|
||||
taskId: config.taskId,
|
||||
ulid: config.ulid,
|
||||
apiConversationHistory: apiHistory,
|
||||
conversationHistoryDeletedRange: config.taskState.conversationHistoryDeletedRange,
|
||||
contextManager: config.services.contextManager,
|
||||
clineMessages: config.messageState.getClineMessages(),
|
||||
messageStateHandler: config.messageState,
|
||||
compactionStrategy: strategy,
|
||||
say: config.callbacks.say,
|
||||
setActiveHookExecution: async (hookExecution) => {
|
||||
if (hookExecution) {
|
||||
await config.callbacks.setActiveHookExecution(hookExecution)
|
||||
}
|
||||
},
|
||||
clearActiveHookExecution: config.callbacks.clearActiveHookExecution,
|
||||
postStateToWebview: config.callbacks.postStateToWebview,
|
||||
taskState: config.taskState,
|
||||
cancelTask: config.callbacks.cancelTask,
|
||||
hooksEnabled,
|
||||
})
|
||||
|
||||
// Hook completed successfully - capture context modification if provided
|
||||
if (result.contextModification) {
|
||||
hookContextModification = result.contextModification
|
||||
Logger.log(`[PreCompact] Hook provided context modification for task ${config.taskId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if this is a hook cancellation error
|
||||
if (error instanceof HookCancellationError) {
|
||||
// Hook was cancelled - show message and return early without executing summarization
|
||||
// (State already saved and task already cancelled by executePreCompactHookWithCleanup)
|
||||
await config.callbacks.say(
|
||||
"error",
|
||||
"Context compaction was cancelled by PreCompact hook. Task has been aborted.",
|
||||
)
|
||||
return "Context compaction was cancelled. Task has been aborted."
|
||||
}
|
||||
|
||||
// Graceful degradation: Show warning but continue with compaction
|
||||
// Hook UI already shows "Failed" status with error details
|
||||
// Run PreCompact hook right before showing the condensing message.
|
||||
try {
|
||||
const adapter = new TaskHookExtensionAdapter({
|
||||
taskId: config.taskId,
|
||||
ulid: config.ulid,
|
||||
taskState: config.taskState,
|
||||
messageStateHandler: config.messageState,
|
||||
say: config.callbacks.say,
|
||||
postStateToWebview: config.callbacks.postStateToWebview,
|
||||
cancelTask: config.callbacks.cancelTask,
|
||||
})
|
||||
const preCompactResult = await adapter.runPreCompactHookWithCleanup({
|
||||
apiConversationHistory: config.messageState.getApiConversationHistory(),
|
||||
conversationHistoryDeletedRange: config.taskState.conversationHistoryDeletedRange,
|
||||
contextManager: config.services.contextManager,
|
||||
clineMessages: config.messageState.getClineMessages(),
|
||||
})
|
||||
if (preCompactResult.contextModification) {
|
||||
hookContextModification = preCompactResult.contextModification
|
||||
Logger.log(`[PreCompact] Hook provided context modification for task ${config.taskId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
// If cancellation happened, cancelTask has already run inside the adapter.
|
||||
if (config.taskState.abort) {
|
||||
await config.callbacks.say(
|
||||
"error",
|
||||
`PreCompact hook failed, continuing with compaction: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"Context compaction was cancelled by PreCompact hook. Task has been aborted.",
|
||||
)
|
||||
Logger.error("[PreCompact] Hook execution failed, continuing with compaction:", error)
|
||||
return "Context compaction was cancelled. Task has been aborted."
|
||||
}
|
||||
|
||||
// Graceful degradation: Show warning but continue with compaction.
|
||||
await config.callbacks.say(
|
||||
"error",
|
||||
`PreCompact hook failed, continuing with compaction: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
Logger.error("[PreCompact] Hook execution failed, continuing with compaction:", error)
|
||||
}
|
||||
|
||||
// Show completed summary in tool UI
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatResponse } from "@core/prompts/responses"
|
||||
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
@@ -117,7 +118,27 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
if (block.name === "write_to_file" && !rawContent) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
await config.services.diffViewProvider.reset()
|
||||
return await config.callbacks.sayAndCreateMissingParamError(block.name, "content")
|
||||
|
||||
// Use progressive error with token budget awareness
|
||||
const relPath = rawRelPath || "unknown"
|
||||
const contextWindow = config.api.getModel().info.contextWindow ?? 128_000
|
||||
const lastApiReqTotalTokens = getLastApiReqTotalTokens(config.messageState.getClineMessages())
|
||||
const contextUsagePercent = contextWindow > 0 ? Math.round((lastApiReqTotalTokens / contextWindow) * 100) : undefined
|
||||
const errorMessage = formatResponse.writeToFileMissingContentError(
|
||||
relPath,
|
||||
config.taskState.consecutiveMistakeCount,
|
||||
contextUsagePercent,
|
||||
)
|
||||
|
||||
await config.callbacks.say(
|
||||
"error",
|
||||
`Cline tried to use write_to_file for '${relPath}' without value for required parameter 'content'. ${
|
||||
config.taskState.consecutiveMistakeCount >= 2
|
||||
? "This has happened multiple times — Cline will try a different approach."
|
||||
: "Retrying..."
|
||||
}`,
|
||||
)
|
||||
return formatResponse.toolError(errorMessage)
|
||||
}
|
||||
|
||||
if (block.name === "new_rule" && !rawContent) {
|
||||
@@ -246,35 +267,34 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
|
||||
await config.services.diffViewProvider.revertChanges()
|
||||
return `The user denied this operation. ${fileDeniedNote}`
|
||||
} else {
|
||||
// User hit the approve button, and may have provided feedback
|
||||
if (text || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
let fileContentString = ""
|
||||
if (files && files.length > 0) {
|
||||
fileContentString = await processFilesIntoText(files)
|
||||
}
|
||||
|
||||
// Push additional tool feedback using existing utilities
|
||||
ToolResultUtils.pushAdditionalToolFeedback(
|
||||
config.taskState.userMessageContent,
|
||||
text,
|
||||
images,
|
||||
fileContentString,
|
||||
)
|
||||
await config.callbacks.say("user_feedback", text, images, files)
|
||||
}
|
||||
// User hit the approve button, and may have provided feedback
|
||||
if (text || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
let fileContentString = ""
|
||||
if (files && files.length > 0) {
|
||||
fileContentString = await processFilesIntoText(files)
|
||||
}
|
||||
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
modelId,
|
||||
providerId,
|
||||
false,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
// Push additional tool feedback using existing utilities
|
||||
ToolResultUtils.pushAdditionalToolFeedback(
|
||||
config.taskState.userMessageContent,
|
||||
text,
|
||||
images,
|
||||
fileContentString,
|
||||
)
|
||||
await config.callbacks.say("user_feedback", text, images, files)
|
||||
}
|
||||
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
modelId,
|
||||
providerId,
|
||||
false,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
|
||||
// Run PreToolUse hook after approval but before execution
|
||||
@@ -327,9 +347,8 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
finalContent,
|
||||
newProblemsMessage,
|
||||
)
|
||||
} else {
|
||||
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
|
||||
}
|
||||
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
|
||||
} catch (error) {
|
||||
// Reset diff view on error
|
||||
await config.services.diffViewProvider.revertChanges()
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { buildApiHandler } from "@core/api"
|
||||
import type { ApiHandler } from "@core/api"
|
||||
import { parseAssistantMessageV2, ToolUse } from "@core/assistant-message"
|
||||
import { ContextManager } from "@core/context/context-management/ContextManager"
|
||||
import { checkContextWindowExceededError } from "@core/context/context-management/context-error-handling"
|
||||
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
|
||||
import { discoverSkills, getAvailableSkills } from "@core/context/instructions/user-instructions/skills"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { PromptRegistry } from "@core/prompts/system-prompt"
|
||||
@@ -14,10 +13,9 @@ import { ClineAssistantToolUseBlock, ClineStorageMessage, ClineTextContentBlock
|
||||
import { Logger } from "@shared/services/Logger"
|
||||
import type { ClineTool } from "@shared/tools"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { isNextGenModelFamily } from "@utils/model-utils"
|
||||
import * as path from "path"
|
||||
import { CoreAgent } from "@/core/agents/CoreAgent"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
|
||||
import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { calculateApiCostAnthropic } from "@/utils/cost"
|
||||
import { TaskState } from "../../TaskState"
|
||||
@@ -225,6 +223,7 @@ export class SubagentRunner {
|
||||
private abortRequested = false
|
||||
private activeCommandExecutions = 0
|
||||
private abortingCommands = false
|
||||
private coreAgent = new CoreAgent()
|
||||
|
||||
constructor(private baseConfig: TaskConfig) {}
|
||||
|
||||
@@ -302,15 +301,15 @@ export class SubagentRunner {
|
||||
...apiConfiguration,
|
||||
ulid: this.baseConfig.ulid,
|
||||
}
|
||||
const api = buildApiHandler(effectiveApiConfiguration, mode)
|
||||
this.activeApiAbort = api.abort?.bind(api)
|
||||
const api = this.coreAgent.initializeApiHandler(effectiveApiConfiguration, mode)
|
||||
this.activeApiAbort = () => this.coreAgent.abortCurrentRequest()
|
||||
|
||||
const providerId = (
|
||||
mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
|
||||
) as string
|
||||
const providerInfo = {
|
||||
providerId,
|
||||
model: api.getModel(),
|
||||
model: this.coreAgent.getModel(),
|
||||
mode,
|
||||
customPrompt: this.baseConfig.services.stateManager.getGlobalSettingsKey("customPrompt"),
|
||||
}
|
||||
@@ -377,66 +376,59 @@ export class SubagentRunner {
|
||||
},
|
||||
]
|
||||
|
||||
while (true) {
|
||||
if (
|
||||
previousRequestTotalTokens !== undefined &&
|
||||
this.shouldCompactBeforeNextRequest(previousRequestTotalTokens, api, providerInfo.model.id)
|
||||
) {
|
||||
const didCompact = this.compactConversationForContextWindow(conversation)
|
||||
if (didCompact) {
|
||||
Logger.warn("[SubagentRunner] Proactively compacted context before next subagent request.")
|
||||
const loopResult = await this.coreAgent.runLoop<void, SubagentRunResult>({
|
||||
initialInput: undefined,
|
||||
shouldAbort: () => this.shouldAbort(),
|
||||
runTurn: async () => {
|
||||
if (
|
||||
previousRequestTotalTokens !== undefined &&
|
||||
this.coreAgent.shouldCompactBeforeNextRequest(
|
||||
previousRequestTotalTokens,
|
||||
api,
|
||||
providerInfo.model.id,
|
||||
this.baseConfig.services.stateManager.getGlobalSettingsKey("useAutoCondense"),
|
||||
)
|
||||
) {
|
||||
const didCompact = this.compactConversationForContextWindow(conversation)
|
||||
if (didCompact) {
|
||||
Logger.warn("[SubagentRunner] Proactively compacted context before next subagent request.")
|
||||
}
|
||||
previousRequestTotalTokens = undefined
|
||||
}
|
||||
// Prevent repeated compaction attempts off the same token sample.
|
||||
previousRequestTotalTokens = undefined
|
||||
}
|
||||
|
||||
const streamHandler = new StreamResponseHandler()
|
||||
const { toolUseHandler } = streamHandler.getHandlers()
|
||||
let requestInputTokens = 0
|
||||
let requestOutputTokens = 0
|
||||
let requestCacheWriteTokens = 0
|
||||
let requestCacheReadTokens = 0
|
||||
let requestTotalCost: number | undefined
|
||||
const streamHandler = new StreamResponseHandler()
|
||||
const { toolUseHandler } = streamHandler.getHandlers()
|
||||
|
||||
let assistantText = ""
|
||||
let assistantTextSignature: string | undefined
|
||||
let requestId: string | undefined
|
||||
const stream = this.createMessageWithInitialChunkRetry(
|
||||
api,
|
||||
systemPrompt,
|
||||
conversation,
|
||||
nativeTools,
|
||||
providerInfo.providerId,
|
||||
providerInfo.model.id,
|
||||
)
|
||||
|
||||
const stream = this.createMessageWithInitialChunkRetry(
|
||||
api,
|
||||
systemPrompt,
|
||||
conversation,
|
||||
nativeTools,
|
||||
providerInfo.providerId,
|
||||
providerInfo.model.id,
|
||||
)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "usage":
|
||||
requestId = requestId ?? chunk.id
|
||||
const streamResult = await this.coreAgent.consumeStream({
|
||||
stream,
|
||||
shouldAbort: () => this.shouldAbort(),
|
||||
onAbort: async () => {
|
||||
await this.abort()
|
||||
},
|
||||
onUsageChunk: (chunk, state) => {
|
||||
stats.inputTokens += chunk.inputTokens || 0
|
||||
stats.outputTokens += chunk.outputTokens || 0
|
||||
stats.cacheWriteTokens += chunk.cacheWriteTokens || 0
|
||||
stats.cacheReadTokens += chunk.cacheReadTokens || 0
|
||||
requestInputTokens += chunk.inputTokens || 0
|
||||
requestOutputTokens += chunk.outputTokens || 0
|
||||
requestCacheWriteTokens += chunk.cacheWriteTokens || 0
|
||||
requestCacheReadTokens += chunk.cacheReadTokens || 0
|
||||
requestTotalCost = chunk.totalCost ?? requestTotalCost
|
||||
stats.contextTokens =
|
||||
requestInputTokens + requestOutputTokens + requestCacheWriteTokens + requestCacheReadTokens
|
||||
state.usage.inputTokens +
|
||||
state.usage.outputTokens +
|
||||
state.usage.cacheWriteTokens +
|
||||
state.usage.cacheReadTokens
|
||||
stats.contextUsagePercentage =
|
||||
stats.contextWindow > 0 ? (stats.contextTokens / stats.contextWindow) * 100 : 0
|
||||
onProgress({ stats: { ...stats } })
|
||||
break
|
||||
case "text":
|
||||
requestId = requestId ?? chunk.id
|
||||
assistantText += chunk.text || ""
|
||||
assistantTextSignature = chunk.signature || assistantTextSignature
|
||||
break
|
||||
case "tool_calls":
|
||||
requestId = requestId ?? chunk.id
|
||||
},
|
||||
onToolCallChunk: (chunk) => {
|
||||
toolUseHandler.processToolUseDelta(
|
||||
{
|
||||
id: chunk.tool_call.function?.id,
|
||||
@@ -447,188 +439,184 @@ export class SubagentRunner {
|
||||
},
|
||||
chunk.tool_call.call_id,
|
||||
)
|
||||
break
|
||||
case "reasoning":
|
||||
requestId = requestId ?? chunk.id
|
||||
break
|
||||
}
|
||||
|
||||
if (this.shouldAbort()) {
|
||||
await this.abort()
|
||||
const error = "Subagent run cancelled."
|
||||
onProgress({ status: "failed", error, stats: { ...stats } })
|
||||
return { status: "failed", error, stats }
|
||||
}
|
||||
}
|
||||
|
||||
const calculatedRequestCost =
|
||||
requestTotalCost ??
|
||||
calculateApiCostAnthropic(
|
||||
providerInfo.model.info,
|
||||
requestInputTokens,
|
||||
requestOutputTokens,
|
||||
requestCacheWriteTokens,
|
||||
requestCacheReadTokens,
|
||||
)
|
||||
stats.totalCost += calculatedRequestCost || 0
|
||||
previousRequestTotalTokens =
|
||||
requestInputTokens + requestOutputTokens + requestCacheWriteTokens + requestCacheReadTokens
|
||||
|
||||
const nativeFinalizedToolCalls = toolUseHandler.getAllFinalizedToolUses().map((toolCall, index) => ({
|
||||
toolUseId: resolveToolUseId(toolCall, index),
|
||||
id: toolCall.id,
|
||||
call_id: toolCall.call_id,
|
||||
signature: toolCall.signature,
|
||||
name: toolCall.name,
|
||||
input: toolCall.input,
|
||||
isNativeToolCall: true,
|
||||
}))
|
||||
const parsedNonNativeToolCalls = parseNonNativeToolCalls(assistantText)
|
||||
const fallbackNonNativeToolCalls = nativeFinalizedToolCalls.map((toolCall) => ({
|
||||
...toolCall,
|
||||
isNativeToolCall: false,
|
||||
}))
|
||||
|
||||
let finalizedToolCalls: SubagentToolCall[] = []
|
||||
if (useNativeToolCalls) {
|
||||
finalizedToolCalls = nativeFinalizedToolCalls
|
||||
} else if (parsedNonNativeToolCalls.length > 0) {
|
||||
finalizedToolCalls = parsedNonNativeToolCalls
|
||||
} else if (fallbackNonNativeToolCalls.length > 0) {
|
||||
// Defensive fallback: if non-native mode receives structured tool call chunks,
|
||||
// execute them but serialize results as plain text to avoid tool_result pairing mismatches.
|
||||
Logger.warn(
|
||||
"[SubagentRunner] Received structured tool_calls while native tool calling is disabled; falling back to non-native result serialization.",
|
||||
)
|
||||
finalizedToolCalls = fallbackNonNativeToolCalls
|
||||
}
|
||||
const assistantContent = [] as any[]
|
||||
if (assistantText.trim().length > 0) {
|
||||
assistantContent.push({
|
||||
type: "text",
|
||||
text: assistantText,
|
||||
signature: assistantTextSignature,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (useNativeToolCalls) {
|
||||
assistantContent.push(...finalizedToolCalls.map(toAssistantToolUseBlock))
|
||||
}
|
||||
|
||||
if (assistantContent.length > 0) {
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
id: requestId,
|
||||
})
|
||||
}
|
||||
|
||||
if (finalizedToolCalls.length === 0) {
|
||||
emptyAssistantResponseRetries += 1
|
||||
if (emptyAssistantResponseRetries > MAX_EMPTY_ASSISTANT_RETRIES) {
|
||||
const error = "Subagent did not call attempt_completion."
|
||||
onProgress({ status: "failed", error, stats: { ...stats } })
|
||||
return { status: "failed", error, stats }
|
||||
if (streamResult.aborted) {
|
||||
return { status: "failed", error: "Subagent run cancelled." }
|
||||
}
|
||||
|
||||
// Mirror the main loop's no-tools-used nudge so empty/blank model turns
|
||||
// can recover without surfacing an immediate hard failure in subagent UI.
|
||||
if (assistantContent.length === 0) {
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Failure: I did not provide a response.",
|
||||
},
|
||||
],
|
||||
id: requestId,
|
||||
const calculatedRequestCost =
|
||||
streamResult.usage.totalCost ??
|
||||
calculateApiCostAnthropic(
|
||||
providerInfo.model.info,
|
||||
streamResult.usage.inputTokens,
|
||||
streamResult.usage.outputTokens,
|
||||
streamResult.usage.cacheWriteTokens,
|
||||
streamResult.usage.cacheReadTokens,
|
||||
)
|
||||
stats.totalCost += calculatedRequestCost || 0
|
||||
previousRequestTotalTokens =
|
||||
streamResult.usage.inputTokens +
|
||||
streamResult.usage.outputTokens +
|
||||
streamResult.usage.cacheWriteTokens +
|
||||
streamResult.usage.cacheReadTokens
|
||||
|
||||
const nativeFinalizedToolCalls = toolUseHandler.getAllFinalizedToolUses().map((toolCall, index) => ({
|
||||
toolUseId: resolveToolUseId(toolCall, index),
|
||||
id: toolCall.id,
|
||||
call_id: toolCall.call_id,
|
||||
signature: toolCall.signature,
|
||||
name: toolCall.name,
|
||||
input: toolCall.input,
|
||||
isNativeToolCall: true,
|
||||
}))
|
||||
const parsedNonNativeToolCalls = parseNonNativeToolCalls(streamResult.assistantText)
|
||||
const fallbackNonNativeToolCalls = nativeFinalizedToolCalls.map((toolCall) => ({
|
||||
...toolCall,
|
||||
isNativeToolCall: false,
|
||||
}))
|
||||
|
||||
let finalizedToolCalls: SubagentToolCall[] = []
|
||||
if (useNativeToolCalls) {
|
||||
finalizedToolCalls = nativeFinalizedToolCalls
|
||||
} else if (parsedNonNativeToolCalls.length > 0) {
|
||||
finalizedToolCalls = parsedNonNativeToolCalls
|
||||
} else if (fallbackNonNativeToolCalls.length > 0) {
|
||||
Logger.warn(
|
||||
"[SubagentRunner] Received structured tool_calls while native tool calling is disabled; falling back to non-native result serialization.",
|
||||
)
|
||||
finalizedToolCalls = fallbackNonNativeToolCalls
|
||||
}
|
||||
const assistantContent = [] as any[]
|
||||
if (streamResult.assistantText.trim().length > 0) {
|
||||
assistantContent.push({
|
||||
type: "text",
|
||||
text: streamResult.assistantText,
|
||||
signature: streamResult.assistantTextSignature,
|
||||
})
|
||||
}
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: formatResponse.noToolsUsed(useNativeToolCalls),
|
||||
},
|
||||
],
|
||||
})
|
||||
await delay(0)
|
||||
continue
|
||||
}
|
||||
emptyAssistantResponseRetries = 0
|
||||
if (useNativeToolCalls) {
|
||||
assistantContent.push(...finalizedToolCalls.map(toAssistantToolUseBlock))
|
||||
}
|
||||
|
||||
const toolResultBlocks = [] as any[]
|
||||
for (const call of finalizedToolCalls) {
|
||||
const toolName = call.name as ClineDefaultTool
|
||||
const toolCallParams = toToolUseParams(call.input)
|
||||
if (assistantContent.length > 0) {
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
id: streamResult.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
if (toolName === ClineDefaultTool.ATTEMPT) {
|
||||
const completionResult = toolCallParams.result?.trim()
|
||||
if (!completionResult) {
|
||||
const missingResultError = formatResponse.missingToolParameterError("result")
|
||||
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, missingResultError)
|
||||
if (finalizedToolCalls.length === 0) {
|
||||
emptyAssistantResponseRetries += 1
|
||||
if (emptyAssistantResponseRetries > MAX_EMPTY_ASSISTANT_RETRIES) {
|
||||
return { status: "failed", error: "Subagent did not call attempt_completion." }
|
||||
}
|
||||
|
||||
if (assistantContent.length === 0) {
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Failure: I did not provide a response." }],
|
||||
id: streamResult.requestId,
|
||||
})
|
||||
}
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: formatResponse.noToolsUsed(useNativeToolCalls) }],
|
||||
})
|
||||
await delay(0)
|
||||
return { status: "continue", nextInput: undefined }
|
||||
}
|
||||
emptyAssistantResponseRetries = 0
|
||||
|
||||
const toolResultBlocks = [] as any[]
|
||||
for (const call of finalizedToolCalls) {
|
||||
const toolName = call.name as ClineDefaultTool
|
||||
const toolCallParams = toToolUseParams(call.input)
|
||||
|
||||
if (toolName === ClineDefaultTool.ATTEMPT) {
|
||||
const completionResult = toolCallParams.result?.trim()
|
||||
if (!completionResult) {
|
||||
const missingResultError = formatResponse.missingToolParameterError("result")
|
||||
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, missingResultError)
|
||||
continue
|
||||
}
|
||||
|
||||
stats.toolCalls += 1
|
||||
onProgress({ stats: { ...stats } })
|
||||
const completed: SubagentRunResult = { status: "completed", result: completionResult, stats }
|
||||
return { status: "complete", output: completed }
|
||||
}
|
||||
|
||||
if (!SUBAGENT_ALLOWED_TOOLS.includes(toolName)) {
|
||||
const deniedResult = formatResponse.toolError(
|
||||
`Tool '${toolName}' is not available inside subagent runs.`,
|
||||
)
|
||||
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, deniedResult)
|
||||
continue
|
||||
}
|
||||
|
||||
const toolCallBlock: ToolUse = {
|
||||
type: "tool_use",
|
||||
name: toolName,
|
||||
params: toolCallParams,
|
||||
partial: false,
|
||||
isNativeToolCall: call.isNativeToolCall,
|
||||
call_id: call.call_id || call.toolUseId,
|
||||
signature: call.signature,
|
||||
}
|
||||
|
||||
if (call.call_id) {
|
||||
state.toolUseIdMap.set(call.call_id, call.toolUseId)
|
||||
}
|
||||
|
||||
onProgress({ latestToolCall: formatToolCallPreview(toolName, toolCallParams) })
|
||||
|
||||
const subagentConfig = this.createSubagentTaskConfig(state)
|
||||
const handler = this.baseConfig.coordinator.getHandler(toolName)
|
||||
let toolResult: unknown
|
||||
|
||||
if (!handler) {
|
||||
toolResult = formatResponse.toolError(`No handler registered for tool '${toolName}'.`)
|
||||
} else {
|
||||
try {
|
||||
toolResult = await this.baseConfig.coordinator.execute(subagentConfig, toolCallBlock)
|
||||
} catch (error) {
|
||||
toolResult = formatResponse.toolError((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
stats.toolCalls += 1
|
||||
onProgress({ stats: { ...stats } })
|
||||
onProgress({ status: "completed", result: completionResult, stats: { ...stats } })
|
||||
return { status: "completed", result: completionResult, stats }
|
||||
|
||||
const serializedToolResult = serializeToolResult(toolResult)
|
||||
const toolDescription = handler?.getDescription(toolCallBlock) || `[${toolName}]`
|
||||
pushSubagentToolResultBlock(toolResultBlocks, call, toolDescription, serializedToolResult)
|
||||
}
|
||||
|
||||
if (!SUBAGENT_ALLOWED_TOOLS.includes(toolName)) {
|
||||
const deniedResult = formatResponse.toolError(`Tool '${toolName}' is not available inside subagent runs.`)
|
||||
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, deniedResult)
|
||||
continue
|
||||
}
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: toolResultBlocks,
|
||||
})
|
||||
|
||||
const toolCallBlock: ToolUse = {
|
||||
type: "tool_use",
|
||||
name: toolName,
|
||||
params: toolCallParams,
|
||||
partial: false,
|
||||
isNativeToolCall: call.isNativeToolCall,
|
||||
call_id: call.call_id || call.toolUseId,
|
||||
signature: call.signature,
|
||||
}
|
||||
await delay(0)
|
||||
return { status: "continue", nextInput: undefined }
|
||||
},
|
||||
})
|
||||
|
||||
if (call.call_id) {
|
||||
state.toolUseIdMap.set(call.call_id, call.toolUseId)
|
||||
}
|
||||
|
||||
const latestToolCall = formatToolCallPreview(toolName, toolCallParams)
|
||||
onProgress({ latestToolCall })
|
||||
|
||||
const subagentConfig = this.createSubagentTaskConfig(state)
|
||||
const handler = this.baseConfig.coordinator.getHandler(toolName)
|
||||
let toolResult: unknown
|
||||
|
||||
if (!handler) {
|
||||
toolResult = formatResponse.toolError(`No handler registered for tool '${toolName}'.`)
|
||||
} else {
|
||||
try {
|
||||
toolResult = await handler.execute(subagentConfig, toolCallBlock)
|
||||
} catch (error) {
|
||||
toolResult = formatResponse.toolError((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
stats.toolCalls += 1
|
||||
onProgress({ stats: { ...stats } })
|
||||
|
||||
const serializedToolResult = serializeToolResult(toolResult)
|
||||
const toolDescription = handler?.getDescription(toolCallBlock) || `[${toolName}]`
|
||||
pushSubagentToolResultBlock(toolResultBlocks, call, toolDescription, serializedToolResult)
|
||||
}
|
||||
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: toolResultBlocks,
|
||||
})
|
||||
|
||||
await delay(0)
|
||||
if (loopResult.status === "complete") {
|
||||
onProgress({ status: "completed", result: loopResult.output.result, stats: { ...stats } })
|
||||
return loopResult.output
|
||||
}
|
||||
if (loopResult.status === "failed") {
|
||||
const error = loopResult.error || "Subagent execution failed."
|
||||
onProgress({ status: "failed", error, stats: { ...stats } })
|
||||
return { status: "failed", error, stats }
|
||||
}
|
||||
const cancelledError = "Subagent run cancelled."
|
||||
onProgress({ status: "failed", error: cancelledError, stats: { ...stats } })
|
||||
return { status: "failed", error: cancelledError, stats }
|
||||
} catch (error) {
|
||||
if (this.shouldAbort()) {
|
||||
const cancelledError = "Subagent run cancelled."
|
||||
@@ -673,19 +661,6 @@ export class SubagentRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetryInitialStreamError(error: unknown, providerId: string, modelId: string): boolean {
|
||||
// Mirror main loop behavior: do not auto-retry auth/balance failures.
|
||||
const parsedError = ClineError.transform(error, modelId, providerId)
|
||||
const isAuthError = parsedError.isErrorType(ClineErrorType.Auth)
|
||||
const isBalanceError = parsedError.isErrorType(ClineErrorType.Balance)
|
||||
|
||||
if (isAuthError || isBalanceError) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private compactConversationForContextWindow(conversation: ClineStorageMessage[]): boolean {
|
||||
const contextManager = new ContextManager()
|
||||
const optimizationResult = this.optimizeConversationForContextWindow(contextManager, conversation)
|
||||
@@ -729,27 +704,8 @@ export class SubagentRunner {
|
||||
return { didOptimize: true, needToTruncate: optimizationResult.needToTruncate }
|
||||
}
|
||||
|
||||
private shouldCompactBeforeNextRequest(
|
||||
previousRequestTotalTokens: number,
|
||||
api: ReturnType<typeof buildApiHandler>,
|
||||
modelId: string,
|
||||
): boolean {
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const useAutoCondense = this.baseConfig.services.stateManager.getGlobalSettingsKey("useAutoCondense")
|
||||
if (useAutoCondense && isNextGenModelFamily(modelId)) {
|
||||
const autoCondenseThreshold = this.baseConfig.services.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
|
||||
| number
|
||||
| undefined
|
||||
const roundedThreshold = autoCondenseThreshold ? Math.floor(contextWindow * autoCondenseThreshold) : maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return previousRequestTotalTokens >= thresholdTokens
|
||||
}
|
||||
|
||||
return previousRequestTotalTokens >= maxAllowedSize
|
||||
}
|
||||
|
||||
private async *createMessageWithInitialChunkRetry(
|
||||
api: ReturnType<typeof buildApiHandler>,
|
||||
api: ApiHandler,
|
||||
systemPrompt: string,
|
||||
conversation: ClineStorageMessage[],
|
||||
nativeTools: ClineTool[] | undefined,
|
||||
@@ -757,7 +713,7 @@ export class SubagentRunner {
|
||||
modelId: string,
|
||||
) {
|
||||
for (let attempt = 1; attempt <= MAX_INITIAL_STREAM_ATTEMPTS; attempt += 1) {
|
||||
const stream = api.createMessage(systemPrompt, conversation, nativeTools)
|
||||
const stream = this.coreAgent.createMessage(systemPrompt, conversation, nativeTools)
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
try {
|
||||
@@ -783,7 +739,7 @@ export class SubagentRunner {
|
||||
const shouldRetry =
|
||||
!this.shouldAbort() &&
|
||||
attempt < MAX_INITIAL_STREAM_ATTEMPTS &&
|
||||
this.shouldRetryInitialStreamError(error, providerId, modelId)
|
||||
this.coreAgent.classifyInitialStreamRetry(error, providerId, modelId).shouldRetry
|
||||
if (!shouldRetry) {
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { ClineContent } from "@shared/messages/content"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { ClineDefaultTool } from "@shared/tools"
|
||||
import type { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import * as vscode from "vscode"
|
||||
import { WorkspaceRootManager } from "@/core/workspace"
|
||||
import type { ContextManager } from "../../../context/context-management/ContextManager"
|
||||
import type { StateManager } from "../../../storage/StateManager"
|
||||
@@ -41,7 +40,6 @@ export interface TaskConfig {
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal" | "backgroundExec"
|
||||
enableParallelToolCalling: boolean
|
||||
isSubagentExecution: boolean
|
||||
context: vscode.ExtensionContext
|
||||
|
||||
// Multi-workspace support (optional for backward compatibility)
|
||||
workspaceManager?: WorkspaceRootManager
|
||||
|
||||
@@ -62,11 +62,11 @@ export class PatchParser {
|
||||
private parseNextAction(): void {
|
||||
const line = this.lines[this.index]
|
||||
|
||||
if (line.startsWith(PATCH_MARKERS.UPDATE)) {
|
||||
if (line?.startsWith(PATCH_MARKERS.UPDATE)) {
|
||||
this.parseUpdate(line.substring(PATCH_MARKERS.UPDATE.length).trim())
|
||||
} else if (line.startsWith(PATCH_MARKERS.DELETE)) {
|
||||
} else if (line?.startsWith(PATCH_MARKERS.DELETE)) {
|
||||
this.parseDelete(line.substring(PATCH_MARKERS.DELETE.length).trim())
|
||||
} else if (line.startsWith(PATCH_MARKERS.ADD)) {
|
||||
} else if (line?.startsWith(PATCH_MARKERS.ADD)) {
|
||||
this.parseAdd(line.substring(PATCH_MARKERS.ADD.length).trim())
|
||||
} else {
|
||||
throw new DiffError(`Unknown line while parsing: ${line}`)
|
||||
@@ -85,14 +85,14 @@ export class PatchParser {
|
||||
|
||||
this.index++
|
||||
const movePath = this.lines[this.index]?.startsWith(PATCH_MARKERS.MOVE)
|
||||
? this.lines[this.index++].substring(PATCH_MARKERS.MOVE.length).trim()
|
||||
? (this.lines[this.index++] ?? "").substring(PATCH_MARKERS.MOVE.length).trim()
|
||||
: undefined
|
||||
|
||||
if (!(path in this.currentFiles)) {
|
||||
throw new DiffError(`Update File Error: Missing File: ${path}`)
|
||||
}
|
||||
|
||||
const text = this.currentFiles[path]!
|
||||
const text = this.currentFiles[path] ?? ""
|
||||
const action = this.parseUpdateFile(text, path)
|
||||
action.movePath = movePath
|
||||
|
||||
@@ -114,8 +114,9 @@ export class PatchParser {
|
||||
]
|
||||
|
||||
while (!stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
|
||||
const defStr = this.lines[this.index]?.startsWith("@@ ") ? this.lines[this.index]!.substring(3) : undefined
|
||||
const sectionStr = this.lines[this.index] === "@@" ? this.lines[this.index] : undefined
|
||||
const currentLine = this.lines[this.index]
|
||||
const defStr = currentLine?.startsWith("@@ ") ? currentLine.substring(3) : undefined
|
||||
const sectionStr = currentLine === "@@" ? currentLine : undefined
|
||||
|
||||
if (defStr !== undefined || sectionStr !== undefined) {
|
||||
this.index++
|
||||
@@ -127,9 +128,10 @@ export class PatchParser {
|
||||
if (defStr?.trim()) {
|
||||
const canonDefStr = canonicalize(defStr.trim())
|
||||
for (let i = index; i < fileLines.length; i++) {
|
||||
if (canonicalize(fileLines[i]!) === canonDefStr || canonicalize(fileLines[i]!.trim()) === canonDefStr) {
|
||||
const fileLine = fileLines[i]
|
||||
if (fileLine && (canonicalize(fileLine) === canonDefStr || canonicalize(fileLine.trim()) === canonDefStr)) {
|
||||
index = i + 1
|
||||
if (canonicalize(fileLines[i]!.trim()) === canonDefStr && canonicalize(fileLines[i]!) !== canonDefStr) {
|
||||
if (canonicalize(fileLine.trim()) === canonDefStr && canonicalize(fileLine) !== canonDefStr) {
|
||||
this.fuzz++
|
||||
}
|
||||
break
|
||||
@@ -192,8 +194,11 @@ export class PatchParser {
|
||||
|
||||
const stopMarkers = [PATCH_MARKERS.END, PATCH_MARKERS.UPDATE, PATCH_MARKERS.DELETE, PATCH_MARKERS.ADD]
|
||||
|
||||
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index].startsWith(m.trim()))) {
|
||||
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
|
||||
const line = this.lines[this.index++]
|
||||
if (line === undefined) {
|
||||
break
|
||||
}
|
||||
if (!line.startsWith("+")) {
|
||||
throw new DiffError(`Invalid Add File line (missing '+'): ${line}`)
|
||||
}
|
||||
@@ -222,31 +227,29 @@ function calculateSimilarity(str1: string, str2: string): number {
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
function levenshteinDistance(str1: string, str2: string): number {
|
||||
const matrix: number[][] = []
|
||||
const rows = str2.length + 1
|
||||
const cols = str1.length + 1
|
||||
const matrix = new Array<number>(rows * cols).fill(0) // avoid undefined access with flat array
|
||||
|
||||
for (let i = 0; i <= str2.length; i++) {
|
||||
matrix[i] = [i]
|
||||
const at = (r: number, c: number): number => matrix[r * cols + c] ?? 0
|
||||
const set = (r: number, c: number, v: number) => {
|
||||
matrix[r * cols + c] = v
|
||||
}
|
||||
|
||||
for (let j = 0; j <= str1.length; j++) {
|
||||
matrix[0]![j] = j
|
||||
}
|
||||
for (let i = 0; i <= str2.length; i++) set(i, 0, i)
|
||||
for (let j = 0; j <= str1.length; j++) set(0, j, j)
|
||||
|
||||
for (let i = 1; i <= str2.length; i++) {
|
||||
for (let j = 1; j <= str1.length; j++) {
|
||||
if (str2[i - 1] === str1[j - 1]) {
|
||||
matrix[i]![j] = matrix[i - 1]![j - 1]!
|
||||
set(i, j, at(i - 1, j - 1))
|
||||
} else {
|
||||
matrix[i]![j] = Math.min(
|
||||
matrix[i - 1]![j - 1]! + 1, // substitution
|
||||
matrix[i]![j - 1]! + 1, // insertion
|
||||
matrix[i - 1]![j]! + 1, // deletion
|
||||
)
|
||||
set(i, j, 1 + Math.min(at(i - 1, j - 1), at(i, j - 1), at(i - 1, j)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str2.length]![str1.length]!
|
||||
return at(str2.length, str1.length)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,8 +359,8 @@ function peek(lines: string[], initialIndex: number): PeekResult {
|
||||
]
|
||||
|
||||
while (index < lines.length) {
|
||||
const s = lines[index]!
|
||||
if (stopMarkers.some((m) => s.startsWith(m.trim()))) {
|
||||
const s = lines[index]
|
||||
if (!s || stopMarkers.some((m) => s.startsWith(m.trim()))) {
|
||||
break
|
||||
}
|
||||
if (s === "***") {
|
||||
|
||||
@@ -20,7 +20,6 @@ export const TASK_CONFIG_KEYS = [
|
||||
"vscodeTerminalExecutionMode",
|
||||
"enableParallelToolCalling",
|
||||
"isSubagentExecution",
|
||||
"context",
|
||||
"taskState",
|
||||
"messageState",
|
||||
"api",
|
||||
|
||||
@@ -375,6 +375,45 @@ describe("PatchParser", () => {
|
||||
expectedError: /Unknown line while parsing/,
|
||||
},
|
||||
|
||||
// Duplicate delete error
|
||||
{
|
||||
name: "error: duplicate delete",
|
||||
patchLines: ["*** Begin Patch", "*** Delete File: old.ts", "*** Delete File: old.ts", "*** End Patch"],
|
||||
currentFiles: {
|
||||
"old.ts": "content",
|
||||
},
|
||||
expectedError: /Duplicate delete/,
|
||||
},
|
||||
|
||||
// Duplicate add error
|
||||
{
|
||||
name: "error: duplicate add",
|
||||
patchLines: [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: new.ts",
|
||||
"+content",
|
||||
"*** Add File: new.ts",
|
||||
"+content2",
|
||||
"*** End Patch",
|
||||
],
|
||||
currentFiles: {},
|
||||
expectedError: /Duplicate add/,
|
||||
},
|
||||
|
||||
// Invalid add line (missing '+')
|
||||
{
|
||||
name: "error: add file line missing '+'",
|
||||
patchLines: [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: new.ts",
|
||||
"+valid line",
|
||||
"invalid line without plus",
|
||||
"*** End Patch",
|
||||
],
|
||||
currentFiles: {},
|
||||
expectedError: /Invalid Add File line/,
|
||||
},
|
||||
|
||||
// Move operation
|
||||
{
|
||||
name: "update with move",
|
||||
@@ -782,34 +821,34 @@ describe("PatchParser", () => {
|
||||
expect(actualAction, `Action for ${filePath} should exist`).to.exist
|
||||
|
||||
// Check action type
|
||||
expect(actualAction!.type).to.equal(expectedAction.type)
|
||||
expect(actualAction.type).to.equal(expectedAction.type)
|
||||
|
||||
// Check newFile for ADD operations
|
||||
if (expectedAction.newFile !== undefined) {
|
||||
expect(actualAction!.newFile).to.equal(expectedAction.newFile)
|
||||
expect(actualAction?.newFile).to.equal(expectedAction.newFile)
|
||||
}
|
||||
|
||||
// Check movePath for MOVE operations
|
||||
if (expectedAction.movePath !== undefined) {
|
||||
expect(actualAction!.movePath).to.equal(expectedAction.movePath)
|
||||
expect(actualAction?.movePath).to.equal(expectedAction.movePath)
|
||||
}
|
||||
|
||||
// Check chunks if provided
|
||||
if (expectedAction.chunks !== undefined) {
|
||||
expect(actualAction!.chunks).to.have.lengthOf(expectedAction.chunks.length)
|
||||
expect(actualAction?.chunks).to.have.lengthOf(expectedAction.chunks.length)
|
||||
|
||||
for (let i = 0; i < expectedAction.chunks.length; i++) {
|
||||
const expectedChunk = expectedAction.chunks[i]
|
||||
const actualChunk = actualAction!.chunks[i]
|
||||
const actualChunk = actualAction?.chunks[i]
|
||||
|
||||
if (expectedChunk.origIndex !== undefined) {
|
||||
expect(actualChunk!.origIndex).to.equal(expectedChunk.origIndex)
|
||||
expect(actualChunk?.origIndex).to.equal(expectedChunk.origIndex)
|
||||
}
|
||||
if (expectedChunk.delLines !== undefined) {
|
||||
expect(actualChunk!.delLines).to.deep.equal(expectedChunk.delLines)
|
||||
expect(actualChunk?.delLines).to.deep.equal(expectedChunk.delLines)
|
||||
}
|
||||
if (expectedChunk.insLines !== undefined) {
|
||||
expect(actualChunk!.insLines).to.deep.equal(expectedChunk.insLines)
|
||||
expect(actualChunk?.insLines).to.deep.equal(expectedChunk.insLines)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,8 +896,8 @@ describe("PatchParser", () => {
|
||||
const result = parser.parse()
|
||||
|
||||
expect(result.patch.actions["large.ts"]).to.exist
|
||||
expect(result.patch.actions["large.ts"]!.chunks).to.have.lengthOf(1)
|
||||
expect(result.patch.actions["large.ts"]!.chunks[0]!.origIndex).to.equal(501)
|
||||
expect(result.patch.actions["large.ts"].chunks).to.have.lengthOf(1)
|
||||
expect(result.patch.actions["large.ts"].chunks[0].origIndex).to.equal(501)
|
||||
})
|
||||
|
||||
it("should handle unicode characters in content", () => {
|
||||
@@ -878,7 +917,7 @@ describe("PatchParser", () => {
|
||||
const result = parser.parse()
|
||||
|
||||
expect(result.patch.actions["unicode.ts"]).to.exist
|
||||
expect(result.patch.actions["unicode.ts"]!.chunks[0]!.insLines[0]).to.equal("const text = '你好'")
|
||||
expect(result.patch.actions["unicode.ts"]?.chunks[0]?.insLines[0]).to.equal("const text = '你好'")
|
||||
})
|
||||
|
||||
it("should handle empty lines in context", () => {
|
||||
@@ -891,6 +930,183 @@ describe("PatchParser", () => {
|
||||
|
||||
expect(result.patch.actions["test.ts"]).to.exist
|
||||
})
|
||||
|
||||
it("should handle updating an empty file", () => {
|
||||
// A file with only a single line (no context needed)
|
||||
const patchLines = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: empty.ts",
|
||||
"@@",
|
||||
"-old content",
|
||||
"+new content",
|
||||
"*** End Patch",
|
||||
]
|
||||
|
||||
const parser = new PatchParser(patchLines, {
|
||||
"empty.ts": "old content",
|
||||
})
|
||||
const result = parser.parse()
|
||||
|
||||
expect(result.patch.actions["empty.ts"]).to.exist
|
||||
expect(result.patch.actions["empty.ts"].chunks).to.have.lengthOf(1)
|
||||
expect(result.patch.actions["empty.ts"].chunks[0].origIndex).to.equal(0)
|
||||
expect(result.patch.actions["empty.ts"].chunks[0].delLines).to.deep.equal(["old content"])
|
||||
expect(result.patch.actions["empty.ts"].chunks[0].insLines).to.deep.equal(["new content"])
|
||||
})
|
||||
|
||||
it("should not include warnings property when there are no warnings", () => {
|
||||
const patchLines = ["*** Begin Patch", "*** Update File: test.ts", "@@", " context", "-old", "+new", "*** End Patch"]
|
||||
|
||||
const parser = new PatchParser(patchLines, {
|
||||
"test.ts": "context\nold",
|
||||
})
|
||||
const result = parser.parse()
|
||||
|
||||
// warnings should be absent (deleted) when empty
|
||||
expect(result.patch.warnings).to.be.undefined
|
||||
})
|
||||
|
||||
it("should handle add file with empty content (no lines)", () => {
|
||||
const patchLines = ["*** Begin Patch", "*** Add File: empty.ts", "*** End Patch"]
|
||||
|
||||
const parser = new PatchParser(patchLines, {})
|
||||
const result = parser.parse()
|
||||
|
||||
expect(result.patch.actions["empty.ts"]).to.exist
|
||||
expect(result.patch.actions["empty.ts"].type).to.equal(PatchActionType.ADD)
|
||||
expect(result.patch.actions["empty.ts"].newFile).to.equal("")
|
||||
})
|
||||
|
||||
it("should handle move without content changes", () => {
|
||||
// Move-only: no @@ section, just rename
|
||||
const patchLines = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: old.ts",
|
||||
"*** Move to: new.ts",
|
||||
"@@",
|
||||
" unchanged line",
|
||||
"*** End Patch",
|
||||
]
|
||||
|
||||
const parser = new PatchParser(patchLines, {
|
||||
"old.ts": "unchanged line",
|
||||
})
|
||||
const result = parser.parse()
|
||||
|
||||
expect(result.patch.actions["old.ts"]).to.exist
|
||||
expect(result.patch.actions["old.ts"].movePath).to.equal("new.ts")
|
||||
expect(result.patch.actions["old.ts"].chunks).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should handle unicode punctuation normalization (curly quotes match straight quotes)", () => {
|
||||
// File uses straight quotes, patch uses Unicode curly quotes — canonicalize should match them
|
||||
const patchLines = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: test.ts",
|
||||
"@@",
|
||||
" const x = \u201Chello\u201D", // LEFT/RIGHT DOUBLE QUOTATION MARK
|
||||
"-const y = \u2018old\u2019", // LEFT/RIGHT SINGLE QUOTATION MARK
|
||||
"+const y = 'new'",
|
||||
"*** End Patch",
|
||||
]
|
||||
|
||||
const parser = new PatchParser(patchLines, {
|
||||
"test.ts": "const x = \"hello\"\nconst y = 'old'",
|
||||
})
|
||||
const result = parser.parse()
|
||||
|
||||
expect(result.patch.actions["test.ts"]).to.exist
|
||||
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it("should handle unicode dash normalization (em-dash matches hyphen)", () => {
|
||||
// File uses a regular hyphen, patch uses an em-dash — canonicalize should match them
|
||||
const patchLines = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: test.ts",
|
||||
"@@",
|
||||
" // section \u2014 header", // EM DASH
|
||||
"-old",
|
||||
"+new",
|
||||
"*** End Patch",
|
||||
]
|
||||
|
||||
const parser = new PatchParser(patchLines, {
|
||||
"test.ts": "// section - header\nold",
|
||||
})
|
||||
const result = parser.parse()
|
||||
|
||||
expect(result.patch.actions["test.ts"]).to.exist
|
||||
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
it("should handle @@ context string not found in file (silently continues from current position)", () => {
|
||||
// When the @@ context string doesn't exist in the file, the parser should
|
||||
// continue searching from the current index rather than throwing
|
||||
const patchLines = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: test.ts",
|
||||
"@@ nonexistent function",
|
||||
" actual context",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** End Patch",
|
||||
]
|
||||
|
||||
const parser = new PatchParser(patchLines, {
|
||||
"test.ts": "actual context\nold",
|
||||
})
|
||||
const result = parser.parse()
|
||||
|
||||
// Should still find the chunk via context lines even though @@ string wasn't found
|
||||
expect(result.patch.actions["test.ts"]).to.exist
|
||||
})
|
||||
|
||||
it("should handle patch with only insertions (no deletions)", () => {
|
||||
const patchLines = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: test.ts",
|
||||
"@@",
|
||||
" line1",
|
||||
"+inserted line",
|
||||
" line2",
|
||||
"*** End Patch",
|
||||
]
|
||||
|
||||
const parser = new PatchParser(patchLines, {
|
||||
"test.ts": "line1\nline2",
|
||||
})
|
||||
const result = parser.parse()
|
||||
|
||||
expect(result.patch.actions["test.ts"]).to.exist
|
||||
const chunks = result.patch.actions["test.ts"].chunks
|
||||
expect(chunks).to.have.lengthOf(1)
|
||||
expect(chunks[0].delLines).to.deep.equal([])
|
||||
expect(chunks[0].insLines).to.deep.equal(["inserted line"])
|
||||
})
|
||||
|
||||
it("should handle patch with only deletions (no insertions)", () => {
|
||||
const patchLines = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: test.ts",
|
||||
"@@",
|
||||
" line1",
|
||||
"-line to delete",
|
||||
" line2",
|
||||
"*** End Patch",
|
||||
]
|
||||
|
||||
const parser = new PatchParser(patchLines, {
|
||||
"test.ts": "line1\nline to delete\nline2",
|
||||
})
|
||||
const result = parser.parse()
|
||||
|
||||
expect(result.patch.actions["test.ts"]).to.exist
|
||||
const chunks = result.patch.actions["test.ts"].chunks
|
||||
expect(chunks).to.have.lengthOf(1)
|
||||
expect(chunks[0].delLines).to.deep.equal(["line to delete"])
|
||||
expect(chunks[0].insLines).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Fuzz Scoring", () => {
|
||||
@@ -968,14 +1184,14 @@ describe("PatchParser", () => {
|
||||
|
||||
// Should have one valid chunk applied
|
||||
expect(result.patch.actions["test.ts"]).to.exist
|
||||
expect(result.patch.actions["test.ts"]!.chunks).to.have.lengthOf(1)
|
||||
expect(result.patch.actions["test.ts"]!.chunks[0]!.origIndex).to.equal(1)
|
||||
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
|
||||
expect(result.patch.actions["test.ts"].chunks[0].origIndex).to.equal(1)
|
||||
|
||||
// Should have warnings for skipped chunk
|
||||
expect(result.patch.warnings).to.exist
|
||||
expect(result.patch.warnings).to.have.lengthOf(1)
|
||||
expect(result.patch.warnings![0]!.path).to.equal("test.ts")
|
||||
expect(result.patch.warnings![0]!.message).to.match(/Could not find matching context/)
|
||||
expect(result.patch.warnings?.[0]?.path).to.equal("test.ts")
|
||||
expect(result.patch.warnings?.[0]?.message).to.match(/Could not find matching context/)
|
||||
})
|
||||
|
||||
it("should handle mixed valid and invalid chunks", () => {
|
||||
@@ -1003,7 +1219,7 @@ describe("PatchParser", () => {
|
||||
const result = parser.parse()
|
||||
|
||||
// Should have 2 valid chunks (1st and 3rd)
|
||||
expect(result.patch.actions["test.ts"]!.chunks).to.have.lengthOf(2)
|
||||
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(2)
|
||||
|
||||
// Should have 1 warning for skipped chunk
|
||||
expect(result.patch.warnings).to.have.lengthOf(1)
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
import { TaskConfig } from "../types/TaskConfig"
|
||||
|
||||
export * from "../types/TaskConfig"
|
||||
export * from "./ToolConstants"
|
||||
export { ToolDisplayUtils } from "./ToolDisplayUtils"
|
||||
export { ToolResultUtils } from "./ToolResultUtils"
|
||||
|
||||
export function getTaskCompletionTelemetry(config: TaskConfig) {
|
||||
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
|
||||
const apiConfig = config.services.stateManager.getApiConfiguration()
|
||||
const provider = currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
|
||||
const model = config.api.getModel()
|
||||
const durationMs = Math.max(0, Date.now() - config.taskState.taskStartTimeMs)
|
||||
|
||||
return {
|
||||
provider,
|
||||
modelId: model.id,
|
||||
apiFormat: model.info.apiFormat,
|
||||
timeToFirstTokenMs: config.taskState.taskFirstTokenTimeMs,
|
||||
durationMs,
|
||||
mode: currentMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import path from "node:path"
|
||||
import { Controller } from "@core/controller/index"
|
||||
import axios from "axios"
|
||||
import { readFile } from "fs/promises"
|
||||
import * as vscode from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ClineExtensionContext } from "@/shared/cline"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getNonce } from "./getNonce"
|
||||
@@ -12,7 +12,7 @@ export abstract class WebviewProvider {
|
||||
private static instance: WebviewProvider | null = null
|
||||
controller: Controller
|
||||
|
||||
constructor(readonly context: vscode.ExtensionContext) {
|
||||
constructor(readonly context: ClineExtensionContext) {
|
||||
WebviewProvider.instance = this
|
||||
|
||||
// Create controller with cache service
|
||||
@@ -140,7 +140,7 @@ export abstract class WebviewProvider {
|
||||
|
||||
return readFile(portFilePath, "utf8")
|
||||
.then((portFile) => {
|
||||
const port = parseInt(portFile.trim()) || DEFAULT_PORT
|
||||
const port = Number.parseInt(portFile.trim()) || DEFAULT_PORT
|
||||
Logger.info(`[getDevServerPort] Using dev server port ${port} from .vite-port file`)
|
||||
|
||||
return port
|
||||
|
||||
+36
-28
@@ -19,6 +19,7 @@ import path from "node:path"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
|
||||
import { createStorageContext } from "@/shared/storage/storage-context"
|
||||
import { readTextFromClipboard, writeTextToClipboard } from "@/utils/env"
|
||||
import { initialize, tearDown } from "./common"
|
||||
import { addToCline } from "./core/controller/commands/addToCline"
|
||||
@@ -28,7 +29,6 @@ import { improveWithCline } from "./core/controller/commands/improveWithCline"
|
||||
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
|
||||
import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebview"
|
||||
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
|
||||
import { StateManager } from "./core/storage/StateManager"
|
||||
import {
|
||||
cleanupMcpMarketplaceCatalogFromGlobalState,
|
||||
cleanupOldApiKey,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { VscodeTerminalManager } from "./hosts/vscode/terminal/VscodeTerminalManager"
|
||||
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
|
||||
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
|
||||
import { exportVSCodeStorageToSharedFiles } from "./hosts/vscode/vscode-to-file-migration"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { AuthService } from "./services/auth/AuthService"
|
||||
import { LogoutReason } from "./services/auth/types"
|
||||
@@ -66,18 +67,26 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// IMPORTANT: This must be done before any service can be registered
|
||||
setupHostProvider(context)
|
||||
|
||||
// 2. Register services and perform common initialization
|
||||
// IMPORTANT: Must be done after host provider is setup
|
||||
const webview = (await initialize(context)) as VscodeWebviewProvider
|
||||
// 2. Clean up legacy data patterns within VSCode's native storage.
|
||||
// Moves workspace→global keys, task history→file, custom instructions→rules, etc.
|
||||
// Must run BEFORE the file export so we copy clean state.
|
||||
await cleanupLegacyVSCodeStorage(context)
|
||||
|
||||
// 3. Register services and commands specific to VS Code
|
||||
// 3. One-time export of VSCode's native storage to shared file-backed stores.
|
||||
// After this, all platforms (VSCode, CLI, JetBrains) read from ~/.cline/data/.
|
||||
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
const storageContext = createStorageContext({ workspacePath })
|
||||
await exportVSCodeStorageToSharedFiles(context, storageContext)
|
||||
|
||||
// 4. Register services and perform common initialization
|
||||
// IMPORTANT: Must be done after host provider is setup and migrations are complete
|
||||
const webview = (await initialize(storageContext)) as VscodeWebviewProvider
|
||||
|
||||
// 5. Register services and commands specific to VS Code
|
||||
// Initialize test mode and add disposables to context
|
||||
const testModeWatchers = await initializeTestMode(webview)
|
||||
context.subscriptions.push(...testModeWatchers)
|
||||
|
||||
// Perform storage migrations that does not block extension activation
|
||||
performStorageMigrations(context)
|
||||
|
||||
// Initialize hook discovery cache for performance optimization
|
||||
HookDiscoveryCache.getInstance().initialize(
|
||||
context as any, // Adapt VSCode ExtensionContext to generic interface
|
||||
@@ -514,25 +523,24 @@ ${ctx.cellJson || "{}"}
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
context.secrets.onDidChange(async (event) => {
|
||||
if (event.key === "cline:clineAccountId") {
|
||||
// Check if the secret was removed (logout) or added/updated (login)
|
||||
const secretValue = await context.secrets.get(event.key)
|
||||
const activeWebview = WebviewProvider.getVisibleInstance()
|
||||
const controller = activeWebview?.controller
|
||||
// Listen for secrets changes (e.g., cross-window login/logout sync)
|
||||
const unsubSecrets = storageContext.secrets.onDidChange((event) => {
|
||||
if (event.key === "cline:clineAccountId") {
|
||||
const secretValue = storageContext.secrets.get<string>(event.key)
|
||||
const activeWebview = WebviewProvider.getVisibleInstance()
|
||||
const controller = activeWebview?.controller
|
||||
|
||||
const authService = AuthService.getInstance(controller)
|
||||
if (secretValue) {
|
||||
// Secret was added or updated - restore auth info (login from another window)
|
||||
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
} else {
|
||||
// Secret was removed - handle logout for all windows
|
||||
authService?.handleDeauth(LogoutReason.CROSS_WINDOW_SYNC)
|
||||
}
|
||||
const authService = AuthService.getInstance(controller)
|
||||
if (secretValue) {
|
||||
// Secret was added or updated - restore auth info (login from another window)
|
||||
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
} else {
|
||||
// Secret was removed - handle logout for all windows
|
||||
authService?.handleDeauth(LogoutReason.CROSS_WINDOW_SYNC)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
context.subscriptions.push({ dispose: unsubSecrets })
|
||||
|
||||
Logger.log(`[Cline] extension activated in ${performance.now() - activationStartTime} ms`)
|
||||
|
||||
@@ -706,11 +714,11 @@ if (IS_DEV) {
|
||||
}
|
||||
|
||||
// VSCode-specific storage migrations
|
||||
async function performStorageMigrations(context: ExtensionContext): Promise<void> {
|
||||
async function cleanupLegacyVSCodeStorage(context: ExtensionContext): Promise<void> {
|
||||
try {
|
||||
cleanupOldApiKey()
|
||||
await cleanupOldApiKey(context)
|
||||
// Migrate is not done if the new storage does not have the lastShownAnnouncementId flag
|
||||
const hasMigrated = StateManager.get().getGlobalStateKey("lastShownAnnouncementId")
|
||||
const hasMigrated = context.globalState.get("lastShownAnnouncementId")
|
||||
if (hasMigrated !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user